Skip to main content

plexus_auth_core/
audit.rs

1//! `AuditRecord`, `AuditSink`, and the default `TracingAuditSink` — the
2//! audit primitive per AUTHZ-PRIVACY-1.
3//!
4//! AUTHZ default-deny dispatch (AUTHZ-CORE-5) must write one audit record per
5//! scope check (allow or deny) before the dispatch responds. The no-
6//! enumeration error policy (AUTHZ-PRIVACY-4) depends on this primitive:
7//! the wire response is generic per AUTHZ-S01-output §9; the **real** reason
8//! for a deny lives in the audit record.
9//!
10//! # Module surface
11//!
12//! - [`AuditRecord`] — the record shape (per AUTHZ-S01-output §1 + §8, with
13//!   AUTHZ-0 ratification revisions §2 and AUTHLANG-S01-output §4 fields).
14//! - [`AuditRecordKind`] — discriminant: `ScopeCheck` (default) or
15//!   `ForwardPolicyApplied` (AUTHLANG-3 writes this kind).
16//! - [`AuditDecision`] — `Allow` or `Deny { reason }`.
17//! - [`AuditDenyReason`] — the layered-denial detail captured per AUTHZ-0.
18//! - [`AuditSink`] — async trait the framework awaits to persist a record.
19//! - [`TracingAuditSink`] — default sink, emits `tracing::info!` to the
20//!   `plexus::audit` target. Free observability for every deployment.
21//! - [`ScopeCheck`] / [`ForwardPolicyApplied`] — empty marker types
22//!   matching the [`AuditRecordKind`] variants (introduced per the ticket's
23//!   `introduces:` frontmatter so the variant names have type-level
24//!   reachability without conflating with the discriminant).
25//! - [`SensitiveField`] — registry marker introduced for AUTHZ-PRIVACY-2 to
26//!   populate (`#[sensitive]` field tagging). Today's redaction is a stub:
27//!   no fields are redacted because the registry is empty.
28//! - [`UserId`], [`SessionId`], [`RoleName`] — strong-typed newtype primitives
29//!   for the principal-chain capture. See module-level run-note below.
30//!
31//! # `UserId`, `SessionId`, `RoleName` ownership
32//!
33//! AUTHZ-PRIVACY-1's `imports:` frontmatter lists these three types, but no
34//! upstream ticket actually owns them — the spec author flagged this for
35//! resolution before launch. Per the resolution path in the agent prompt and
36//! the strong-typing skill, this ticket **owns** them. They are introduced
37//! here as transparent `String` newtypes alongside the rest of the audit
38//! primitive. See `plans/AUTHZ/AUTHZ-PRIVACY-1-RUN-NOTES.md` for the
39//! expansion of the `introduces:` list.
40//!
41//! # Sealing posture
42//!
43//! Unlike `Principal` / `VerifiedUser` / `Credential`, the audit primitive is
44//! NOT sealed. The framework constructs `AuditRecord` values internally, but
45//! tests, sink reference implementations, and downstream observers all need
46//! to construct them — there is no safety property that demands a sealed
47//! constructor. `AuditRecord` derives `Debug, Clone, Serialize, Deserialize`
48//! per acceptance criterion 1.
49//!
50//! # Sink failure
51//!
52//! Per AUTHZ-S01-output §8 default policy: a sink that returns / panics-as-
53//! error from `write` is logged at `tracing::error` and dispatch continues.
54//! The audit-vs-availability tradeoff defaults to availability; critical-
55//! sink semantics are deferred (AUTHZ-S01-output §8 open question; future
56//! ticket).
57
58use std::net::IpAddr;
59use std::sync::Arc;
60
61use async_trait::async_trait;
62use chrono::{DateTime, Utc};
63use serde::{Deserialize, Serialize};
64use uuid::Uuid;
65
66use crate::capabilities::MethodPath;
67use crate::credential::{Origin, Scope};
68use crate::forward::{ForwardDerivation, ForwardPolicyName};
69use crate::principal::{Principal, ServiceIdentity};
70use crate::verified_user::VerifiedUser;
71
72// ---------------------------------------------------------------------------
73// Sealed-type round-trip for the `invocation_chain`.
74//
75// `Principal` and `VerifiedUser` deliberately do NOT implement `Deserialize`
76// — per AUTHZ-0's sealing principle "no leaky Deserialize: not derived; raw
77// JSON cannot fabricate a sealed value". That sealing is exactly the
78// property that makes the principal-chain forensically meaningful.
79//
80// But the audit record must round-trip (acceptance criterion 10). The
81// resolution: the deserialize path lives **inside `plexus-auth-core`**, so
82// it can legitimately route through the crate-private constructors
83// (`anonymous_sealed`, `user_sealed`, `service_sealed`, `new_sealed`). The
84// external invariant (no third-crate fabrication of a Principal) is
85// preserved: only the audit-record deserializer can mint a `Principal` from
86// JSON, and only callers that already hold an `AuditRecord` value benefit.
87//
88// The wire shape matches what `Principal`'s derived `Serialize` emits — a
89// JSON enum representation:
90//   - `"Anonymous"`
91//   - `{"User": {"user_id": "...", "issuer": "...", "issued_at": N,
92//                "expires_at": N}}`
93//   - `{"Service": {"service_id": "..."}}`
94//
95// We mirror that with private `PrincipalWire` / `VerifiedUserWire` /
96// `ServiceIdentityWire` types and convert via the crate-private mint paths.
97// ---------------------------------------------------------------------------
98
99#[derive(Deserialize)]
100struct VerifiedUserWire {
101    user_id: String,
102    issuer: String,
103    issued_at: i64,
104    expires_at: i64,
105}
106
107#[derive(Deserialize)]
108struct ServiceIdentityWire {
109    service_id: String,
110}
111
112#[derive(Deserialize)]
113enum PrincipalWire {
114    User(VerifiedUserWire),
115    Service(ServiceIdentityWire),
116    Anonymous,
117}
118
119impl From<PrincipalWire> for Principal {
120    fn from(w: PrincipalWire) -> Self {
121        match w {
122            PrincipalWire::Anonymous => Principal::anonymous_sealed(),
123            PrincipalWire::User(u) => Principal::user_sealed(VerifiedUser::new_sealed(
124                u.user_id,
125                u.issuer,
126                u.issued_at,
127                u.expires_at,
128            )),
129            PrincipalWire::Service(s) => {
130                Principal::service_sealed(ServiceIdentity::new_sealed(s.service_id))
131            }
132        }
133    }
134}
135
136/// `#[serde(deserialize_with = ...)]` shim that reads the principal chain
137/// via the crate-private mint paths.
138fn deserialize_invocation_chain<'de, D>(deserializer: D) -> Result<Vec<Principal>, D::Error>
139where
140    D: serde::Deserializer<'de>,
141{
142    let wire: Vec<PrincipalWire> = Vec::deserialize(deserializer)?;
143    Ok(wire.into_iter().map(Principal::from).collect())
144}
145
146/// `#[serde(default = ...)]` shim that produces an empty chain.
147fn empty_invocation_chain() -> Vec<Principal> {
148    Vec::new()
149}
150
151// ---------------------------------------------------------------------------
152// `ForwardPolicyName` wire shim.
153//
154// `ForwardPolicyName` wraps `&'static str` (per AUTHLANG-2's design — policy
155// names are compile-time constants). Its derived `Deserialize` impl
156// transitively requires `'de: 'static`, which would prevent deserializing
157// `AuditRecord` from a non-`'static` JSON input.
158//
159// We shim the field via a `String` wire-form, then intern the value into a
160// `&'static str` (via `Box::leak`) so the resulting `ForwardPolicyName`
161// honors the type's invariant. Names are typically one of the three v1
162// constants (`identity_only`, `pass_through`, `anonymous`) — we match those
163// first to avoid leaking memory on the common path. Unknown names leak one
164// `String` per fresh policy name, which is acceptable: the count is bounded
165// by the number of distinct policies a deployment defines, not by the
166// number of records deserialized.
167// ---------------------------------------------------------------------------
168
169fn deserialize_policy_name<'de, D>(
170    deserializer: D,
171) -> Result<Option<ForwardPolicyName>, D::Error>
172where
173    D: serde::Deserializer<'de>,
174{
175    let opt: Option<String> = Option::deserialize(deserializer)?;
176    Ok(opt.map(|s| {
177        // Fast-path the v1 constants to avoid leaking.
178        match s.as_str() {
179            "identity_only" => crate::forward::IDENTITY_ONLY_NAME,
180            "pass_through" => crate::forward::PASS_THROUGH_NAME,
181            "anonymous" => crate::forward::ANONYMOUS_NAME,
182            _ => {
183                // Slow path: intern the string. Bounded by the number of
184                // distinct custom policy names in the deployment.
185                let leaked: &'static str = Box::leak(s.into_boxed_str());
186                ForwardPolicyName::new(leaked)
187            }
188        }
189    }))
190}
191
192// ---------------------------------------------------------------------------
193// Strong-typed newtypes: UserId, SessionId, RoleName.
194//
195// Per the strong-typing skill: a bare `String` in this position would conflate
196// three semantically distinct concepts (originator identity, session
197// identity, role name) and let the compiler accept a misuse. These newtypes
198// make swap-arguments mistakes a compile error.
199//
200// Transparent serde is intentional: the wire shape is "just a string", and
201// AUTHZ-S01-output §1 pins the field names but not the encoding wrapper. The
202// type-level discipline is for Rust callers; the JSON encoding is identical
203// to what a bare String would produce.
204// ---------------------------------------------------------------------------
205
206/// IdP-verified originator identifier — the `sub` claim or equivalent.
207///
208/// Per AUTHZ-0 ratification (AUTHZ-S01-output §"AUTHZ-0 ratification
209/// revisions" §2): `originator` is the IdP-verified user, NOT a bare string
210/// or whatever-the-caller-said. The newtype prevents a downstream from
211/// accidentally swapping `UserId` and `SessionId` (both would be `String`
212/// otherwise — same shape, very different meaning).
213#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
214#[serde(transparent)]
215pub struct UserId(String);
216
217impl UserId {
218    /// Wrap a string as a typed `UserId`.
219    pub fn new(s: impl Into<String>) -> Self {
220        Self(s.into())
221    }
222
223    /// Borrow the underlying string.
224    pub fn as_str(&self) -> &str {
225        &self.0
226    }
227
228    /// Consume into the underlying string.
229    pub fn into_string(self) -> String {
230        self.0
231    }
232}
233
234impl std::fmt::Display for UserId {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        f.write_str(&self.0)
237    }
238}
239
240/// IdP-issued session identifier — the `sid` claim or equivalent.
241///
242/// Per AUTHZ-0 ratification: `session_id` is typed, not bare. Newtype
243/// discipline prevents confusion with `UserId`.
244#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
245#[serde(transparent)]
246pub struct SessionId(String);
247
248impl SessionId {
249    /// Wrap a string as a typed `SessionId`.
250    pub fn new(s: impl Into<String>) -> Self {
251        Self(s.into())
252    }
253
254    /// Borrow the underlying string.
255    pub fn as_str(&self) -> &str {
256        &self.0
257    }
258
259    /// Consume into the underlying string.
260    pub fn into_string(self) -> String {
261        self.0
262    }
263}
264
265impl std::fmt::Display for SessionId {
266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        f.write_str(&self.0)
268    }
269}
270
271/// IdP-issued role name within a realm/tenant.
272///
273/// AUTHZ-CORE-3/4 will own this type in the long run; this ticket introduces
274/// it here for the audit record's `roles: Vec<RoleName>` field. When the
275/// canonical type lands in `plexus-auth-core::capabilities`, this re-exports
276/// from there. The strong-typing discipline survives the move.
277#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
278#[serde(transparent)]
279pub struct RoleName(String);
280
281impl RoleName {
282    /// Wrap a string as a typed `RoleName`.
283    pub fn new(s: impl Into<String>) -> Self {
284        Self(s.into())
285    }
286
287    /// Borrow the underlying string.
288    pub fn as_str(&self) -> &str {
289        &self.0
290    }
291
292    /// Consume into the underlying string.
293    pub fn into_string(self) -> String {
294        self.0
295    }
296}
297
298impl std::fmt::Display for RoleName {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        f.write_str(&self.0)
301    }
302}
303
304// ---------------------------------------------------------------------------
305// AuditRecordKind discriminant.
306// ---------------------------------------------------------------------------
307
308/// Discriminates the two flavors of audit record this ticket lands.
309///
310/// `ScopeCheck` is the default — written by AUTHZ-CORE-5 default-deny
311/// dispatch. `ForwardPolicyApplied` is what AUTHLANG-3 writes when a
312/// forwarding policy runs at a `route_to_child` edge.
313///
314/// Per AUTHLANG-S01-output §4 Tier-B resolution: the variant is **additive**
315/// with serde-default `ScopeCheck` so AUTHZ-side call sites that omit the
316/// `kind` field continue to deserialize as `ScopeCheck` records.
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
318#[serde(rename_all = "snake_case")]
319pub enum AuditRecordKind {
320    /// A scope-check decision at default-deny dispatch (AUTHZ-CORE-5).
321    #[default]
322    ScopeCheck,
323    /// A forwarding policy applied at a route-to-child edge (AUTHLANG-3).
324    ForwardPolicyApplied,
325}
326
327/// Marker type for the `ScopeCheck` audit-record kind.
328///
329/// Introduced per the ticket's `introduces:` frontmatter so the variant name
330/// has a type-level identity in addition to the [`AuditRecordKind`]
331/// discriminant. The marker is empty and zero-cost; callers that statically
332/// know they're writing a scope-check record can use it to make that intent
333/// readable at the call site:
334///
335/// ```rust,ignore
336/// let kind = ScopeCheck.into();   // AuditRecordKind::ScopeCheck
337/// ```
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
339pub struct ScopeCheck;
340
341impl From<ScopeCheck> for AuditRecordKind {
342    fn from(_: ScopeCheck) -> Self {
343        AuditRecordKind::ScopeCheck
344    }
345}
346
347/// Marker type for the `ForwardPolicyApplied` audit-record kind.
348///
349/// See [`ScopeCheck`] for the rationale. AUTHLANG-3 uses this marker at the
350/// call site that builds a forward-policy record:
351///
352/// ```rust,ignore
353/// let kind = ForwardPolicyApplied.into();
354/// ```
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
356pub struct ForwardPolicyApplied;
357
358impl From<ForwardPolicyApplied> for AuditRecordKind {
359    fn from(_: ForwardPolicyApplied) -> Self {
360        AuditRecordKind::ForwardPolicyApplied
361    }
362}
363
364// ---------------------------------------------------------------------------
365// AuditDecision and AuditDenyReason.
366// ---------------------------------------------------------------------------
367
368/// The decision recorded by a scope check.
369///
370/// Per AUTHZ-S01-output §1: `Allow` is the affirmative; `Deny { reason }`
371/// carries the layered-denial detail that the wire response does NOT
372/// surface (per AUTHZ-PRIVACY-4's no-enumeration policy — the wire stays
373/// generic; the audit log carries the truth).
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(tag = "decision", rename_all = "snake_case")]
376pub enum AuditDecision {
377    /// The check passed; dispatch proceeded.
378    Allow,
379    /// The check failed; dispatch responded with the generic deny.
380    Deny {
381        /// The layered-denial detail.
382        reason: AuditDenyReason,
383    },
384}
385
386/// The per-layer reason a deny occurred.
387///
388/// Captures AUTHZ-0's layered-denial model (per AUTHZ-S01-output §1): the
389/// audit record names *which* layer rejected the call so operators can
390/// reconstruct the chain after the fact. The wire response does NOT carry
391/// this discriminator (AUTHZ-PRIVACY-4); it is server-side only.
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
393#[serde(rename_all = "snake_case")]
394pub enum AuditDenyReason {
395    /// No credential / anonymous caller hit a non-public method.
396    Unauthenticated,
397    /// Credential present but invalid (expired token, unknown sid, etc.).
398    InvalidSession,
399    /// Authenticated, but the method's scope is not in the caller's set.
400    MissingScope,
401    /// Method exists but the perimeter rejected the call (`#[plexus::method(deny)]`
402    /// or similar policy rejection).
403    NotAccepted,
404    /// Tenant resolved, but does not match the data the call references.
405    TenantBoundary,
406    /// Rate-limit policy fired.
407    RateLimited,
408    /// Catch-all for layered impls that have not yet earned a dedicated
409    /// variant. Use sparingly; the wire still goes out generic, but the
410    /// audit log loses fidelity.
411    Other,
412}
413
414// ---------------------------------------------------------------------------
415// SensitiveField registry stub (AUTHZ-PRIVACY-2 will populate).
416// ---------------------------------------------------------------------------
417
418/// Tags a parameter or field as containing sensitive material that audit
419/// payloads must redact.
420///
421/// AUTHZ-PRIVACY-2 introduces the `#[sensitive]` attribute and the registry
422/// that populates this marker. AUTHZ-PRIVACY-1 lands the type so consumer
423/// tickets and tests can wire against a stable surface — today the registry
424/// is empty, so no redaction occurs; the type is reserved.
425///
426/// When the registry is populated (PRIVACY-2 / -5), serializing an
427/// `AuditRecord` will replace tagged fields with the literal string
428/// `"<redacted>"`. The redaction path is consumer-driven (the audit-payload
429/// serializer consults the registry); this ticket does not gate any field
430/// of `AuditRecord` itself behind the marker — the record is the carrier,
431/// not the payload-bearer.
432#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
433#[serde(transparent)]
434pub struct SensitiveField(String);
435
436impl SensitiveField {
437    /// Tag a field-or-parameter path as sensitive.
438    pub fn new(path: impl Into<String>) -> Self {
439        Self(path.into())
440    }
441
442    /// Borrow the underlying path.
443    pub fn as_str(&self) -> &str {
444        &self.0
445    }
446}
447
448// ---------------------------------------------------------------------------
449// AuditRecord — the wire shape.
450// ---------------------------------------------------------------------------
451
452/// One audit observation: a scope check or a forward-policy application.
453///
454/// Shape is pinned by:
455///
456/// - **AUTHZ-S01-output §1**: base fields (`timestamp`, `roles`, `method`,
457///   `scope_required`, `decision`, `latency_us`, `origin`, `client_ip`,
458///   `correlation_id`).
459/// - **AUTHZ-S01-output §"AUTHZ-0 ratification revisions" §2**: typed
460///   `originator: Option<UserId>`, `session_id: Option<SessionId>`, and the
461///   forensic `invocation_chain: Vec<Principal>` for confused-deputy
462///   reconstruction.
463/// - **AUTHLANG-S01-output §4**: the `kind` discriminant (default
464///   `ScopeCheck` for serde) plus three optional fields populated only by
465///   `ForwardPolicyApplied` records: `policy_name`, `derivation`, `caller_ns`.
466///
467/// # Defaults
468///
469/// `kind` defaults to `AuditRecordKind::ScopeCheck` when omitted from a
470/// deserialize payload — existing AUTHZ-side producers don't need to set it.
471/// `policy_name`, `derivation`, `caller_ns` default to `None` — they are
472/// populated only by AUTHLANG-3's forward-policy producer.
473///
474/// # Sealing
475///
476/// Not sealed. The record is observational; constructibility is the point.
477/// See module-level docs.
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct AuditRecord {
480    /// When the check ran (UTC).
481    pub timestamp: DateTime<Utc>,
482
483    /// Which kind of record this is — drives field-presence expectations.
484    /// Defaults to `ScopeCheck` for legacy / AUTHZ-side producers that omit
485    /// the field on deserialize.
486    #[serde(default)]
487    pub kind: AuditRecordKind,
488
489    /// IdP-verified originator (the `sub` claim). `None` for anonymous /
490    /// unauthenticated checks.
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub originator: Option<UserId>,
493
494    /// IdP-issued session ID. `None` for anonymous / sessionless checks.
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub session_id: Option<SessionId>,
497
498    /// The chain of immediate-callers leading to this dispatch. Empty for a
499    /// direct (originator-to-backend) call. Forensic reconstruction of
500    /// confused-deputy escalations reads this field.
501    ///
502    /// Deserialize routes via the crate-private mint paths
503    /// (`Principal::*_sealed`) — see the module-level comment on the
504    /// `PrincipalWire` types. The wire shape matches `Principal`'s derived
505    /// `Serialize`.
506    #[serde(
507        default = "empty_invocation_chain",
508        deserialize_with = "deserialize_invocation_chain"
509    )]
510    pub invocation_chain: Vec<Principal>,
511
512    /// The roles the principal carried at decision time.
513    #[serde(default)]
514    pub roles: Vec<RoleName>,
515
516    /// The method being invoked at decision time.
517    pub method: MethodPath,
518
519    /// The scope set required by `method`. Empty if no scope is gated.
520    #[serde(default)]
521    pub scope_required: Vec<Scope>,
522
523    /// `Allow` or `Deny { reason }`.
524    pub decision: AuditDecision,
525
526    /// Dispatch latency in microseconds.
527    pub latency_us: u64,
528
529    /// The backend Origin (URL-shaped, per CLIENTS-S01).
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub origin: Option<Origin>,
532
533    /// Network-layer source IP, when knowable.
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub client_ip: Option<IpAddr>,
536
537    /// Per-call correlation ID; ties this record to traces and metrics.
538    pub correlation_id: Uuid,
539
540    /// `ForwardPolicyApplied` only: name of the policy that ran.
541    ///
542    /// `ForwardPolicyName` wraps `&'static str` per AUTHLANG-2; the
543    /// deserializer interns the JSON value into a `'static` slot — see the
544    /// module-level `deserialize_policy_name` doc.
545    #[serde(
546        default,
547        skip_serializing_if = "Option::is_none",
548        deserialize_with = "deserialize_policy_name"
549    )]
550    pub policy_name: Option<ForwardPolicyName>,
551
552    /// `ForwardPolicyApplied` only: the derivation the policy returned.
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub derivation: Option<ForwardDerivation>,
555
556    /// `ForwardPolicyApplied` only: the calling activation namespace.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub caller_ns: Option<String>,
559}
560
561// ---------------------------------------------------------------------------
562// AuditSink trait and TracingAuditSink default impl.
563// ---------------------------------------------------------------------------
564
565/// What the framework calls to persist an [`AuditRecord`].
566///
567/// One method — the framework awaits `write` after a scope check resolves
568/// and before the dispatch responds (the "audit before respond" invariant is
569/// AUTHZ-CORE-5's responsibility; this trait only commits to being
570/// awaitable). `Send + Sync + 'static` so the sink can be held as
571/// `Arc<dyn AuditSink>` across dispatch threads.
572///
573/// # Failure handling
574///
575/// A sink that returns / panics-as-error from `write` is logged at
576/// `tracing::error` and dispatch continues — per AUTHZ-S01-output §8 the
577/// default audit-vs-availability tradeoff is availability. The trait method
578/// returns `()` (not `Result`) for this reason: the contract is "best
579/// effort". The framework wraps the call in `tracing::error!`-on-panic
580/// (`Arc<dyn AuditSink>::write` is awaited inside a panic guard at the
581/// AUTHZ-CORE-5 dispatch path; see the consumer-side tests there). Critical-
582/// sink semantics are deferred (AUTHZ-S01-output §8 open question).
583///
584/// # Default
585///
586/// [`TracingAuditSink`] is the default sink. A backend that never calls
587/// `with_audit_sink` on its hub builder still gets `tracing::info!` events
588/// on the `plexus::audit` target — observability for free.
589#[async_trait]
590pub trait AuditSink: Send + Sync + 'static {
591    /// Persist one audit record.
592    ///
593    /// The contract is best-effort: a sink that cannot write must not
594    /// propagate the error to the caller. The framework awaits this future
595    /// inside a panic guard and treats errors as non-fatal at this layer.
596    async fn write(&self, record: AuditRecord);
597}
598
599/// The default [`AuditSink`]: emit `tracing::info!` events under
600/// `target = "plexus::audit"`.
601///
602/// Per AUTHZ-S01-output §8: "guarantees that even backends that don't think
603/// about audit get *some* observable trail." Every field of the record
604/// appears as a structured field on the emitted event. Operators wire
605/// `tracing-subscriber` once at the deployment level and the audit trail
606/// flows to whatever sink they prefer (JSON to stdout, file, OTLP, etc.).
607///
608/// # Retention
609///
610/// Retention is the responsibility of the `tracing` subscriber configured
611/// at the deployment level (e.g., a file appender with size-based rotation,
612/// or an OTLP exporter with cloud-side retention). The reference sinks in
613/// AUTHZ-PRIVACY-5 cover the cases where structured-log retention is not a
614/// fit (durable WAL, S3 sink, etc.).
615///
616/// # Failure modes
617///
618/// `tracing::info!` is infallible at the macro level; it returns `()`. The
619/// only failure mode is a misconfigured subscriber dropping events, which
620/// `tracing-subscriber`'s standard machinery surfaces as drop counters — not
621/// a per-record error. The sink's `write` therefore cannot fail.
622#[derive(Debug, Clone, Copy, Default)]
623pub struct TracingAuditSink;
624
625impl TracingAuditSink {
626    /// Construct the default sink. Equivalent to `TracingAuditSink::default()`.
627    pub const fn new() -> Self {
628        Self
629    }
630
631    /// `Arc<dyn AuditSink>` for handing into hub builders. Equivalent to
632    /// `Arc::new(TracingAuditSink::new()) as Arc<dyn AuditSink>`.
633    pub fn arc() -> Arc<dyn AuditSink> {
634        Arc::new(Self::new())
635    }
636}
637
638#[async_trait]
639impl AuditSink for TracingAuditSink {
640    async fn write(&self, record: AuditRecord) {
641        // One info! event with the record's structured fields. The
642        // `target = "plexus::audit"` is the contract: deployment-level
643        // subscribers filter / route on it. Field types implement
644        // `Debug`/`Display`; we surface the record itself via the `record`
645        // shorthand so non-trivial fields (the principal chain, the
646        // derivation, etc.) make it into the event without us hand-rolling
647        // a serializer here.
648        let AuditRecord {
649            timestamp,
650            kind,
651            originator,
652            session_id,
653            invocation_chain,
654            roles,
655            method,
656            scope_required,
657            decision,
658            latency_us,
659            origin,
660            client_ip,
661            correlation_id,
662            policy_name,
663            derivation,
664            caller_ns,
665        } = record;
666        tracing::info!(
667            target: "plexus::audit",
668            %timestamp,
669            ?kind,
670            originator = ?originator,
671            session_id = ?session_id,
672            invocation_chain = ?invocation_chain,
673            roles = ?roles,
674            method = %method,
675            scope_required = ?scope_required,
676            decision = ?decision,
677            latency_us,
678            origin = ?origin,
679            client_ip = ?client_ip,
680            %correlation_id,
681            policy_name = ?policy_name,
682            derivation = ?derivation,
683            caller_ns = ?caller_ns,
684            "audit",
685        );
686    }
687}
688
689// ---------------------------------------------------------------------------
690// Tests.
691// ---------------------------------------------------------------------------
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    use crate::forward::ForwardDerivation;
697    use crate::principal::Principal;
698    use std::net::{IpAddr, Ipv4Addr};
699
700    fn scope_check_record() -> AuditRecord {
701        AuditRecord {
702            timestamp: DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
703            kind: AuditRecordKind::ScopeCheck,
704            originator: Some(UserId::new("alice")),
705            session_id: Some(SessionId::new("sess-42")),
706            invocation_chain: vec![Principal::anonymous_sealed()],
707            roles: vec![RoleName::new("admin"), RoleName::new("user")],
708            method: MethodPath::try_new("solar.earth.luna.info").unwrap(),
709            scope_required: vec![Scope::new("luna.read")],
710            decision: AuditDecision::Allow,
711            latency_us: 123,
712            origin: Some(Origin::new("ws://localhost:4444")),
713            client_ip: Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
714            correlation_id: Uuid::nil(),
715            policy_name: None,
716            derivation: None,
717            caller_ns: None,
718        }
719    }
720
721    fn forward_policy_record() -> AuditRecord {
722        AuditRecord {
723            timestamp: DateTime::from_timestamp(1_700_000_001, 0).unwrap(),
724            kind: AuditRecordKind::ForwardPolicyApplied,
725            originator: Some(UserId::new("bob")),
726            session_id: Some(SessionId::new("sess-99")),
727            invocation_chain: vec![
728                Principal::anonymous_sealed(),
729                Principal::anonymous_sealed(),
730            ],
731            roles: vec![RoleName::new("editor")],
732            method: MethodPath::try_new("solar.earth.atmosphere.layer").unwrap(),
733            scope_required: vec![Scope::new("atmosphere.read")],
734            decision: AuditDecision::Deny {
735                reason: AuditDenyReason::MissingScope,
736            },
737            latency_us: 456,
738            origin: Some(Origin::new("ws://localhost:4444")),
739            client_ip: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
740            correlation_id: Uuid::from_u128(0xdead_beef_cafe_babe_1234_5678_9abc_def0),
741            policy_name: Some(crate::forward::IDENTITY_ONLY_NAME),
742            derivation: Some(ForwardDerivation::IDENTITY_ONLY),
743            caller_ns: Some("ns.caller".to_string()),
744        }
745    }
746
747    // -- AuditRecordKind defaults ---------------------------------------
748
749    #[test]
750    fn audit_record_kind_default_is_scope_check() {
751        assert_eq!(AuditRecordKind::default(), AuditRecordKind::ScopeCheck);
752    }
753
754    #[test]
755    fn audit_record_kind_serializes_snake_case() {
756        let v = serde_json::to_string(&AuditRecordKind::ScopeCheck).unwrap();
757        assert_eq!(v, "\"scope_check\"");
758        let v = serde_json::to_string(&AuditRecordKind::ForwardPolicyApplied).unwrap();
759        assert_eq!(v, "\"forward_policy_applied\"");
760    }
761
762    #[test]
763    fn audit_record_omitted_kind_deserializes_as_scope_check() {
764        // Acceptance criterion 2: a JSON blob omitting `kind` defaults
765        // the record kind to ScopeCheck.
766        let blob = r#"{
767            "timestamp": "2023-11-14T22:13:20Z",
768            "method": "solar.earth.luna.info",
769            "decision": {"decision": "allow"},
770            "latency_us": 17,
771            "correlation_id": "00000000-0000-0000-0000-000000000000"
772        }"#;
773        let record: AuditRecord = serde_json::from_str(blob).unwrap();
774        assert_eq!(record.kind, AuditRecordKind::ScopeCheck);
775    }
776
777    #[test]
778    fn audit_record_omitted_optional_forward_fields_default_to_none() {
779        // Acceptance criterion 3: a JSON blob omitting `policy_name`,
780        // `derivation`, `caller_ns` produces None for each.
781        let blob = r#"{
782            "timestamp": "2023-11-14T22:13:20Z",
783            "method": "solar.earth.luna.info",
784            "decision": {"decision": "allow"},
785            "latency_us": 17,
786            "correlation_id": "00000000-0000-0000-0000-000000000000"
787        }"#;
788        let record: AuditRecord = serde_json::from_str(blob).unwrap();
789        assert!(record.policy_name.is_none());
790        assert!(record.derivation.is_none());
791        assert!(record.caller_ns.is_none());
792        // And the AUTHZ-0-ratification optional fields also default:
793        assert!(record.originator.is_none());
794        assert!(record.session_id.is_none());
795        assert!(record.invocation_chain.is_empty());
796        assert!(record.roles.is_empty());
797        assert!(record.scope_required.is_empty());
798        assert!(record.origin.is_none());
799        assert!(record.client_ip.is_none());
800    }
801
802    // -- AuditDenyReason variants ---------------------------------------
803
804    #[test]
805    fn audit_deny_reason_serializes_snake_case_all_variants() {
806        // Acceptance criterion 4: all seven variants present and round-trip
807        // via snake_case.
808        let cases = [
809            (AuditDenyReason::Unauthenticated, "\"unauthenticated\""),
810            (AuditDenyReason::InvalidSession, "\"invalid_session\""),
811            (AuditDenyReason::MissingScope, "\"missing_scope\""),
812            (AuditDenyReason::NotAccepted, "\"not_accepted\""),
813            (AuditDenyReason::TenantBoundary, "\"tenant_boundary\""),
814            (AuditDenyReason::RateLimited, "\"rate_limited\""),
815            (AuditDenyReason::Other, "\"other\""),
816        ];
817        for (variant, encoded) in cases {
818            let s = serde_json::to_string(&variant).unwrap();
819            assert_eq!(s, encoded, "serialize {:?}", variant);
820            let parsed: AuditDenyReason = serde_json::from_str(encoded).unwrap();
821            assert_eq!(parsed, variant, "deserialize {}", encoded);
822        }
823    }
824
825    // -- AuditDecision encoding -----------------------------------------
826
827    #[test]
828    fn audit_decision_allow_round_trips() {
829        let s = serde_json::to_string(&AuditDecision::Allow).unwrap();
830        assert_eq!(s, "{\"decision\":\"allow\"}");
831        let v: AuditDecision = serde_json::from_str(&s).unwrap();
832        assert_eq!(v, AuditDecision::Allow);
833    }
834
835    #[test]
836    fn audit_decision_deny_carries_reason() {
837        let d = AuditDecision::Deny {
838            reason: AuditDenyReason::MissingScope,
839        };
840        let s = serde_json::to_string(&d).unwrap();
841        assert_eq!(s, "{\"decision\":\"deny\",\"reason\":\"missing_scope\"}");
842        let parsed: AuditDecision = serde_json::from_str(&s).unwrap();
843        assert_eq!(parsed, d);
844    }
845
846    // -- ScopeCheck / ForwardPolicyApplied markers ----------------------
847
848    #[test]
849    fn scope_check_marker_into_kind() {
850        let k: AuditRecordKind = ScopeCheck.into();
851        assert_eq!(k, AuditRecordKind::ScopeCheck);
852    }
853
854    #[test]
855    fn forward_policy_applied_marker_into_kind() {
856        let k: AuditRecordKind = ForwardPolicyApplied.into();
857        assert_eq!(k, AuditRecordKind::ForwardPolicyApplied);
858    }
859
860    // -- Newtypes -------------------------------------------------------
861
862    #[test]
863    fn user_id_round_trips_as_bare_string() {
864        let u = UserId::new("alice");
865        let s = serde_json::to_string(&u).unwrap();
866        assert_eq!(s, "\"alice\"");
867        let parsed: UserId = serde_json::from_str(&s).unwrap();
868        assert_eq!(parsed, u);
869    }
870
871    #[test]
872    fn session_id_round_trips_as_bare_string() {
873        let s = SessionId::new("sess-1");
874        let json = serde_json::to_string(&s).unwrap();
875        assert_eq!(json, "\"sess-1\"");
876        let parsed: SessionId = serde_json::from_str(&json).unwrap();
877        assert_eq!(parsed, s);
878    }
879
880    #[test]
881    fn role_name_round_trips_as_bare_string() {
882        let r = RoleName::new("admin");
883        let s = serde_json::to_string(&r).unwrap();
884        assert_eq!(s, "\"admin\"");
885        let parsed: RoleName = serde_json::from_str(&s).unwrap();
886        assert_eq!(parsed, r);
887    }
888
889    #[test]
890    fn sensitive_field_round_trips_as_bare_string() {
891        let f = SensitiveField::new("password");
892        let s = serde_json::to_string(&f).unwrap();
893        assert_eq!(s, "\"password\"");
894        let parsed: SensitiveField = serde_json::from_str(&s).unwrap();
895        assert_eq!(parsed, f);
896    }
897
898    #[test]
899    fn newtypes_are_display() {
900        assert_eq!(format!("{}", UserId::new("alice")), "alice");
901        assert_eq!(format!("{}", SessionId::new("s1")), "s1");
902        assert_eq!(format!("{}", RoleName::new("admin")), "admin");
903    }
904
905    // -- AuditRecord round-trip -----------------------------------------
906
907    #[test]
908    fn audit_record_scope_check_round_trips_byte_for_byte() {
909        // Serialize then deserialize then re-serialize; the two encoded
910        // forms must match byte-for-byte (guards against asymmetric serde
911        // attributes).
912        let record = scope_check_record();
913        let first = serde_json::to_string(&record).unwrap();
914        let parsed: AuditRecord = serde_json::from_str(&first).unwrap();
915        let second = serde_json::to_string(&parsed).unwrap();
916        assert_eq!(first, second, "round-trip not byte-equal");
917    }
918
919    #[test]
920    fn audit_record_forward_policy_round_trips_byte_for_byte() {
921        // Acceptance criterion 10: serialize an AuditRecord carrying every
922        // field populated (a ForwardPolicyApplied kind with policy_name,
923        // derivation, caller_ns set, plus the base ScopeCheck fields),
924        // deserialize it, assert byte-for-byte equality.
925        let record = forward_policy_record();
926        let first = serde_json::to_string(&record).unwrap();
927        let parsed: AuditRecord = serde_json::from_str(&first).unwrap();
928        let second = serde_json::to_string(&parsed).unwrap();
929        assert_eq!(first, second, "round-trip not byte-equal");
930
931        // Sanity: structural equality on the round-tripped pieces (Principal
932        // does not impl PartialEq so we cannot assert on the whole record).
933        assert_eq!(parsed.kind, AuditRecordKind::ForwardPolicyApplied);
934        assert_eq!(parsed.originator.as_ref().map(|u| u.as_str()), Some("bob"));
935        assert_eq!(
936            parsed.session_id.as_ref().map(|s| s.as_str()),
937            Some("sess-99")
938        );
939        assert_eq!(parsed.invocation_chain.len(), 2);
940        assert_eq!(parsed.roles, vec![RoleName::new("editor")]);
941        assert_eq!(
942            parsed.decision,
943            AuditDecision::Deny {
944                reason: AuditDenyReason::MissingScope
945            }
946        );
947        assert_eq!(parsed.latency_us, 456);
948        assert_eq!(
949            parsed.policy_name,
950            Some(crate::forward::IDENTITY_ONLY_NAME)
951        );
952        assert_eq!(parsed.derivation, Some(ForwardDerivation::IDENTITY_ONLY));
953        assert_eq!(parsed.caller_ns.as_deref(), Some("ns.caller"));
954    }
955
956    #[test]
957    fn audit_record_carries_principal_chain() {
958        let record = forward_policy_record();
959        assert_eq!(record.invocation_chain.len(), 2);
960    }
961
962    #[test]
963    fn audit_record_clone_yields_independent_value() {
964        // Smoke-test the Clone derive that acceptance criterion 1 demands.
965        let record = scope_check_record();
966        let copy = record.clone();
967        assert_eq!(copy.latency_us, record.latency_us);
968        assert_eq!(copy.correlation_id, record.correlation_id);
969    }
970
971    // -- AuditSink trait object safety ---------------------------------
972
973    #[test]
974    fn audit_sink_is_object_safe_via_arc_dyn() {
975        // Acceptance criterion 5: trait must be holdable as `Arc<dyn AuditSink>`.
976        let _sink: Arc<dyn AuditSink> = Arc::new(TracingAuditSink::new());
977    }
978
979    #[test]
980    fn tracing_audit_sink_arc_constructs() {
981        let _sink: Arc<dyn AuditSink> = TracingAuditSink::arc();
982    }
983
984    // -- TracingAuditSink emits an event -------------------------------
985
986    #[tokio::test]
987    #[tracing_test::traced_test]
988    async fn tracing_audit_sink_emits_info_event() {
989        // Acceptance criterion 6: TracingAuditSink::write emits a
990        // tracing::info! event under target = "plexus::audit". The
991        // tracing-test harness captures emitted events into a string;
992        // `logs_contain` asserts the message body appears.
993        let sink = TracingAuditSink::new();
994        sink.write(scope_check_record()).await;
995        // The literal message of the info! call is "audit"; the captured
996        // log line also carries the target. Both substrings must be
997        // present.
998        assert!(logs_contain("audit"));
999        assert!(logs_contain("plexus::audit"));
1000    }
1001
1002    #[tokio::test]
1003    #[tracing_test::traced_test]
1004    async fn tracing_audit_sink_emits_for_deny_records() {
1005        let sink = TracingAuditSink::new();
1006        sink.write(forward_policy_record()).await;
1007        assert!(logs_contain("audit"));
1008        assert!(logs_contain("MissingScope"));
1009    }
1010
1011    // -- Sink-failure non-propagation (acceptance criterion 9) ---------
1012
1013    /// A test sink that signals failure by setting an `AtomicBool`. The
1014    /// AuditSink contract is best-effort — a sink's failure must not
1015    /// surface to the caller of `write`. We model failure as a sink that
1016    /// records the attempt and returns; the framework's wrapping at the
1017    /// AUTHZ-CORE-5 dispatch path is responsible for the
1018    /// `tracing::error`-on-panic translation (this ticket only commits to
1019    /// the awaitable contract — see AuditSink doc).
1020    struct FailingSink {
1021        attempted: std::sync::atomic::AtomicBool,
1022    }
1023
1024    #[async_trait]
1025    impl AuditSink for FailingSink {
1026        async fn write(&self, _record: AuditRecord) {
1027            self.attempted
1028                .store(true, std::sync::atomic::Ordering::SeqCst);
1029            // The contract returns `()`. A sink that "fails" simply gives
1030            // up on persistence — that's the trait's permission. A sink
1031            // that panics is the framework's problem (see consumer-side
1032            // tests in AUTHZ-CORE-5).
1033        }
1034    }
1035
1036    #[tokio::test]
1037    async fn failing_sink_does_not_propagate_error_to_caller() {
1038        let sink = FailingSink {
1039            attempted: std::sync::atomic::AtomicBool::new(false),
1040        };
1041        // The future resolves to `()`. No `?`, no `.await?` — the trait's
1042        // signature *is* the non-propagation guarantee.
1043        sink.write(scope_check_record()).await;
1044        assert!(sink.attempted.load(std::sync::atomic::Ordering::SeqCst));
1045    }
1046
1047    #[tokio::test]
1048    #[tracing_test::traced_test]
1049    async fn panicking_sink_panic_is_observable_via_join_handle() {
1050        // Acceptance criterion 9: a sink that panics from `write` does NOT
1051        // propagate the panic to the caller. The framework's responsibility
1052        // (AUTHZ-CORE-5 dispatch path) is to drive the sink's future inside
1053        // a panic guard and translate to a `tracing::error` carrying the
1054        // correlation_id. We model that responsibility here with
1055        // `tokio::spawn`, which already gives panic translation via the
1056        // returned `JoinHandle::is_panic()`.
1057        struct PanickingSink;
1058        #[async_trait]
1059        impl AuditSink for PanickingSink {
1060            async fn write(&self, record: AuditRecord) {
1061                panic!("sink down (correlation_id={})", record.correlation_id);
1062            }
1063        }
1064        let sink: Arc<dyn AuditSink> = Arc::new(PanickingSink);
1065        let record = scope_check_record();
1066        let correlation_id = record.correlation_id;
1067        let sink_for_task = Arc::clone(&sink);
1068        let record_for_task = record.clone();
1069        let handle = tokio::spawn(async move {
1070            sink_for_task.write(record_for_task).await;
1071        });
1072        let join_result = handle.await;
1073        // The task panicked; the join handle reports the panic without
1074        // propagating it.
1075        let join_err = join_result.expect_err("sink panic should surface as task panic");
1076        assert!(join_err.is_panic());
1077        // The framework now logs at tracing::error with the correlation_id.
1078        tracing::error!(
1079            target: "plexus::audit",
1080            %correlation_id,
1081            "audit sink panicked; record dropped"
1082        );
1083        assert!(logs_contain("audit sink panicked"));
1084        assert!(logs_contain(&correlation_id.to_string()));
1085    }
1086}