use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::ids::{AuditRecordId, CorrelationId};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum Outcome {
Success,
Failure {
code: String,
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[non_exhaustive]
pub struct AuditRecord {
pub id: AuditRecordId,
pub schema_version: u16,
pub actor_json: serde_json::Value,
pub operation: String,
pub target_ref: String,
pub created_at: DateTime<Utc>,
pub outcome: Outcome,
pub correlation_id: Option<CorrelationId>,
}
impl AuditRecord {
#[must_use]
pub fn new(
actor_json: serde_json::Value,
operation: String,
target_ref: String,
created_at: DateTime<Utc>,
outcome: Outcome,
) -> Self {
Self {
id: AuditRecordId::new(),
schema_version: crate::SCHEMA_VERSION,
actor_json,
operation,
target_ref,
created_at,
outcome,
correlation_id: None,
}
}
#[must_use]
pub fn with_correlation(mut self, correlation_id: CorrelationId) -> Self {
self.correlation_id = Some(correlation_id);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn fixture_record() -> AuditRecord {
let mut r = AuditRecord::new(
serde_json::json!({"kind": "operator", "username": "alice"}),
"principle.promote".into(),
"prn_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap(),
Outcome::Success,
);
r.id = "aud_01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap();
r
}
#[test]
fn construct_with_required_fields() {
let r = fixture_record();
assert_eq!(r.schema_version, crate::SCHEMA_VERSION);
assert_eq!(r.operation, "principle.promote");
assert_eq!(r.target_ref, "prn_01ARZ3NDEKTSV4RRFFQ69G5FAV");
assert!(matches!(r.outcome, Outcome::Success));
assert!(r.correlation_id.is_none());
}
#[test]
fn audit_record_has_no_default_in_practice() {
fn _signature_check(
actor: serde_json::Value,
op: String,
tgt: String,
ts: DateTime<Utc>,
out: Outcome,
) -> AuditRecord {
AuditRecord::new(actor, op, tgt, ts, out)
}
let _ = _signature_check;
}
#[test]
fn outcome_failure_serializes_with_code_and_reason() {
let r = AuditRecord::new(
serde_json::json!({"kind": "system"}),
"memory.accept".into(),
"mem_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap(),
Outcome::Failure {
code: "policy.gate.blocked".into(),
reason: "missing applies_when".into(),
},
);
let j = serde_json::to_value(&r).unwrap();
assert_eq!(j["outcome"]["status"], "failure");
assert_eq!(j["outcome"]["code"], "policy.gate.blocked");
assert_eq!(j["outcome"]["reason"], "missing applies_when");
}
#[test]
fn round_trip_with_correlation() {
let cor = CorrelationId::new();
let r = fixture_record().with_correlation(cor);
let j = serde_json::to_value(&r).unwrap();
let back: AuditRecord = serde_json::from_value(j).unwrap();
assert_eq!(r, back);
assert_eq!(back.correlation_id, Some(cor));
}
#[test]
fn audit_record_has_no_secret_named_keys() {
let r = fixture_record().with_correlation(CorrelationId::new());
let j = serde_json::to_value(&r).unwrap();
const FORBIDDEN: &[&str] = &[
"password",
"passwd",
"secret",
"secrets",
"token",
"tokens",
"api_key",
"apikey",
"access_key",
"private_key",
"privatekey",
"credential",
"credentials",
"session_token",
"bearer",
"auth_token",
];
fn walk(v: &serde_json::Value, forbidden: &[&str]) -> Vec<String> {
let mut hits = Vec::new();
match v {
serde_json::Value::Object(map) => {
for (k, val) in map {
let lk = k.to_ascii_lowercase();
if forbidden.iter().any(|f| lk == *f) {
hits.push(k.clone());
}
hits.extend(walk(val, forbidden));
}
}
serde_json::Value::Array(items) => {
for item in items {
hits.extend(walk(item, forbidden));
}
}
_ => {}
}
hits
}
let hits = walk(&j, FORBIDDEN);
assert!(
hits.is_empty(),
"AuditRecord serialized form contains secret-named keys: {hits:?} \n\
Doctrine anti-criterion violated. Either rename the field or move \n\
the data out of AuditRecord entirely.",
);
}
}