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}