ppoppo_sdk_core/audit/mod.rs
1//! M48 + M49 — verify-failure audit emission port + per-source rate
2//! limiter (RFC_2026-05-04_jwt-full-adoption Phase 9).
3//!
4//! ── Why pas-external owns the port, not the schema (β1) ─────────────────
5//!
6//! [`AuditSink`] is purely abstract. pas-external ships only:
7//!
8//! - [`NoopAuditSink`] — explicit "I don't want audit emission" choice
9//! - `MemoryAuditSink` — `test-support`-gated adapter for boundary
10//! verification (downstream consumers' integration tests)
11//!
12//! Production adapters (chat-auth in PCS, future RCW/CTW middleware)
13//! live in their own crates and decide their own persistence schema
14//! (Postgres table, tracing-subscriber piping into Cloud Logging, etc).
15//! The SDK does not bake in `sqlx` or any specific schema — schema
16//! decisions belong to whichever service operates the audit pipeline.
17//! See [`super::token::port::BearerVerifier`] for the matching
18//! port-and-adapter precedent (D-04 γ, locked 2026-05-05).
19//!
20//! ── Why composition, not orchestration (refinement #1) ─────────────────
21//!
22//! [`super::token::PasJwtVerifier`] holds ONE port (`Arc<dyn AuditSink>`),
23//! not two. Rate-limiting is a property of the sink, expressed by
24//! wrapping any sink in a future `RateLimitedAuditSink<S, L>` (Phase
25//! 9.C). This matches `epoch_revocation`'s deep-module note:
26//! composition lives in the adapter layer; the engine sees a single
27//! port. Future stacking is free (`BatchedAuditSink`,
28//! `AsyncSpawnAuditSink`, etc).
29//!
30//! ── Failure-mode contract — non-blocking ────────────────────────────────
31//!
32//! [`AuditSink::record_failure`] returns `()` (no [`Result`]). M48 is
33//! observability, NOT auth-flow critical. Adapters log internal substrate
34//! failures via `tracing::error!` and continue. The verify hot path
35//! MUST NEVER degrade because audit persistence failed; this contract
36//! is enforced at the trait surface (no error to bubble in the first
37//! place). Callers needing a Result for instrumentation can wrap in a
38//! private struct that records the result internally.
39//!
40//! ── SLA contract ────────────────────────────────────────────────────────
41//!
42//! Implementations SHOULD return within 10ms. Heavier work (HTTP
43//! roundtrip, batch flush, retry) MUST be spawned onto a background
44//! task so the verify hot path is not blocked. The `&self` (not
45//! `&mut self`) + `Send + Sync` bounds let one verifier emit
46//! concurrently without per-call locking.
47//!
48//! ── Phase 10 inheritance ────────────────────────────────────────────────
49//!
50//! Phase 10.11 (RP middleware — `pas-external::oidc::IdTokenVerifier`)
51//! emits through the same [`AuditSink`] port. id_token verify failures
52//! and access_token verify failures share the audit pipeline; the
53//! [`VerifyErrorKind`] enum gains id_token-specific variants in 10.11
54//! without breaking the contract.
55
56pub mod rate_limit;
57pub mod rate_limited_sink;
58pub mod sink;
59
60pub use rate_limit::{MemoryRateLimiter, RateLimiter};
61pub use rate_limited_sink::RateLimitedAuditSink;
62pub use sink::{
63 AuditEvent, AuditSink, IdTokenFailureKind, NoopAuditSink, VerifyErrorKind,
64 compose_id_token_source_id, compose_source_id,
65};
66
67#[cfg(any(test, feature = "test-support"))]
68pub use sink::MemoryAuditSink;
69
70/// Opaque per-source bucket key for a `RateLimiter`.
71///
72/// Wraps a `String` so the underlying derivation strategy stays a hidden
73/// implementation detail of whoever builds the key.
74/// [`AuditEvent::rate_limit_key`] derives compound `client_id_hint ‖
75/// kid_hint` keys per Phase 9 design call (e); future callers (Phase
76/// 10.11 nonce-store, OAuth callback PKCE throttle, etc) compose their
77/// own keys from substrate-relevant fields.
78///
79/// Newtype (rather than passing `&str` through the limiter trait) lets
80/// the type system prove that callers used the correct derivation —
81/// stringly-typed keys would silently re-bucket on stray formatting
82/// drift (whitespace, casing, separator choice).
83#[derive(Debug, Clone, PartialEq, Eq, Hash)]
84pub struct RateLimitKey(String);
85
86impl RateLimitKey {
87 /// Construct from any string-like source.
88 #[must_use]
89 pub fn new(key: impl Into<String>) -> Self {
90 Self(key.into())
91 }
92
93 /// Borrow the underlying string. Limiter substrates need this to
94 /// build their backend key (HashMap entry, Redis key, etc).
95 #[must_use]
96 pub fn as_str(&self) -> &str {
97 &self.0
98 }
99}
100
101impl From<&str> for RateLimitKey {
102 fn from(s: &str) -> Self {
103 Self(s.to_owned())
104 }
105}
106
107impl From<String> for RateLimitKey {
108 fn from(s: String) -> Self {
109 Self(s)
110 }
111}
112
113impl std::fmt::Display for RateLimitKey {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 f.write_str(&self.0)
116 }
117}
118
119#[cfg(test)]
120mod rate_limit_key_tests {
121 use super::RateLimitKey;
122
123 #[test]
124 fn display_round_trips_through_as_str() {
125 let key = RateLimitKey::new("rcw-client::k1");
126 assert_eq!(key.as_str(), "rcw-client::k1");
127 assert_eq!(format!("{key}"), "rcw-client::k1");
128 }
129
130 #[test]
131 fn equal_keys_hash_equal() {
132 use std::collections::HashSet;
133 let mut set = HashSet::new();
134 set.insert(RateLimitKey::from("rcw::k1"));
135 set.insert(RateLimitKey::from("rcw::k1".to_owned()));
136 assert_eq!(set.len(), 1, "From<&str> and From<String> must produce equal keys");
137 }
138}