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}