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}