use crate::secret::{CodeId, SessionId, SubjectId};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CodeAuthEvent {
CodeIssued {
code_id: CodeId,
purpose: Option<String>,
},
CodeRedeemed {
code_id: CodeId,
subject_id: SubjectId,
},
RedemptionFailed {
reason: crate::error::RedemptionFailReason,
},
CodeRevoked {
code_id: CodeId,
scope: Option<String>,
},
SessionIssued {
session_id: SessionId,
subject_id: SubjectId,
},
SessionValidateFailed,
SessionRevoked {
session_id: SessionId,
},
FormTokenReplay {
purpose: String,
},
RateLimitHit {
key_fingerprint: String,
purpose: Option<String>,
},
KeyVersionMissing {
version: crate::hashing::KeyVersion,
},
}
impl CodeAuthEvent {
#[must_use]
pub fn key(&self) -> &'static str {
match self {
Self::CodeIssued { .. } => "code.issue.succeeded",
Self::CodeRedeemed { .. } => "code.redeem.succeeded",
Self::RedemptionFailed { .. } => "code.redeem.failed",
Self::CodeRevoked { .. } => "code.revoke.succeeded",
Self::SessionIssued { .. } => "session.issue.succeeded",
Self::SessionValidateFailed => "session.validate.failed",
Self::SessionRevoked { .. } => "session.revoke.succeeded",
Self::FormTokenReplay { .. } => "form_token.consume.replay",
Self::RateLimitHit { .. } => "rate_limit.blocked",
Self::KeyVersionMissing { .. } => "key_provider.missing_version",
}
}
}
pub trait AuditSink {
fn record(&self, event: CodeAuthEvent);
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopAuditSink;
impl AuditSink for NoopAuditSink {
fn record(&self, _event: CodeAuthEvent) {}
}
#[cfg(any(test, feature = "test-utils"))]
#[derive(Debug, Default)]
pub struct CollectingAuditSink {
events: std::sync::Mutex<Vec<CodeAuthEvent>>,
}
#[cfg(any(test, feature = "test-utils"))]
impl CollectingAuditSink {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn drain(&self) -> Vec<CodeAuthEvent> {
self.events.lock().unwrap().drain(..).collect()
}
pub fn len(&self) -> usize {
self.events.lock().unwrap().len()
}
pub fn is_empty(&self) -> bool {
self.events.lock().unwrap().is_empty()
}
}
#[cfg(any(test, feature = "test-utils"))]
impl AuditSink for CollectingAuditSink {
fn record(&self, event: CodeAuthEvent) {
self.events.lock().unwrap().push(event);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::RedemptionFailReason;
#[test]
fn event_keys_are_stable() {
let events: &[(&str, CodeAuthEvent)] = &[
(
"code.issue.succeeded",
CodeAuthEvent::CodeIssued {
code_id: CodeId::new("c1".into()),
purpose: None,
},
),
(
"code.redeem.succeeded",
CodeAuthEvent::CodeRedeemed {
code_id: CodeId::new("c1".into()),
subject_id: SubjectId::new("s1".into()),
},
),
(
"code.redeem.failed",
CodeAuthEvent::RedemptionFailed {
reason: RedemptionFailReason::Expired,
},
),
(
"code.revoke.succeeded",
CodeAuthEvent::CodeRevoked {
code_id: CodeId::new("c1".into()),
scope: None,
},
),
(
"session.issue.succeeded",
CodeAuthEvent::SessionIssued {
session_id: SessionId::new("s1".into()),
subject_id: SubjectId::new("u1".into()),
},
),
(
"session.validate.failed",
CodeAuthEvent::SessionValidateFailed,
),
(
"session.revoke.succeeded",
CodeAuthEvent::SessionRevoked {
session_id: SessionId::new("s1".into()),
},
),
(
"form_token.consume.replay",
CodeAuthEvent::FormTokenReplay {
purpose: "logout".into(),
},
),
(
"rate_limit.blocked",
CodeAuthEvent::RateLimitHit {
key_fingerprint: "fp1".into(),
purpose: None,
},
),
(
"key_provider.missing_version",
CodeAuthEvent::KeyVersionMissing {
version: crate::hashing::KeyVersion::new("v0"),
},
),
];
for (expected_key, event) in events {
assert_eq!(event.key(), *expected_key, "key mismatch for {event:?}");
}
}
#[test]
fn noop_sink_accepts_all_events() {
let sink = NoopAuditSink;
sink.record(CodeAuthEvent::SessionValidateFailed);
sink.record(CodeAuthEvent::FormTokenReplay {
purpose: "logout".into(),
});
}
#[test]
fn collecting_sink_drains() {
let sink = CollectingAuditSink::new();
assert!(sink.is_empty());
sink.record(CodeAuthEvent::SessionValidateFailed);
sink.record(CodeAuthEvent::SessionRevoked {
session_id: SessionId::new("s1".into()),
});
assert_eq!(sink.len(), 2);
let drained = sink.drain();
assert_eq!(drained.len(), 2);
assert!(sink.is_empty());
assert_eq!(drained[0].key(), "session.validate.failed");
assert_eq!(drained[1].key(), "session.revoke.succeeded");
}
#[test]
fn events_contain_no_secrets_by_construction() {
let forbidden = ["secret", "hmac", "pepper", "cookie", "password"];
let events = [
CodeAuthEvent::CodeIssued {
code_id: CodeId::new("c1".into()),
purpose: None,
},
CodeAuthEvent::RedemptionFailed {
reason: RedemptionFailReason::AlreadyUsed,
},
CodeAuthEvent::RateLimitHit {
key_fingerprint: "fp".into(),
purpose: Some("redeem".into()),
},
];
for ev in &events {
let dbg = format!("{ev:?}");
for word in forbidden {
assert!(
!dbg.to_lowercase().contains(word),
"event debug contains forbidden word {word:?}: {dbg}"
);
}
}
}
}