Skip to main content

tensor_wasm_api/
audit.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Structured audit log for state-mutating API calls.
5//!
6//! Implements PATH-TO-V1 v0.4 exit-criterion *Audit log*: every request
7//! that mutates server state writes one JSON record describing who called,
8//! what they did, what came back, and how long it took. Read-only calls
9//! (`GET /healthz`, `GET /metrics`, `GET /jobs/{id}`) emit nothing — they
10//! would drown the log in noise.
11//!
12//! ## Record shape
13//!
14//! Each record is a single JSON object emitted on its own line:
15//!
16//! ```json
17//! {
18//!   "ts_unix_ms": 1716491220123,
19//!   "request_id": "b8b6f7e0-...-...",
20//!   "actor": {
21//!     "kind": "bearer",
22//!     "token_id": 14217683...,
23//!     "scope": { "kind": "tenant_set", "tenants": [1, 2] }
24//!   },
25//!   "action": "invoke_function",
26//!   "resource": {
27//!     "function_id": "f47ac10b-...-...",
28//!     "tenant_id": 7
29//!   },
30//!   "outcome": { "status_code": 200, "error_kind": null },
31//!   "latency_ms": 12,
32//!   "peer_addr": "10.0.0.1:54321",
33//!   "client_cert_subject": null
34//! }
35//! ```
36//!
37//! Field-stability rules match the rest of the public surface: keys and the
38//! discriminant tags inside [`AuditAction`] and [`AuditActorKind`] are part
39//! of the public contract; additive forward-compatible fields are allowed.
40//! See [`docs/AUDIT-LOG.md`](../../../docs/AUDIT-LOG.md) for the wire-format
41//! contract.
42//!
43//! ## Sinks
44//!
45//! [`AuditSink`] is a small trait so consumers can plug in whatever
46//! durable target they prefer. The crate ships two implementations:
47//!
48//! * [`StdoutJsonSink`] — writes each record to stdout via `println!`. The
49//!   default sink: stdout is what container runtimes capture and ship.
50//!   Also mirrors each record at `tracing::info!` level so OpenTelemetry
51//!   tail-sampling sees it.
52//! * [`FileJsonSink`] — appends to a file. The file is opened append-only
53//!   and the writes serialise through an `Arc<std::sync::Mutex<File>>`. The
54//!   critical section is `write_all` + `flush` of ~512 bytes — measured
55//!   below in the [latency budget](#latency-budget) section. PERF: the
56//!   blocking write is offloaded to [`tokio::task::spawn_blocking`] when a
57//!   runtime is present so it never parks the async response path.
58//! * [`NoopSink`] — drops every record. Used when
59//!   `TENSOR_WASM_API_AUDIT_LOG=none` and exposed for tests.
60//!
61//! ## Configuration
62//!
63//! Read at server start via [`AuditConfig::from_env`]:
64//!
65//! | `TENSOR_WASM_API_AUDIT_LOG` value | Resulting sink                                  |
66//! |-----------------------------------|--------------------------------------------------|
67//! | unset / empty                     | [`StdoutJsonSink`] (default — stdout)            |
68//! | `none`                            | [`NoopSink`] (audit disabled)                    |
69//! | `stdout`                          | [`StdoutJsonSink`] (explicit form)               |
70//! | `file:/path/to/audit.log`         | [`FileJsonSink`] (append-only at that path)      |
71//!
72//! ## Latency budget
73//!
74//! The audit middleware runs *after* the handler completes, so it never
75//! delays the response from the application's point of view in the sense
76//! of "client sees the body later" — `axum::middleware::from_fn` happens to
77//! serialise the post-handler work into the response future, but the bytes
78//! the client receives flow once the inner future yields. The relevant
79//! cost is therefore the CPU time spent in [`AuditSink::emit`]:
80//!
81//! * `StdoutJsonSink`: `serde_json::to_string` on a ~10-field struct +
82//!   one `println!`. The macro takes the stdout lock for the duration of
83//!   the write. Locally measured at **~6–18 µs** per call on a modern x86
84//!   workstation (cold) — well inside the < 100 µs budget.
85//! * `FileJsonSink`: `serde_json::to_string` + `Mutex<File>::lock()` +
86//!   `write_all` + `flush`. The flush forces a `write(2)` per record so
87//!   crashes lose at most one record; on commodity NVMe the worst case
88//!   we observed is **~30–80 µs** (Linux ext4, single-writer). On a slow
89//!   disk or under contention this can spike — the documented mitigation
90//!   below applies.
91//!
92//! PERF (resolved): the blocking write no longer runs inline on the async
93//! response path. Both [`StdoutJsonSink`] and [`FileJsonSink`] now offload
94//! the `write_all` + `flush` (or `println!`) to
95//! [`tokio::task::spawn_blocking`] when an active tokio runtime is detected
96//! (the production middleware path), and fall back to a synchronous write
97//! otherwise (unit tests / non-async embedders). `emit` stays sync and the
98//! [`AuditSink`] trait stays object-safe: serialisation happens on the
99//! caller's thread and only the owned line (plus a cheap `Arc` file-handle
100//! clone for the file sink) is moved into the worker. Ordering across
101//! concurrent writers stays well-defined — the worker re-acquires the inner
102//! `Mutex<File>` and `O_APPEND` makes each append atomic, so records never
103//! interleave. Records are queued, never dropped, when the bound is hit.
104//!
105//! Read-only routes do **not** invoke the sink at all (the route filter
106//! short-circuits before serialisation), so the entire mechanism is zero
107//! cost on the dominant `GET /metrics` scrape path.
108
109use std::fs::{File, OpenOptions};
110use std::io::Write;
111use std::net::IpAddr;
112use std::path::PathBuf;
113use std::sync::{Arc, Mutex};
114use std::time::{SystemTime, UNIX_EPOCH};
115
116use dashmap::DashSet;
117use ipnet::IpNet;
118use serde::Serialize;
119use uuid::Uuid;
120
121use tensor_wasm_core::types::TenantId;
122
123use crate::rate_limit::{AuthContext, TokenId};
124use crate::token_scope::{TenantScope, TokenScope};
125
126/// Environment variable selecting the audit-log destination. See module
127/// docs for the accepted values.
128pub const ENV_AUDIT_LOG: &str = "TENSOR_WASM_API_AUDIT_LOG";
129
130/// Sentinel value that disables the audit log entirely
131/// (`TENSOR_WASM_API_AUDIT_LOG=none`).
132pub const AUDIT_LOG_DISABLED_VALUE: &str = "none";
133
134/// Sentinel value that selects the explicit stdout sink. Equivalent to
135/// leaving the variable unset.
136pub const AUDIT_LOG_STDOUT_VALUE: &str = "stdout";
137
138/// Prefix that selects the file sink: `file:/absolute/path/to/audit.log`.
139pub const AUDIT_LOG_FILE_PREFIX: &str = "file:";
140
141// ---------------------------------------------------------------------------
142// Records
143// ---------------------------------------------------------------------------
144
145/// Top-level audit record emitted once per state-mutating HTTP call.
146///
147/// Every field is serialised in a stable shape; see the module docs and
148/// `docs/AUDIT-LOG.md` for the wire-format contract.
149#[derive(Debug, Clone, Serialize)]
150pub struct AuditRecord {
151    /// Millisecond-precision Unix timestamp when the request completed
152    /// (i.e. when the audit record was synthesised).
153    pub ts_unix_ms: u64,
154    /// Unique identifier for this request. Generated by the audit
155    /// middleware on entry and inserted into the request extensions so
156    /// handlers can correlate logs against the audit trail.
157    pub request_id: Uuid,
158    /// Who issued the request.
159    pub actor: AuditActor,
160    /// What they tried to do.
161    pub action: AuditAction,
162    /// What they tried to do it to.
163    pub resource: AuditResource,
164    /// What happened.
165    pub outcome: AuditOutcome,
166    /// End-to-end handler latency, in milliseconds. Includes all
167    /// middleware that ran *after* `audit_log_middleware`.
168    pub latency_ms: u64,
169    /// Caller's peer socket address as observed by the listener, if the
170    /// router was bound with `axum::extract::connect_info::IntoMakeServiceWithConnectInfo`.
171    /// `None` in tests (which drive the router via `oneshot`) and in
172    /// proxy-fronted deployments that strip the connection.
173    pub peer_addr: Option<String>,
174    /// Client-certificate Subject DN as recovered from the
175    /// `X-Forwarded-Client-Cert` header (Envoy XFCC format). Populated
176    /// only when the reverse-proxy mTLS path described in
177    /// `docs/deployment/mtls.md` (W2.8) is in front of the gateway and
178    /// the request reached us with the header set.
179    pub client_cert_subject: Option<String>,
180}
181
182/// Authenticated principal that issued the request.
183#[derive(Debug, Clone, Serialize)]
184pub struct AuditActor {
185    /// Discriminator: bearer-token or dev-mode pass-through.
186    pub kind: AuditActorKind,
187    /// Stable process-local token id (from [`TokenId`]). `None` in dev
188    /// mode — the dev sentinel has no meaningful identity to log.
189    pub token_id: Option<TokenId>,
190    /// Stringified projection of the caller's [`TokenScope`].
191    pub scope: TokenScopeView,
192}
193
194/// Discriminator on [`AuditActor::kind`].
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
196#[serde(rename_all = "snake_case")]
197pub enum AuditActorKind {
198    /// Bearer-token authenticated caller. The default for production.
199    Bearer,
200    /// Dev-mode pass-through (no `TENSOR_WASM_API_TOKENS` configured).
201    /// Recorded explicitly so operators can spot accidental dev-mode
202    /// deployments in the audit stream.
203    Dev,
204}
205
206/// Stable, JSON-friendly projection of a [`TokenScope`].
207///
208/// The on-wire shape is tagged by `kind` so consumers can pattern-match
209/// without inspecting field presence:
210///
211/// * `{"kind":"wildcard"}` — wildcard scope (`tenant=*`).
212/// * `{"kind":"tenant_set","tenants":[1,2,3]}` — explicit tenant set.
213/// * `{"kind":"dev"}` — dev-mode synthetic scope.
214#[derive(Debug, Clone, Serialize)]
215#[serde(tag = "kind", rename_all = "snake_case")]
216pub enum TokenScopeView {
217    /// Token may address every tenant. Produced by `token:tenant=*` and
218    /// by legacy bare entries.
219    Wildcard,
220    /// Token is restricted to the listed tenant ids. Always sorted on
221    /// output so two equivalent scopes render byte-identically — useful
222    /// for diff-based audit-stream consumers.
223    TenantSet {
224        /// Sorted list of allowed tenant ids.
225        tenants: Vec<u64>,
226    },
227    /// Dev-mode pass-through. Distinct from `Wildcard` so an audit
228    /// reader can tell "the operator opted into wildcard" from "no auth
229    /// was configured".
230    Dev,
231}
232
233impl TokenScopeView {
234    /// Project a [`TokenScope`] into the JSON-shaped view. Caller is
235    /// expected to handle the dev-mode case explicitly via [`Self::Dev`].
236    pub fn from_scope(scope: &TokenScope) -> Self {
237        match &scope.tenants {
238            TenantScope::All => TokenScopeView::Wildcard,
239            TenantScope::Set(s) => {
240                let mut tenants: Vec<u64> = s.iter().map(|t| t.0).collect();
241                tenants.sort_unstable();
242                TokenScopeView::TenantSet { tenants }
243            }
244        }
245    }
246}
247
248/// Catalogue of state-mutating actions recognised by the audit log.
249///
250/// Tag strings (`create_function`, `delete_function`, `invoke_function`,
251/// `invoke_function_async`, `invoke_function_stream`) are part of the
252/// public contract.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
254#[serde(rename_all = "snake_case")]
255pub enum AuditAction {
256    /// `POST /functions` — deploy a new function.
257    CreateFunction,
258    /// `DELETE /functions/{id}` — remove a deployed function.
259    DeleteFunction,
260    /// `POST /functions/{id}/invoke` — synchronous invocation.
261    InvokeFunction,
262    /// `POST /functions/{id}/invoke-async` — fire-and-forget invocation.
263    InvokeFunctionAsync,
264    /// `POST /functions/{id}/invoke-stream` — streaming invocation
265    /// (SSE / chunked-transfer). Roadmap feature #2 — see
266    /// `docs/STREAMING.md`. The audit record carries the same
267    /// `function_id` / `tenant` shape as the other invoke actions so
268    /// operators can correlate streaming and non-streaming invocations
269    /// uniformly.
270    InvokeFunctionStream,
271}
272
273impl AuditAction {
274    /// Classify an `(method, path)` pair as one of the state-mutating
275    /// actions, or `None` for a read-only / unknown route.
276    ///
277    /// The path matcher walks segments so per-id routes (`{id}`) match
278    /// regardless of the concrete UUID. Read-only routes (`GET /healthz`,
279    /// `GET /metrics`, `GET /jobs/{id}`) all return `None` here, which is
280    /// what suppresses their audit emission.
281    pub fn classify(method: &axum::http::Method, path: &str) -> Option<Self> {
282        use axum::http::Method;
283        // Strip a single leading slash, then split into trimmed segments.
284        let trimmed = path.trim_start_matches('/');
285        let segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
286        match (method, segments.as_slice()) {
287            (&Method::POST, ["functions"]) => Some(AuditAction::CreateFunction),
288            (&Method::DELETE, ["functions", _id]) => Some(AuditAction::DeleteFunction),
289            (&Method::POST, ["functions", _id, "invoke"]) => Some(AuditAction::InvokeFunction),
290            (&Method::POST, ["functions", _id, "invoke-async"]) => {
291                Some(AuditAction::InvokeFunctionAsync)
292            }
293            (&Method::POST, ["functions", _id, "invoke-stream"]) => {
294                Some(AuditAction::InvokeFunctionStream)
295            }
296            _ => None,
297        }
298    }
299}
300
301/// Resource the action targeted. Both fields are optional: not every
302/// action binds to both, and `POST /functions` has no function id at the
303/// time the route is matched (the new id is assigned by the handler).
304#[derive(Debug, Clone, Serialize)]
305pub struct AuditResource {
306    /// Function id parsed from the route, when present.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub function_id: Option<Uuid>,
309    /// Tenant id resolved from the `X-TensorWasm-Tenant` header by the
310    /// `tenant_scope` middleware, when present. `None` for routes that do
311    /// not bind to a tenant.
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub tenant_id: Option<TenantId>,
314}
315
316/// What came back from the handler.
317#[derive(Debug, Clone, Serialize)]
318pub struct AuditOutcome {
319    /// HTTP status code returned to the client.
320    pub status_code: u16,
321    /// The `error.kind` value from the error envelope, when the response
322    /// was a non-2xx. Read from the response extensions; handlers stash
323    /// the kind there via [`AuditOutcomeExt`].
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub error_kind: Option<String>,
326}
327
328/// Marker type inserted into the response extensions by handlers so the
329/// audit middleware can recover the `error.kind` value without re-parsing
330/// the JSON body. The handler stamps this when it converts an `ApiError`
331/// into a response.
332#[derive(Debug, Clone)]
333pub struct AuditOutcomeExt {
334    /// Stable machine-readable identifier (the `kind` field on the wire).
335    pub error_kind: String,
336}
337
338// ---------------------------------------------------------------------------
339// Sinks
340// ---------------------------------------------------------------------------
341
342/// Pluggable target for [`AuditRecord`] emission.
343///
344/// Implementations must be `Send + Sync` because the audit middleware
345/// shares a single instance across all in-flight requests through an
346/// `Arc<dyn AuditSink>`.
347pub trait AuditSink: Send + Sync + std::fmt::Debug {
348    /// Persist `record`. Errors are intentionally absorbed: an audit
349    /// failure must never bubble up and corrupt a successful API call.
350    /// Implementations should log internally via `tracing::error!`
351    /// instead of propagating.
352    fn emit(&self, record: &AuditRecord);
353}
354
355/// Default sink — writes each record to stdout as a single JSON line and
356/// mirrors it at `tracing::info!` for OTel correlation.
357#[derive(Debug, Clone, Copy, Default)]
358pub struct StdoutJsonSink;
359
360impl StdoutJsonSink {
361    /// Construct a stdout sink.
362    pub fn new() -> Self {
363        Self
364    }
365}
366
367impl AuditSink for StdoutJsonSink {
368    fn emit(&self, record: &AuditRecord) {
369        let line = match serde_json::to_string(record) {
370            Ok(line) => line,
371            Err(e) => {
372                tracing::error!(
373                    target: "tensor_wasm_api::audit",
374                    error = %e,
375                    "failed to serialise audit record",
376                );
377                return;
378            }
379        };
380        // Mirror at info level so an OTel-attached subscriber can correlate
381        // the audit entry with the request trace. This is cheap (the
382        // subscriber's own machinery) and stays on the caller's thread so
383        // span context is preserved.
384        tracing::info!(
385            target: "tensor_wasm_api::audit",
386            audit = %line,
387            "audit",
388        );
389        // PERF: `println!` takes the process-wide stdout lock and performs
390        // a blocking write. On the async response path that parks a tokio
391        // worker on the stdout lock — under load, behind a slow/full pipe
392        // consumer it serialises all in-flight requests. Offload the write
393        // to the blocking pool when a runtime is present; fall back to the
394        // inline write otherwise (unit tests / non-async embedders) so the
395        // simple behaviour is preserved. `println!` adds the trailing
396        // newline, keeping the JSONL framing identical to before.
397        match tokio::runtime::Handle::try_current() {
398            Ok(_) => {
399                tokio::task::spawn_blocking(move || {
400                    println!("{line}");
401                });
402            }
403            Err(_) => {
404                println!("{line}");
405            }
406        }
407    }
408}
409
410/// File-backed sink. Records are appended to `path` as JSONL.
411///
412/// The file is opened once at construction with `O_APPEND` (or the
413/// platform equivalent) and writes serialise through an inner
414/// `std::sync::Mutex`. Each `emit` performs `write_all` + `flush` so a
415/// process crash loses at most the in-flight record. See module docs for
416/// the latency rationale.
417#[derive(Debug)]
418pub struct FileJsonSink {
419    /// Path the sink was constructed with — retained for diagnostics.
420    pub path: PathBuf,
421    /// The append-only file handle. `std::sync::Mutex` (not
422    /// `tokio::sync::Mutex`) because the critical section is sync I/O
423    /// with no `.await` point.
424    ///
425    /// PERF: wrapped in an `Arc` so [`AuditSink::emit`] can hand a cheap
426    /// clone of the handle to a [`tokio::task::spawn_blocking`] worker and
427    /// offload the blocking `write_all` + `flush` off the async response
428    /// path (see the `emit` impl below). The `Arc` is the minimal change
429    /// that lets the `&self`-borrowed mutex outlive the request future on
430    /// the blocking pool.
431    pub file: Arc<Mutex<File>>,
432}
433
434impl FileJsonSink {
435    /// Open `path` for append, creating it if missing. Returns an `io`
436    /// error if the OS refuses (typically permissions or a missing
437    /// parent directory).
438    pub fn open(path: impl Into<PathBuf>) -> std::io::Result<Self> {
439        let path = path.into();
440        let file = OpenOptions::new().create(true).append(true).open(&path)?;
441        Ok(Self {
442            path,
443            file: Arc::new(Mutex::new(file)),
444        })
445    }
446
447    /// Perform the blocking `write_all` + `flush` of one already-serialised
448    /// JSONL line. Factored out of [`AuditSink::emit`] so the same body can
449    /// run either inline (no tokio runtime) or inside a
450    /// [`tokio::task::spawn_blocking`] worker (the hot path). Errors are
451    /// logged, never propagated — an audit-write failure must not corrupt a
452    /// successful API call.
453    fn write_line(file: &Mutex<File>, path: &std::path::Path, line: &str) {
454        let mut guard = match file.lock() {
455            Ok(g) => g,
456            Err(poisoned) => {
457                // A poisoned mutex means a previous writer panicked
458                // mid-write; the file handle itself is still usable.
459                // Recover the inner guard and continue: dropping the
460                // audit stream on a stale panic is worse than a possibly
461                // truncated prior record.
462                tracing::warn!(
463                    target: "tensor_wasm_api::audit",
464                    path = %path.display(),
465                    "audit file mutex was poisoned; recovering",
466                );
467                poisoned.into_inner()
468            }
469        };
470        if let Err(e) = writeln!(&mut *guard, "{line}") {
471            tracing::error!(
472                target: "tensor_wasm_api::audit",
473                error = %e,
474                path = %path.display(),
475                "failed to write audit record",
476            );
477            return;
478        }
479        if let Err(e) = guard.flush() {
480            tracing::error!(
481                target: "tensor_wasm_api::audit",
482                error = %e,
483                path = %path.display(),
484                "failed to flush audit record",
485            );
486        }
487    }
488}
489
490impl AuditSink for FileJsonSink {
491    fn emit(&self, record: &AuditRecord) {
492        // Serialise on the caller's thread — it is cheap (~a few µs on a
493        // ~10-field struct) and lets us move only the owned `String` into
494        // the blocking worker.
495        let line = match serde_json::to_string(record) {
496            Ok(s) => s,
497            Err(e) => {
498                tracing::error!(
499                    target: "tensor_wasm_api::audit",
500                    error = %e,
501                    "failed to serialise audit record",
502                );
503                return;
504            }
505        };
506        // PERF: the blocking `write_all` + `flush` (a forced `write(2)` per
507        // record) previously ran inline under the `std::sync::Mutex`,
508        // serialising all in-flight requests on the audit critical section
509        // and parking a tokio worker thread on disk I/O. Offload it onto
510        // the blocking pool via `spawn_blocking` when we are inside a tokio
511        // runtime (the production response path); the response future is no
512        // longer held up by the disk write. Ordering across concurrent
513        // requests is still well-defined: each worker re-acquires the inner
514        // `Mutex<File>` and the OS `O_APPEND` guarantees atomic appends, so
515        // records never interleave even if two writes race. We do not drop
516        // records — `spawn_blocking` queues the closure rather than failing.
517        match tokio::runtime::Handle::try_current() {
518            Ok(_) => {
519                let file = Arc::clone(&self.file);
520                let path = self.path.clone();
521                tokio::task::spawn_blocking(move || {
522                    Self::write_line(&file, &path, &line);
523                });
524            }
525            Err(_) => {
526                // No runtime (unit tests, embedders driving the sink
527                // directly): fall back to the synchronous write so the
528                // behaviour and durability guarantee are unchanged.
529                Self::write_line(&self.file, &self.path, &line);
530            }
531        }
532    }
533}
534
535/// No-op sink. Selected by `TENSOR_WASM_API_AUDIT_LOG=none` for
536/// deployments where the operator aggregates Prometheus + OTel and does
537/// not want a third audit stream.
538#[derive(Debug, Clone, Copy, Default)]
539pub struct NoopSink;
540
541impl NoopSink {
542    /// Construct the no-op sink.
543    pub fn new() -> Self {
544        Self
545    }
546}
547
548impl AuditSink for NoopSink {
549    fn emit(&self, _record: &AuditRecord) {
550        // Intentionally empty.
551    }
552}
553
554// ---------------------------------------------------------------------------
555// Configuration
556// ---------------------------------------------------------------------------
557
558/// Resolved audit-log configuration loaded from the process environment.
559#[derive(Debug, Clone)]
560pub struct AuditConfig {
561    /// The selected sink, wrapped in an `Arc` so the middleware can
562    /// clone cheaply into each request.
563    pub sink: Arc<dyn AuditSink>,
564}
565
566impl AuditConfig {
567    /// Construct from an explicit sink. Used by tests and embedders.
568    pub fn from_sink(sink: Arc<dyn AuditSink>) -> Self {
569        Self { sink }
570    }
571
572    /// Convenience: build the default stdout config.
573    pub fn stdout() -> Self {
574        Self::from_sink(Arc::new(StdoutJsonSink::new()))
575    }
576
577    /// Convenience: build the disabled config (no-op sink).
578    pub fn disabled() -> Self {
579        Self::from_sink(Arc::new(NoopSink::new()))
580    }
581
582    /// Load from `$TENSOR_WASM_API_AUDIT_LOG`.
583    ///
584    /// Falls back to [`StdoutJsonSink`] when the variable is unset or
585    /// empty. A `file:` prefix that fails to open emits a startup
586    /// `tracing::error!` and degrades to stdout — refusing to start
587    /// because a log target is unavailable would be hostile in containers
588    /// where the path is mounted asynchronously.
589    pub fn from_env() -> Self {
590        let raw = std::env::var(ENV_AUDIT_LOG).unwrap_or_default();
591        let trimmed = raw.trim();
592        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(AUDIT_LOG_STDOUT_VALUE) {
593            tracing::info!(
594                target: "tensor_wasm_api::audit",
595                "audit log enabled — sink: stdout (JSONL)",
596            );
597            return Self::stdout();
598        }
599        if trimmed.eq_ignore_ascii_case(AUDIT_LOG_DISABLED_VALUE) {
600            tracing::warn!(
601                target: "tensor_wasm_api::audit",
602                env = ENV_AUDIT_LOG,
603                "audit log disabled ({}={})",
604                ENV_AUDIT_LOG,
605                AUDIT_LOG_DISABLED_VALUE,
606            );
607            return Self::disabled();
608        }
609        if let Some(path) = trimmed.strip_prefix(AUDIT_LOG_FILE_PREFIX) {
610            let path = path.trim();
611            if path.is_empty() {
612                tracing::error!(
613                    target: "tensor_wasm_api::audit",
614                    env = ENV_AUDIT_LOG,
615                    "audit log file: prefix had no path; falling back to stdout",
616                );
617                return Self::stdout();
618            }
619            match FileJsonSink::open(path) {
620                Ok(sink) => {
621                    tracing::info!(
622                        target: "tensor_wasm_api::audit",
623                        path,
624                        "audit log enabled — sink: file (JSONL, append-only)",
625                    );
626                    return Self::from_sink(Arc::new(sink));
627                }
628                Err(e) => {
629                    tracing::error!(
630                        target: "tensor_wasm_api::audit",
631                        path,
632                        error = %e,
633                        "failed to open audit log file; falling back to stdout",
634                    );
635                    return Self::stdout();
636                }
637            }
638        }
639        tracing::warn!(
640            target: "tensor_wasm_api::audit",
641            env = ENV_AUDIT_LOG,
642            value = trimmed,
643            "unrecognised TENSOR_WASM_API_AUDIT_LOG value; expected `none`, \
644             `stdout`, or `file:/path/to/log` — falling back to stdout",
645        );
646        Self::stdout()
647    }
648}
649
650impl Default for AuditConfig {
651    /// Default is stdout — same as the unset env behaviour.
652    fn default() -> Self {
653        Self::stdout()
654    }
655}
656
657// ---------------------------------------------------------------------------
658// Helpers used by the middleware
659// ---------------------------------------------------------------------------
660
661/// Current wall-clock time as milliseconds since the Unix epoch.
662///
663/// On the (vanishingly unlikely) event that the system clock is set
664/// before 1970-01-01, returns 0 rather than panicking. The audit middleware
665/// uses this so a misconfigured clock never aborts a state-mutating
666/// request.
667pub(crate) fn now_unix_ms() -> u64 {
668    SystemTime::now()
669        .duration_since(UNIX_EPOCH)
670        .map(|d| d.as_millis() as u64)
671        .unwrap_or(0)
672}
673
674/// Project an [`AuthContext`] into its actor representation. Dev contexts
675/// become [`AuditActorKind::Dev`] with no token id and a `Dev`-tagged
676/// scope view; everything else is a `Bearer` with the token's id and the
677/// real scope.
678pub(crate) fn actor_from_auth(auth: &AuthContext) -> AuditActor {
679    if auth.token_id == TokenId::DEV {
680        AuditActor {
681            kind: AuditActorKind::Dev,
682            token_id: None,
683            scope: TokenScopeView::Dev,
684        }
685    } else {
686        AuditActor {
687            kind: AuditActorKind::Bearer,
688            token_id: Some(auth.token_id),
689            scope: TokenScopeView::from_scope(&auth.scope),
690        }
691    }
692}
693
694/// Default actor used when the auth middleware did not run (e.g. a test
695/// that drives a sub-router without bearer_auth). Recorded as dev — the
696/// most conservative interpretation.
697pub(crate) fn default_actor() -> AuditActor {
698    AuditActor {
699        kind: AuditActorKind::Dev,
700        token_id: None,
701        scope: TokenScopeView::Dev,
702    }
703}
704
705/// Header name from which the audit middleware recovers the
706/// client-certificate Subject when an XFCC-aware reverse proxy is in
707/// front of the gateway. See `docs/deployment/mtls.md` §4.4.
708pub const HEADER_XFCC: &str = "X-Forwarded-Client-Cert";
709
710/// Environment variable carrying a comma-separated allowlist of IPv4/IPv6
711/// addresses or CIDR ranges whose `X-Forwarded-Client-Cert` headers the
712/// audit middleware will trust. Empty / unset = **never trust XFCC** (the
713/// safe default — see [`TrustedProxies`]).
714///
715/// Example: `TENSOR_WASM_API_TRUSTED_XFCC_PROXIES=10.0.0.0/8,127.0.0.1,::1`.
716pub const ENV_TRUSTED_XFCC_PROXIES: &str = "TENSOR_WASM_API_TRUSTED_XFCC_PROXIES";
717
718/// Maximum byte length of a client-certificate `Subject` that the audit
719/// middleware will record (see [`sanitise_cert_subject`]). A well-formed
720/// X.509 Distinguished Name is comfortably under this; anything longer is
721/// either malformed or hostile padding from a misconfigured / compromised
722/// trusted proxy. We pick the same 256-byte bound used for request paths
723/// (`middleware::MAX_PATH_LEN`) so every per-record string attribute shares
724/// one log-line budget.
725const MAX_CERT_SUBJECT_LEN: usize = 256;
726
727/// Allowlist of reverse-proxy peer addresses whose `X-Forwarded-Client-Cert`
728/// headers the audit middleware will trust.
729///
730/// # Threat model
731///
732/// XFCC is a header an upstream Envoy / Istio sidecar sets after performing
733/// mTLS termination on behalf of the gateway. Because it is a plain HTTP
734/// header, anything that can open a TCP connection to the gateway can also
735/// *claim* a `Subject=...` value. If the gateway forwards that claim into
736/// the audit log unchecked, an attacker on the same L3 segment (or with
737/// access to a misconfigured ingress) can write arbitrary identities into
738/// the audit stream — defeating non-repudiation, poisoning downstream SIEM
739/// tooling, and providing cover for malicious activity attributed to a
740/// fabricated certificate Subject.
741///
742/// The mitigation is layered: only consult XFCC when the *immediate TCP
743/// peer* (the L4 source IP axum's listener observed via `ConnectInfo`) is
744/// in an operator-curated allowlist of proxies known to terminate mTLS.
745/// Everything else has its XFCC header silently dropped. The allowlist is
746/// empty by default so a fresh deployment cannot accidentally trust an
747/// attacker.
748///
749/// # Configuration
750///
751/// Operators populate the allowlist via [`ENV_TRUSTED_XFCC_PROXIES`]: a
752/// comma-separated list of IPv4 / IPv6 addresses (`127.0.0.1`, `::1`) or
753/// CIDR ranges (`10.0.0.0/8`, `fd00::/8`). Parse failures on individual
754/// entries are logged at `warn` and the entry is dropped; a fully empty
755/// list (the default) means *no peer is trusted*.
756///
757/// # Sharing
758///
759/// `Clone` is intentionally cheap: the inner CIDR list is small and the
760/// per-peer warn-dedup `DashSet` is wrapped in `Arc`, so cloning into each
761/// request's extensions does not duplicate state.
762#[derive(Debug, Clone, Default)]
763pub struct TrustedProxies {
764    /// Parsed allowlist. Empty means "trust nobody" (the safe default).
765    ranges: Arc<Vec<IpNet>>,
766    /// Set of peer IPs we have already warned about. Shared across requests
767    /// so the warn fires at most once per unique untrusted peer per
768    /// process — not per request — to avoid drowning operators in log
769    /// noise during a probe storm.
770    warned: Arc<DashSet<IpAddr>>,
771}
772
773impl TrustedProxies {
774    /// Construct an empty allowlist (the safe default — trusts nobody).
775    pub fn empty() -> Self {
776        Self::default()
777    }
778
779    /// Load from [`ENV_TRUSTED_XFCC_PROXIES`].
780    ///
781    /// Unset / empty → empty allowlist (no peer is trusted, every inbound
782    /// `X-Forwarded-Client-Cert` header is dropped). Malformed individual
783    /// entries are skipped with a startup `tracing::warn!` so a typo in
784    /// one entry does not poison the whole allowlist.
785    pub fn from_env() -> Self {
786        let raw = std::env::var(ENV_TRUSTED_XFCC_PROXIES).unwrap_or_default();
787        Self::parse(&raw)
788    }
789
790    /// Parse a comma-separated list of IPs / CIDR ranges into an allowlist.
791    ///
792    /// Bare IPs (`127.0.0.1`, `::1`) are normalised into `/32` (IPv4) or
793    /// `/128` (IPv6) host routes so the membership test is uniform across
794    /// shapes. Whitespace around individual entries is tolerated.
795    pub fn parse(s: &str) -> Self {
796        let mut ranges: Vec<IpNet> = Vec::new();
797        let mut had_any = false;
798        for entry in s.split(',') {
799            let trimmed = entry.trim();
800            if trimmed.is_empty() {
801                continue;
802            }
803            had_any = true;
804            match trimmed.parse::<IpNet>() {
805                Ok(net) => ranges.push(net),
806                Err(_) => match trimmed.parse::<IpAddr>() {
807                    Ok(ip) => ranges.push(ip.into()),
808                    Err(e) => {
809                        tracing::warn!(
810                            target: "tensor_wasm_api::audit",
811                            env = ENV_TRUSTED_XFCC_PROXIES,
812                            entry = trimmed,
813                            error = %e,
814                            "ignored malformed XFCC trusted-proxy entry; \
815                             expected an IPv4/IPv6 address or CIDR range",
816                        );
817                    }
818                },
819            }
820        }
821        if had_any && ranges.is_empty() {
822            tracing::warn!(
823                target: "tensor_wasm_api::audit",
824                env = ENV_TRUSTED_XFCC_PROXIES,
825                "{} was set but no entries parsed; XFCC will be dropped \
826                 from every request",
827                ENV_TRUSTED_XFCC_PROXIES,
828            );
829        } else if ranges.is_empty() {
830            tracing::debug!(
831                target: "tensor_wasm_api::audit",
832                "{} unset; XFCC headers will be ignored from every peer \
833                 (set this to the IPs / CIDR ranges of your mTLS-terminating \
834                 proxies to enable XFCC ingestion)",
835                ENV_TRUSTED_XFCC_PROXIES,
836            );
837        } else {
838            tracing::info!(
839                target: "tensor_wasm_api::audit",
840                count = ranges.len(),
841                "XFCC trusted-proxy allowlist configured",
842            );
843        }
844        Self {
845            ranges: Arc::new(ranges),
846            warned: Arc::new(DashSet::new()),
847        }
848    }
849
850    /// `true` when no peer is trusted (the safe default). When this is
851    /// `true`, [`Self::contains`] returns `false` for every input.
852    pub fn is_empty(&self) -> bool {
853        self.ranges.is_empty()
854    }
855
856    /// Membership test: is `ip` in any of the configured ranges?
857    pub fn contains(&self, ip: IpAddr) -> bool {
858        self.ranges.iter().any(|net| net.contains(&ip))
859    }
860
861    /// Record a warn-level diagnostic exactly once per unique peer that
862    /// tried to send `X-Forwarded-Client-Cert` from outside the allowlist.
863    /// Subsequent requests from the same peer are silent. Returns `true`
864    /// if the warn was emitted (the peer was previously unseen).
865    pub fn warn_once_untrusted(&self, peer: IpAddr) -> bool {
866        if self.warned.insert(peer) {
867            tracing::warn!(
868                target: "tensor_wasm_api::audit",
869                env = ENV_TRUSTED_XFCC_PROXIES,
870                %peer,
871                "dropped X-Forwarded-Client-Cert from peer not in the \
872                 trusted-proxy allowlist — set {} if this peer is a known \
873                 mTLS-terminating proxy",
874                ENV_TRUSTED_XFCC_PROXIES,
875            );
876            true
877        } else {
878            false
879        }
880    }
881}
882
883/// Render a parsed XFCC `Subject` as a bounded, log-safe string before it
884/// enters the audit record.
885///
886/// The `Subject` is supplied by a trusted reverse proxy, but "trusted" only
887/// means the *immediate TCP peer* is on the allowlist — a misconfigured or
888/// compromised proxy can still forward an unbounded-length or
889/// control-char-laden value. Unlike the path / method / traceparent fields,
890/// this value previously reached `AuditRecord.client_cert_subject` verbatim,
891/// so we apply the same defences here, mirroring [`sanitise_traceparent`] in
892/// `middleware`:
893///
894/// 1. **Clamp length to [`MAX_CERT_SUBJECT_LEN`] bytes**, truncating on a
895///    UTF-8 char boundary so we never emit invalid UTF-8 downstream.
896/// 2. **Strip non-printable bytes** (anything outside `0x20..=0x7E`),
897///    which also removes CR / LF / NUL and so closes the log-injection and
898///    terminal-escape-smuggling channels.
899///
900/// When the input is already a clean ASCII-printable string short enough to
901/// fit, we return `Cow::Borrowed` to avoid the allocation.
902fn sanitise_cert_subject(raw: &str) -> std::borrow::Cow<'_, str> {
903    use std::borrow::Cow;
904
905    // Fast path: already short, already printable ASCII -> borrow.
906    let is_clean =
907        raw.len() <= MAX_CERT_SUBJECT_LEN && raw.bytes().all(|b| (0x20..=0x7E).contains(&b));
908    if is_clean {
909        return Cow::Borrowed(raw);
910    }
911
912    // Slow path: build a filtered, clamped owned copy. Walk by char so
913    // truncation never lands mid-codepoint, and skip anything outside
914    // printable ASCII.
915    let mut out = String::with_capacity(raw.len().min(MAX_CERT_SUBJECT_LEN));
916    for ch in raw.chars() {
917        let b = ch as u32;
918        if !(0x20..=0x7E).contains(&b) {
919            continue;
920        }
921        let ch_len = ch.len_utf8();
922        if out.len() + ch_len > MAX_CERT_SUBJECT_LEN {
923            break;
924        }
925        out.push(ch);
926    }
927    Cow::Owned(out)
928}
929
930/// Recover the `Subject="..."` field from an Envoy-style XFCC header
931/// value. Returns the contents of the first matched `Subject="..."`
932/// component, unescaping doubled `\"` sequences. `None` if the header is
933/// absent or shaped unexpectedly — we deliberately do not surface a
934/// parse error to the audit record because XFCC is a best-effort
935/// optional field.
936///
937/// # Threat model
938///
939/// `X-Forwarded-Client-Cert` is a free-form HTTP header. Any TCP peer can
940/// claim an arbitrary `Subject=...` value, so this function must only be
941/// invoked when the *immediate TCP peer* has already been authenticated as
942/// a trusted reverse proxy. Callers in the audit pipeline route through
943/// [`extract_client_cert_subject_gated`] which performs that check; this
944/// raw parser is `pub(crate)` so unit tests of the parser shape can call
945/// it directly without round-tripping through axum extensions.
946pub(crate) fn extract_client_cert_subject(headers: &axum::http::HeaderMap) -> Option<String> {
947    let raw = headers.get(HEADER_XFCC)?.to_str().ok()?;
948    // XFCC components are `;`-separated; each component is `key=value`.
949    // The value may be a bare token or a double-quoted string with `\"`
950    // escapes per the Envoy spec.
951    for component in raw.split(';') {
952        let (k, v) = component.split_once('=')?;
953        if k.trim().eq_ignore_ascii_case("Subject") {
954            let v = v.trim();
955            // Strip surrounding quotes if present; unescape `\"`.
956            let inner = if v.starts_with('"') && v.ends_with('"') && v.len() >= 2 {
957                &v[1..v.len() - 1]
958            } else {
959                v
960            };
961            // Sanitise before the value can reach the audit record: a
962            // misconfigured / compromised trusted proxy could forge an
963            // oversized or control-char-laden Subject. Bound the length and
964            // strip non-printable bytes, mirroring the other audit-field
965            // sanitisers.
966            let unescaped = inner.replace("\\\"", "\"");
967            return Some(sanitise_cert_subject(&unescaped).into_owned());
968        }
969    }
970    None
971}
972
973/// XFCC-spoofing-resistant wrapper around [`extract_client_cert_subject`].
974///
975/// # Threat model
976///
977/// `X-Forwarded-Client-Cert` is intended as a "trust path" header: an
978/// upstream Envoy / Istio sidecar terminates mTLS, validates the client
979/// certificate, and forwards the validated `Subject` DN into the gateway
980/// via this header so the audit log can record the certificate identity
981/// alongside the bearer-token identity. The header carries **no
982/// cryptographic guarantee on its own** — anything able to open a TCP
983/// connection to the gateway can also set any header it likes. Operators
984/// that deploy the gateway directly (no mTLS proxy in front) would, prior
985/// to this gate, write attacker-supplied `Subject` values straight into
986/// the audit stream, breaking non-repudiation and providing cover for
987/// abuse attributed to a forged certificate.
988///
989/// The gate restores the trust path. Only when the *immediate TCP peer*
990/// (the L4 source axum observes via `ConnectInfo`) is in the
991/// operator-curated [`TrustedProxies`] allowlist do we consult the header.
992/// Every other peer has its XFCC dropped silently — with a one-shot
993/// `tracing::warn!` per unique peer to surface possible misconfigurations
994/// without flooding logs during a probe storm.
995///
996/// # Behaviour
997///
998/// * `peer_ip = None` (e.g. a test driving the router via `oneshot`, or a
999///   listener bound without `IntoMakeServiceWithConnectInfo`) → drop the
1000///   header. We cannot validate the peer, so we cannot trust the claim.
1001/// * `peer_ip = Some(ip)` and `trusted.contains(ip)` → parse the header
1002///   as before.
1003/// * `peer_ip = Some(ip)` and not trusted → drop the header. If the
1004///   header was actually present, emit a one-shot warn for this peer.
1005pub(crate) fn extract_client_cert_subject_gated(
1006    headers: &axum::http::HeaderMap,
1007    peer_ip: Option<IpAddr>,
1008    trusted: &TrustedProxies,
1009) -> Option<String> {
1010    let Some(ip) = peer_ip else {
1011        // Unknown peer: cannot validate trust, drop the claim. We do not
1012        // warn here because the absence of ConnectInfo is structural
1013        // (test harness, embedded use) rather than indicative of attack.
1014        return None;
1015    };
1016    if trusted.contains(ip) {
1017        return extract_client_cert_subject(headers);
1018    }
1019    if headers.contains_key(HEADER_XFCC) {
1020        trusted.warn_once_untrusted(ip);
1021    }
1022    None
1023}
1024
1025// ---------------------------------------------------------------------------
1026// Middleware
1027// ---------------------------------------------------------------------------
1028
1029/// Axum middleware that synthesises one [`AuditRecord`] per state-mutating
1030/// HTTP call and dispatches it to the configured [`AuditSink`].
1031///
1032/// Wiring contract:
1033///
1034/// * The router must layer `axum::Extension(AuditConfig)` so this
1035///   middleware can recover the sink.
1036/// * Run this middleware **after** `bearer_auth` and `tenant_scope` so the
1037///   `AuthContext` and `Extension<TenantId>` are populated by the time
1038///   the audit record is built.
1039/// * The route-shape filter ([`AuditAction::classify`]) suppresses
1040///   emission for read-only routes (`GET /healthz`, `GET /metrics`,
1041///   `GET /jobs/{id}`); those calls flow through with zero serialisation
1042///   cost.
1043///
1044/// On entry the middleware stamps the request extensions with a fresh
1045/// [`Uuid`] so handlers can correlate their own logs against the audit
1046/// trail (recovered via `Extension<Uuid>` or directly from request
1047/// extensions). The same id appears in [`AuditRecord::request_id`].
1048pub async fn audit_log_middleware(
1049    mut req: axum::extract::Request,
1050    next: axum::middleware::Next,
1051) -> axum::response::Response {
1052    use std::time::Instant;
1053
1054    let method = req.method().clone();
1055    let path = req.uri().path().to_owned();
1056    let action = AuditAction::classify(&method, &path);
1057
1058    // Read-only / unknown route: skip the entire audit path.
1059    let Some(action) = action else {
1060        return next.run(req).await;
1061    };
1062
1063    // Stamp the request with a stable id so handlers can correlate their
1064    // own logs against the audit trail.
1065    let request_id = Uuid::new_v4();
1066    req.extensions_mut().insert(request_id);
1067
1068    // Snapshot inputs that the downstream handler may overwrite or
1069    // consume before the response is produced. If no `AuditConfig` is
1070    // present in the extensions (e.g. a test driver bypassed the
1071    // production router), fall back to the no-op sink — emitting to
1072    // stdout from a misconfigured router would surprise integration
1073    // tests that pre-date the audit middleware.
1074    let cfg = req
1075        .extensions()
1076        .get::<AuditConfig>()
1077        .cloned()
1078        .unwrap_or_else(AuditConfig::disabled);
1079    let actor = req
1080        .extensions()
1081        .get::<AuthContext>()
1082        .map(actor_from_auth)
1083        .unwrap_or_else(default_actor);
1084    let tenant_id = req.extensions().get::<TenantId>().copied();
1085    let function_id = parse_function_id_from_path(&path);
1086    // XFCC spoofing mitigation: only consult `X-Forwarded-Client-Cert`
1087    // when the immediate TCP peer is in the operator-curated trusted-
1088    // proxy allowlist. Missing extension → safe-default empty allowlist
1089    // → drop the header. See `TrustedProxies` and
1090    // `extract_client_cert_subject_gated` for the threat model.
1091    let connect_info = req
1092        .extensions()
1093        .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
1094        .map(|ci| ci.0);
1095    let peer_ip = connect_info.map(|sa| sa.ip());
1096    let trusted_proxies = req
1097        .extensions()
1098        .get::<TrustedProxies>()
1099        .cloned()
1100        .unwrap_or_default();
1101    let client_cert_subject =
1102        extract_client_cert_subject_gated(req.headers(), peer_ip, &trusted_proxies);
1103    let peer_addr = connect_info.map(|sa| sa.to_string());
1104
1105    let start = Instant::now();
1106    let response = next.run(req).await;
1107    let elapsed = start.elapsed();
1108
1109    let status_code = response.status().as_u16();
1110    let error_kind = response
1111        .extensions()
1112        .get::<AuditOutcomeExt>()
1113        .map(|e| e.error_kind.clone());
1114
1115    let record = AuditRecord {
1116        ts_unix_ms: now_unix_ms(),
1117        request_id,
1118        actor,
1119        action,
1120        resource: AuditResource {
1121            function_id,
1122            tenant_id,
1123        },
1124        outcome: AuditOutcome {
1125            status_code,
1126            error_kind,
1127        },
1128        latency_ms: elapsed.as_millis() as u64,
1129        peer_addr,
1130        client_cert_subject,
1131    };
1132    cfg.sink.emit(&record);
1133    response
1134}
1135
1136/// Recover the function id from a `/functions/{id}` or
1137/// `/functions/{id}/invoke[-async|-stream]` path, if present and a
1138/// valid UUID.
1139fn parse_function_id_from_path(path: &str) -> Option<Uuid> {
1140    let trimmed = path.trim_start_matches('/');
1141    let segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
1142    // [`functions`, <id>, ...]
1143    if segments.len() >= 2 && segments[0] == "functions" {
1144        return Uuid::parse_str(segments[1]).ok();
1145    }
1146    None
1147}
1148
1149// ---------------------------------------------------------------------------
1150// Tests
1151// ---------------------------------------------------------------------------
1152
1153#[cfg(test)]
1154mod tests {
1155    use super::*;
1156    use std::sync::Mutex as StdMutex;
1157    use std::sync::OnceLock;
1158
1159    /// Capturing sink used by the unit tests in this module to assert
1160    /// `emit` was called with the expected record shape, without
1161    /// inspecting stdout.
1162    #[derive(Debug, Default)]
1163    struct CapturingSink {
1164        records: StdMutex<Vec<AuditRecord>>,
1165    }
1166
1167    impl CapturingSink {
1168        fn new() -> Self {
1169            Self::default()
1170        }
1171        fn snapshot(&self) -> Vec<AuditRecord> {
1172            self.records.lock().unwrap().clone()
1173        }
1174    }
1175
1176    impl AuditSink for CapturingSink {
1177        fn emit(&self, record: &AuditRecord) {
1178            self.records.lock().unwrap().push(record.clone());
1179        }
1180    }
1181
1182    fn sample_record() -> AuditRecord {
1183        AuditRecord {
1184            ts_unix_ms: 1_716_491_220_123,
1185            request_id: Uuid::nil(),
1186            actor: AuditActor {
1187                kind: AuditActorKind::Bearer,
1188                token_id: Some(TokenId(1234)),
1189                scope: TokenScopeView::Wildcard,
1190            },
1191            action: AuditAction::CreateFunction,
1192            resource: AuditResource {
1193                function_id: None,
1194                tenant_id: Some(TenantId(7)),
1195            },
1196            outcome: AuditOutcome {
1197                status_code: 200,
1198                error_kind: None,
1199            },
1200            latency_ms: 12,
1201            peer_addr: None,
1202            client_cert_subject: None,
1203        }
1204    }
1205
1206    #[test]
1207    fn classify_state_mutating_routes() {
1208        use axum::http::Method;
1209        assert_eq!(
1210            AuditAction::classify(&Method::POST, "/functions"),
1211            Some(AuditAction::CreateFunction),
1212        );
1213        assert_eq!(
1214            AuditAction::classify(
1215                &Method::DELETE,
1216                "/functions/f47ac10b-58cc-4372-a567-0e02b2c3d479",
1217            ),
1218            Some(AuditAction::DeleteFunction),
1219        );
1220        assert_eq!(
1221            AuditAction::classify(
1222                &Method::POST,
1223                "/functions/f47ac10b-58cc-4372-a567-0e02b2c3d479/invoke",
1224            ),
1225            Some(AuditAction::InvokeFunction),
1226        );
1227        assert_eq!(
1228            AuditAction::classify(
1229                &Method::POST,
1230                "/functions/f47ac10b-58cc-4372-a567-0e02b2c3d479/invoke-async",
1231            ),
1232            Some(AuditAction::InvokeFunctionAsync),
1233        );
1234        assert_eq!(
1235            AuditAction::classify(
1236                &Method::POST,
1237                "/functions/f47ac10b-58cc-4372-a567-0e02b2c3d479/invoke-stream",
1238            ),
1239            Some(AuditAction::InvokeFunctionStream),
1240        );
1241    }
1242
1243    #[test]
1244    fn classify_read_only_routes_returns_none() {
1245        use axum::http::Method;
1246        assert!(AuditAction::classify(&Method::GET, "/healthz").is_none());
1247        assert!(AuditAction::classify(&Method::GET, "/metrics").is_none());
1248        assert!(
1249            AuditAction::classify(&Method::GET, "/jobs/abcd-1234").is_none(),
1250            "GET /jobs/<id> is read-only and must be filtered out",
1251        );
1252        assert!(AuditAction::classify(&Method::GET, "/").is_none());
1253    }
1254
1255    #[test]
1256    fn record_serialises_with_stable_keys() {
1257        let rec = sample_record();
1258        let v: serde_json::Value = serde_json::to_value(&rec).expect("serialises");
1259        assert_eq!(v["ts_unix_ms"], 1_716_491_220_123u64);
1260        assert_eq!(v["action"], "create_function");
1261        assert_eq!(v["actor"]["kind"], "bearer");
1262        assert_eq!(v["actor"]["scope"]["kind"], "wildcard");
1263        assert_eq!(v["resource"]["tenant_id"], 7);
1264        assert_eq!(v["outcome"]["status_code"], 200);
1265        // The optional `function_id` is suppressed when None.
1266        assert!(v["resource"].get("function_id").is_none());
1267        // The optional `error_kind` is suppressed when None.
1268        assert!(v["outcome"].get("error_kind").is_none());
1269    }
1270
1271    #[test]
1272    fn record_round_trips_through_serde_json() {
1273        // The wire schema is part of the contract; round-tripping
1274        // proves the serialized form deserialises cleanly into a
1275        // structurally-equivalent shape (we don't derive Deserialize
1276        // on `AuditRecord` itself to avoid leaking it as a public input
1277        // type, so we round-trip through `serde_json::Value`).
1278        let rec = sample_record();
1279        let s = serde_json::to_string(&rec).expect("serialises");
1280        let back: serde_json::Value = serde_json::from_str(&s).expect("deserialises");
1281        assert_eq!(back["action"], "create_function");
1282        assert_eq!(back["request_id"], "00000000-0000-0000-0000-000000000000");
1283    }
1284
1285    #[test]
1286    fn token_scope_view_from_scope_is_stable_order() {
1287        // Build a scope from a `HashSet`-derived TokenScope and verify
1288        // the projection sorts the tenant ids so two equivalent scopes
1289        // render byte-identically.
1290        let scope = TokenScope::from_tenants([TenantId(3), TenantId(1), TenantId(2)]);
1291        let view = TokenScopeView::from_scope(&scope);
1292        match view {
1293            TokenScopeView::TenantSet { tenants } => {
1294                assert_eq!(tenants, vec![1, 2, 3]);
1295            }
1296            other => panic!("expected TenantSet, got {other:?}"),
1297        }
1298    }
1299
1300    #[test]
1301    fn actor_from_dev_context_renders_as_dev() {
1302        let ctx = AuthContext::dev();
1303        let actor = actor_from_auth(&ctx);
1304        assert_eq!(actor.kind, AuditActorKind::Dev);
1305        assert!(actor.token_id.is_none());
1306        assert!(matches!(actor.scope, TokenScopeView::Dev));
1307    }
1308
1309    #[test]
1310    fn actor_from_bearer_context_renders_as_bearer() {
1311        let ctx = AuthContext::with_scope("alpha", TokenScope::from_tenants([TenantId(1)]));
1312        let actor = actor_from_auth(&ctx);
1313        assert_eq!(actor.kind, AuditActorKind::Bearer);
1314        assert!(actor.token_id.is_some());
1315        match actor.scope {
1316            TokenScopeView::TenantSet { tenants } => assert_eq!(tenants, vec![1]),
1317            other => panic!("expected TenantSet, got {other:?}"),
1318        }
1319    }
1320
1321    #[test]
1322    fn noop_sink_drops_records() {
1323        let sink = NoopSink::new();
1324        sink.emit(&sample_record());
1325        // No assertion beyond "does not panic" — the sink is a no-op
1326        // by definition.
1327    }
1328
1329    #[test]
1330    fn capturing_sink_records_emission() {
1331        let sink = CapturingSink::new();
1332        sink.emit(&sample_record());
1333        sink.emit(&sample_record());
1334        assert_eq!(sink.snapshot().len(), 2);
1335    }
1336
1337    #[test]
1338    fn file_sink_appends_jsonl_records() {
1339        let dir = std::env::temp_dir();
1340        // Unique per-process file so parallel test runs don't collide.
1341        static N: OnceLock<std::sync::atomic::AtomicU64> = OnceLock::new();
1342        let n = N
1343            .get_or_init(|| std::sync::atomic::AtomicU64::new(0))
1344            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1345        let path = dir.join(format!(
1346            "tensor-wasm-audit-test-{}-{n}.log",
1347            std::process::id(),
1348        ));
1349        // Clean any prior file from a flaky previous run.
1350        let _ = std::fs::remove_file(&path);
1351        let sink = FileJsonSink::open(&path).expect("opens");
1352        sink.emit(&sample_record());
1353        sink.emit(&sample_record());
1354        let body = std::fs::read_to_string(&path).expect("reads");
1355        let lines: Vec<&str> = body.lines().collect();
1356        assert_eq!(lines.len(), 2);
1357        for line in lines {
1358            let v: serde_json::Value = serde_json::from_str(line).expect("each line is JSON");
1359            assert_eq!(v["action"], "create_function");
1360        }
1361        let _ = std::fs::remove_file(&path);
1362    }
1363
1364    /// PERF: under an active tokio runtime `FileJsonSink::emit` offloads the
1365    /// write to `spawn_blocking`. This exercises that path and confirms the
1366    /// records still land (queued, never dropped). We await the offloaded
1367    /// work by draining the blocking pool: a `spawn_blocking` fence resolves
1368    /// only after earlier-queued blocking tasks have had a chance to run on
1369    /// the (multi-thread) pool; we then poll the file with a bounded retry
1370    /// to avoid a flaky race on slow CI.
1371    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1372    async fn file_sink_offloads_write_under_runtime() {
1373        let dir = std::env::temp_dir();
1374        static N: OnceLock<std::sync::atomic::AtomicU64> = OnceLock::new();
1375        let n = N
1376            .get_or_init(|| std::sync::atomic::AtomicU64::new(0))
1377            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1378        let path = dir.join(format!(
1379            "tensor-wasm-audit-async-test-{}-{n}.log",
1380            std::process::id(),
1381        ));
1382        let _ = std::fs::remove_file(&path);
1383        let sink = FileJsonSink::open(&path).expect("opens");
1384        sink.emit(&sample_record());
1385        sink.emit(&sample_record());
1386
1387        // Bounded poll: the offloaded writes complete asynchronously.
1388        let mut lines = 0;
1389        for _ in 0..200 {
1390            if let Ok(body) = std::fs::read_to_string(&path) {
1391                lines = body.lines().count();
1392                if lines >= 2 {
1393                    break;
1394                }
1395            }
1396            tokio::task::yield_now().await;
1397            // Give the blocking pool a tick to flush on slow hosts.
1398            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1399        }
1400        assert_eq!(lines, 2, "both records must reach the file (none dropped)");
1401        let _ = std::fs::remove_file(&path);
1402    }
1403
1404    #[test]
1405    fn audit_config_from_env_falls_back_to_stdout_when_unset() {
1406        // We can't reliably manipulate the env var in tests (parallel
1407        // execution would race); instead, exercise the explicit ctor.
1408        let cfg = AuditConfig::stdout();
1409        // The Arc<dyn AuditSink> must not panic on emit.
1410        cfg.sink.emit(&sample_record());
1411    }
1412
1413    #[test]
1414    fn audit_config_disabled_is_noop() {
1415        let cfg = AuditConfig::disabled();
1416        cfg.sink.emit(&sample_record());
1417        // No assertion beyond "does not panic" — NoopSink emits nothing.
1418    }
1419
1420    #[test]
1421    fn extract_subject_from_xfcc_envoy_format() {
1422        let mut headers = axum::http::HeaderMap::new();
1423        headers.insert(
1424            HEADER_XFCC,
1425            axum::http::HeaderValue::from_static(
1426                "Hash=abc123;Subject=\"CN=client-prod,O=Acme\";URI=spiffe://acme.io/client",
1427            ),
1428        );
1429        let subj = extract_client_cert_subject(&headers).expect("present");
1430        assert_eq!(subj, "CN=client-prod,O=Acme");
1431    }
1432
1433    #[test]
1434    fn extract_subject_returns_none_when_header_absent() {
1435        let headers = axum::http::HeaderMap::new();
1436        assert!(extract_client_cert_subject(&headers).is_none());
1437    }
1438
1439    #[test]
1440    fn parse_function_id_recognises_canonical_routes() {
1441        let id = Uuid::new_v4();
1442        let s = id.to_string();
1443        assert_eq!(
1444            parse_function_id_from_path(&format!("/functions/{s}")),
1445            Some(id),
1446        );
1447        assert_eq!(
1448            parse_function_id_from_path(&format!("/functions/{s}/invoke")),
1449            Some(id),
1450        );
1451        assert_eq!(
1452            parse_function_id_from_path(&format!("/functions/{s}/invoke-async")),
1453            Some(id),
1454        );
1455        // POST /functions: no id yet (handler assigns it).
1456        assert_eq!(parse_function_id_from_path("/functions"), None);
1457        // Non-uuid second segment: defensive None rather than panic.
1458        assert_eq!(parse_function_id_from_path("/functions/garbage"), None);
1459        // Read-only route should not surface a function id either.
1460        assert_eq!(parse_function_id_from_path("/healthz"), None);
1461    }
1462
1463    #[test]
1464    fn extract_subject_returns_none_when_header_has_no_subject() {
1465        let mut headers = axum::http::HeaderMap::new();
1466        headers.insert(
1467            HEADER_XFCC,
1468            axum::http::HeaderValue::from_static("Hash=abc123;URI=spiffe://acme.io/client"),
1469        );
1470        assert!(extract_client_cert_subject(&headers).is_none());
1471    }
1472
1473    /// XFCC spoofing mitigation: an empty `TrustedProxies` allowlist
1474    /// (the safe default) must drop the header regardless of how the
1475    /// peer claims to be addressed.
1476    #[test]
1477    fn gated_extract_drops_xfcc_when_no_proxies_trusted() {
1478        let mut headers = axum::http::HeaderMap::new();
1479        headers.insert(
1480            HEADER_XFCC,
1481            axum::http::HeaderValue::from_static("Subject=\"CN=evil\""),
1482        );
1483        let trusted = TrustedProxies::empty();
1484        let peer = Some(IpAddr::from([127, 0, 0, 1]));
1485        assert!(extract_client_cert_subject_gated(&headers, peer, &trusted).is_none());
1486    }
1487
1488    /// Gated extract honours XFCC when the peer is in the allowlist.
1489    #[test]
1490    fn gated_extract_honours_xfcc_from_trusted_peer() {
1491        let mut headers = axum::http::HeaderMap::new();
1492        headers.insert(
1493            HEADER_XFCC,
1494            axum::http::HeaderValue::from_static("Subject=\"CN=client-prod\""),
1495        );
1496        let trusted = TrustedProxies::parse("127.0.0.1");
1497        let peer = Some(IpAddr::from([127, 0, 0, 1]));
1498        let subj = extract_client_cert_subject_gated(&headers, peer, &trusted)
1499            .expect("trusted peer's header is honoured");
1500        assert_eq!(subj, "CN=client-prod");
1501    }
1502
1503    /// Unknown peer (no `ConnectInfo`) must drop the header even with a
1504    /// non-empty allowlist — we cannot validate trust, so we cannot
1505    /// honour the claim.
1506    #[test]
1507    fn gated_extract_drops_xfcc_when_peer_unknown() {
1508        let mut headers = axum::http::HeaderMap::new();
1509        headers.insert(
1510            HEADER_XFCC,
1511            axum::http::HeaderValue::from_static("Subject=\"CN=anyone\""),
1512        );
1513        let trusted = TrustedProxies::parse("10.0.0.0/8");
1514        assert!(extract_client_cert_subject_gated(&headers, None, &trusted).is_none());
1515    }
1516
1517    /// CIDR membership for IPv4 — a peer inside `10.0.0.0/8` is trusted,
1518    /// a peer outside is not.
1519    #[test]
1520    fn trusted_proxies_cidr_membership_v4() {
1521        let t = TrustedProxies::parse("10.0.0.0/8");
1522        assert!(t.contains(IpAddr::from([10, 5, 3, 7])));
1523        assert!(t.contains(IpAddr::from([10, 0, 0, 0])));
1524        assert!(t.contains(IpAddr::from([10, 255, 255, 255])));
1525        assert!(!t.contains(IpAddr::from([192, 168, 1, 1])));
1526        assert!(!t.contains(IpAddr::from([11, 0, 0, 0])));
1527    }
1528
1529    /// CIDR membership for IPv6.
1530    #[test]
1531    fn trusted_proxies_cidr_membership_v6() {
1532        let t = TrustedProxies::parse("fd00::/8");
1533        let v6: IpAddr = "fd12::1".parse().unwrap();
1534        assert!(t.contains(v6));
1535        let outside: IpAddr = "2001:db8::1".parse().unwrap();
1536        assert!(!t.contains(outside));
1537    }
1538
1539    /// Bare IPs are normalised into host routes (/32 or /128).
1540    #[test]
1541    fn trusted_proxies_bare_ip_normalised_to_host_route() {
1542        let t = TrustedProxies::parse("127.0.0.1, ::1");
1543        assert!(t.contains(IpAddr::from([127, 0, 0, 1])));
1544        assert!(!t.contains(IpAddr::from([127, 0, 0, 2])));
1545        let v6: IpAddr = "::1".parse().unwrap();
1546        assert!(t.contains(v6));
1547    }
1548
1549    /// Empty input yields an empty allowlist that trusts nobody.
1550    #[test]
1551    fn trusted_proxies_empty_input_trusts_nobody() {
1552        let t = TrustedProxies::parse("");
1553        assert!(t.is_empty());
1554        assert!(!t.contains(IpAddr::from([127, 0, 0, 1])));
1555        let t = TrustedProxies::parse("   ,   ,   ");
1556        assert!(t.is_empty());
1557    }
1558
1559    /// Malformed entries are skipped but do not poison the rest of the
1560    /// allowlist.
1561    #[test]
1562    fn trusted_proxies_malformed_entries_are_skipped() {
1563        let t = TrustedProxies::parse("garbage,127.0.0.1,also-bad/99");
1564        assert!(t.contains(IpAddr::from([127, 0, 0, 1])));
1565        assert!(!t.contains(IpAddr::from([10, 0, 0, 1])));
1566    }
1567
1568    /// The warn-dedup set fires at most once per unique peer.
1569    #[test]
1570    fn trusted_proxies_warn_once_dedup() {
1571        let t = TrustedProxies::empty();
1572        let peer = IpAddr::from([192, 168, 1, 1]);
1573        assert!(t.warn_once_untrusted(peer), "first call emits the warn");
1574        assert!(
1575            !t.warn_once_untrusted(peer),
1576            "second call from same peer is suppressed",
1577        );
1578        // A different peer still fires once.
1579        let other = IpAddr::from([192, 168, 1, 2]);
1580        assert!(t.warn_once_untrusted(other));
1581    }
1582}