Skip to main content

codlet_core/
audit.rs

1//! Security audit events and the `AuditSink` trait (RFC-012).
2//!
3//! [`CodeAuthEvent`] represents every notable security event codlet can emit.
4//! All variants are **redacted by construction**: no plaintext code, token,
5//! session secret, raw lookup key, HMAC key, or raw IP address appears in any
6//! variant (RFC-012 §10.3).
7//!
8//! The host application provides an [`AuditSink`] implementation and maps
9//! codlet events into its own audit schema, logging backend, or metrics
10//! pipeline. codlet never makes logging decisions for the host.
11//!
12//! ## Forbidden content
13//!
14//! The following must never appear in any event field:
15//! - plaintext code, token, or session secret;
16//! - raw HMAC lookup key or key bytes;
17//! - display name, email, or other personally identifiable free text;
18//! - raw IP address (use a stable fingerprint / hashed value instead).
19
20use crate::secret::{CodeId, SessionId, SubjectId};
21
22/// A notable security event emitted by codlet (RFC-012 §10.2).
23///
24/// Variants use stable string names following `noun.verb.outcome` convention.
25/// All fields are opaque identifiers or redacted fingerprints — no secrets.
26#[derive(Debug, Clone, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum CodeAuthEvent {
29    /// A one-time code was successfully issued and a record inserted.
30    ///
31    /// Event key: `code.issue.succeeded`
32    CodeIssued {
33        /// Opaque record ID (not the plaintext code or lookup key).
34        code_id: CodeId,
35        /// Optional host-provided purpose label.
36        purpose: Option<String>,
37    },
38
39    /// A one-time code was successfully claimed (atomic winner).
40    ///
41    /// Event key: `code.redeem.succeeded`
42    CodeRedeemed {
43        /// The record that was claimed.
44        code_id: CodeId,
45        /// The subject that claimed it.
46        subject_id: SubjectId,
47    },
48
49    /// A code redemption attempt failed.
50    ///
51    /// Event key: `code.redeem.failed`
52    RedemptionFailed {
53        /// Stable internal classification (safe for logs; not for users).
54        reason: crate::error::RedemptionFailReason,
55    },
56
57    /// A code was administratively revoked.
58    ///
59    /// Event key: `code.revoke.succeeded`
60    CodeRevoked {
61        /// The record that was revoked.
62        code_id: CodeId,
63        /// Optional scope at which the revocation was scoped.
64        scope: Option<String>,
65    },
66
67    /// A session was successfully issued.
68    ///
69    /// Event key: `session.issue.succeeded`
70    SessionIssued {
71        /// Opaque session record ID (not the bearer secret).
72        session_id: SessionId,
73        /// The authenticated subject.
74        subject_id: SubjectId,
75    },
76
77    /// A session validation attempt found no valid session.
78    ///
79    /// Event key: `session.validate.failed`
80    ///
81    /// Emitted only when the host opts in; not emitted on every anonymous
82    /// request to avoid log noise.
83    SessionValidateFailed,
84
85    /// A session was explicitly revoked (logout or incident response).
86    ///
87    /// Event key: `session.revoke.succeeded`
88    SessionRevoked {
89        /// The revoked session record ID.
90        session_id: SessionId,
91    },
92
93    /// A form-token consume returned `Replay` (idempotent second submit).
94    ///
95    /// Event key: `form_token.consume.replay`
96    FormTokenReplay {
97        /// The purpose label of the token that was replayed.
98        purpose: String,
99    },
100
101    /// A rate-limit threshold was exceeded.
102    ///
103    /// Event key: `rate_limit.blocked`
104    RateLimitHit {
105        /// A stable, privacy-safe fingerprint of the rate-limit key.
106        /// Must not be the raw IP or raw user identifier (RFC-012 §10.3).
107        key_fingerprint: String,
108        /// The purpose or action class that was limited.
109        purpose: Option<String>,
110    },
111
112    /// A key version was requested but not found in the provider.
113    ///
114    /// Event key: `key_provider.missing_version`
115    KeyVersionMissing {
116        /// The version label that was requested.
117        version: crate::hashing::KeyVersion,
118    },
119}
120
121impl CodeAuthEvent {
122    /// A stable, machine-readable event key for this variant.
123    ///
124    /// Suitable for structured logging, metrics labels, and audit schemas.
125    /// Keys follow the `noun.verb.outcome` convention from RFC-012 §10.2.
126    #[must_use]
127    pub fn key(&self) -> &'static str {
128        match self {
129            Self::CodeIssued { .. } => "code.issue.succeeded",
130            Self::CodeRedeemed { .. } => "code.redeem.succeeded",
131            Self::RedemptionFailed { .. } => "code.redeem.failed",
132            Self::CodeRevoked { .. } => "code.revoke.succeeded",
133            Self::SessionIssued { .. } => "session.issue.succeeded",
134            Self::SessionValidateFailed => "session.validate.failed",
135            Self::SessionRevoked { .. } => "session.revoke.succeeded",
136            Self::FormTokenReplay { .. } => "form_token.consume.replay",
137            Self::RateLimitHit { .. } => "rate_limit.blocked",
138            Self::KeyVersionMissing { .. } => "key_provider.missing_version",
139        }
140    }
141}
142
143/// A recipient of security audit events (RFC-012 §3).
144///
145/// Implement this trait to connect codlet events to a logging backend, an
146/// audit database, or a metrics pipeline. The implementation must not block the
147/// calling thread for extended periods; use a background channel if the backend
148/// is slow.
149///
150/// The implementation must not log the event in a way that violates the
151/// redaction contract — i.e., it must not attempt to extract or store
152/// plaintext secrets from the event fields.
153pub trait AuditSink {
154    /// Receive a security event. Called synchronously in the hot path; must
155    /// return quickly. Fire-and-forget semantics: codlet does not retry on
156    /// failure.
157    fn record(&self, event: CodeAuthEvent);
158}
159
160/// A no-op audit sink that discards every event. Useful as a default when the
161/// host has not configured a sink, and for unit tests that do not care about
162/// events.
163#[derive(Debug, Default, Clone, Copy)]
164pub struct NoopAuditSink;
165
166impl AuditSink for NoopAuditSink {
167    fn record(&self, _event: CodeAuthEvent) {}
168}
169
170/// An audit sink that accumulates events in a `Vec` for inspection in tests.
171#[cfg(any(test, feature = "test-utils"))]
172#[derive(Debug, Default)]
173pub struct CollectingAuditSink {
174    events: std::sync::Mutex<Vec<CodeAuthEvent>>,
175}
176
177#[cfg(any(test, feature = "test-utils"))]
178impl CollectingAuditSink {
179    /// Construct an empty collecting sink.
180    #[must_use]
181    pub fn new() -> Self {
182        Self::default()
183    }
184
185    /// Drain and return all collected events.
186    pub fn drain(&self) -> Vec<CodeAuthEvent> {
187        self.events.lock().unwrap().drain(..).collect()
188    }
189
190    /// Number of events collected so far.
191    pub fn len(&self) -> usize {
192        self.events.lock().unwrap().len()
193    }
194
195    /// Whether any events have been collected.
196    pub fn is_empty(&self) -> bool {
197        self.events.lock().unwrap().is_empty()
198    }
199}
200
201#[cfg(any(test, feature = "test-utils"))]
202impl AuditSink for CollectingAuditSink {
203    fn record(&self, event: CodeAuthEvent) {
204        self.events.lock().unwrap().push(event);
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::error::RedemptionFailReason;
212
213    #[test]
214    fn event_keys_are_stable() {
215        let events: &[(&str, CodeAuthEvent)] = &[
216            (
217                "code.issue.succeeded",
218                CodeAuthEvent::CodeIssued {
219                    code_id: CodeId::new("c1".into()),
220                    purpose: None,
221                },
222            ),
223            (
224                "code.redeem.succeeded",
225                CodeAuthEvent::CodeRedeemed {
226                    code_id: CodeId::new("c1".into()),
227                    subject_id: SubjectId::new("s1".into()),
228                },
229            ),
230            (
231                "code.redeem.failed",
232                CodeAuthEvent::RedemptionFailed {
233                    reason: RedemptionFailReason::Expired,
234                },
235            ),
236            (
237                "code.revoke.succeeded",
238                CodeAuthEvent::CodeRevoked {
239                    code_id: CodeId::new("c1".into()),
240                    scope: None,
241                },
242            ),
243            (
244                "session.issue.succeeded",
245                CodeAuthEvent::SessionIssued {
246                    session_id: SessionId::new("s1".into()),
247                    subject_id: SubjectId::new("u1".into()),
248                },
249            ),
250            (
251                "session.validate.failed",
252                CodeAuthEvent::SessionValidateFailed,
253            ),
254            (
255                "session.revoke.succeeded",
256                CodeAuthEvent::SessionRevoked {
257                    session_id: SessionId::new("s1".into()),
258                },
259            ),
260            (
261                "form_token.consume.replay",
262                CodeAuthEvent::FormTokenReplay {
263                    purpose: "logout".into(),
264                },
265            ),
266            (
267                "rate_limit.blocked",
268                CodeAuthEvent::RateLimitHit {
269                    key_fingerprint: "fp1".into(),
270                    purpose: None,
271                },
272            ),
273            (
274                "key_provider.missing_version",
275                CodeAuthEvent::KeyVersionMissing {
276                    version: crate::hashing::KeyVersion::new("v0"),
277                },
278            ),
279        ];
280        for (expected_key, event) in events {
281            assert_eq!(event.key(), *expected_key, "key mismatch for {event:?}");
282        }
283    }
284
285    #[test]
286    fn noop_sink_accepts_all_events() {
287        let sink = NoopAuditSink;
288        sink.record(CodeAuthEvent::SessionValidateFailed);
289        sink.record(CodeAuthEvent::FormTokenReplay {
290            purpose: "logout".into(),
291        });
292    }
293
294    #[test]
295    fn collecting_sink_drains() {
296        let sink = CollectingAuditSink::new();
297        assert!(sink.is_empty());
298        sink.record(CodeAuthEvent::SessionValidateFailed);
299        sink.record(CodeAuthEvent::SessionRevoked {
300            session_id: SessionId::new("s1".into()),
301        });
302        assert_eq!(sink.len(), 2);
303        let drained = sink.drain();
304        assert_eq!(drained.len(), 2);
305        assert!(sink.is_empty());
306        assert_eq!(drained[0].key(), "session.validate.failed");
307        assert_eq!(drained[1].key(), "session.revoke.succeeded");
308    }
309
310    #[test]
311    fn events_contain_no_secrets_by_construction() {
312        // Guard: every event variant's Debug output must not contain any of the
313        // forbidden content listed in the module docs.
314        let forbidden = ["secret", "hmac", "pepper", "cookie", "password"];
315        let events = [
316            CodeAuthEvent::CodeIssued {
317                code_id: CodeId::new("c1".into()),
318                purpose: None,
319            },
320            CodeAuthEvent::RedemptionFailed {
321                reason: RedemptionFailReason::AlreadyUsed,
322            },
323            CodeAuthEvent::RateLimitHit {
324                key_fingerprint: "fp".into(),
325                purpose: Some("redeem".into()),
326            },
327        ];
328        for ev in &events {
329            let dbg = format!("{ev:?}");
330            for word in forbidden {
331                assert!(
332                    !dbg.to_lowercase().contains(word),
333                    "event debug contains forbidden word {word:?}: {dbg}"
334                );
335            }
336        }
337    }
338}