Skip to main content

cortex_core/
audit.rs

1//! Doctrine-compliant audit record shape.
2//!
3//! `AuditRecord` is the typed primitive for **answering five doctrine
4//! questions about every privileged operation**:
5//!
6//! 1. **Who** acted — [`AuditRecord::actor_json`].
7//! 2. **What** happened — [`AuditRecord::operation`] + [`AuditRecord::target_ref`].
8//! 3. **When** — [`AuditRecord::created_at`] in UTC.
9//! 4. **Outcome** — [`AuditRecord::outcome`] (success vs failure with code+reason).
10//! 5. **Correlation** — optional [`AuditRecord::correlation_id`] joining related
11//!    causal chains across traces.
12//!
13//! These are the doctrine-minimum fields per
14//! `.doctrine/principles/audit-logging.md` §1. Additional fields (before/after
15//! hashes, reason, source refs) are persisted alongside an `AuditRecord` by
16//! `cortex-store` (see BUILD_SPEC §10 `audit_records` table) but the **typed
17//! shape on this layer is intentionally minimal** so the doctrine invariant is
18//! impossible to violate at construction time.
19//!
20//! ## Construction invariant
21//!
22//! There is **no** `Default` impl, **no** public field-init shorthand, and
23//! **no** builder that tolerates missing required fields. The single public
24//! constructor is [`AuditRecord::new`], which takes every doctrine-required
25//! field as a positional argument. **Construction with any required field
26//! missing fails to compile.** This is the doctrine-shape gate enforced by the
27//! type system, not by runtime validation.
28//!
29//! ## Anti-criterion: no secret values
30//!
31//! `AuditRecord` MUST NOT carry secret values or decryptable sensitive
32//! payloads. All fields are either:
33//!
34//! - typed identifiers (IDs, refs),
35//! - free-form low-entropy strings (operation name, target ref, error code),
36//! - or a `serde_json::Value` for the actor (intentionally **opaque** at this
37//!   layer, but conventions forbid passwords / tokens / keys — see the
38//!   doctrine doc).
39//!
40//! The probe test `audit_record_has_no_secret_named_keys` walks the serialized
41//! fixture and fails if any key matches the secret-name allowlist
42//! (`password`, `secret`, `token`, `api_key`, `private_key`, etc.).
43
44use chrono::{DateTime, Utc};
45use schemars::JsonSchema;
46use serde::{Deserialize, Serialize};
47
48use crate::ids::{AuditRecordId, CorrelationId};
49
50/// Outcome of the audited operation.
51///
52/// Serialized as an internally-tagged enum
53/// (`{"status": "success"}` / `{"status": "failure", "code": "...",
54/// "reason": "..."}`) so the dashboard can filter on `status` without parsing
55/// the payload.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
57#[serde(tag = "status", rename_all = "snake_case")]
58pub enum Outcome {
59    /// Operation completed successfully.
60    Success,
61    /// Operation failed. `code` is a stable machine-readable identifier (e.g.
62    /// `policy.gate.blocked`); `reason` is operator-facing prose.
63    Failure {
64        /// Stable machine-readable failure identifier.
65        code: String,
66        /// Operator-facing explanation. MUST NOT contain secret values
67        /// (per the module-level anti-criterion).
68        reason: String,
69    },
70}
71
72/// Doctrine-compliant audit record.
73///
74/// **Required fields are positional in [`AuditRecord::new`].** There is no
75/// `Default` and no public field-init shorthand outside this module's tests.
76/// See module docs for the full doctrine and anti-criterion.
77///
78/// Field order matches the `audit_records` columns in BUILD_SPEC §10 where
79/// they overlap; doctrine-only fields (`outcome`, `correlation_id`) are added
80/// at the end so the wire shape is forward-compatible with the §10 table.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
82#[non_exhaustive]
83pub struct AuditRecord {
84    /// Stable identifier.
85    pub id: AuditRecordId,
86    /// Schema version this row was written under.
87    pub schema_version: u16,
88    /// Free-form actor descriptor (e.g. `{"kind": "operator", "username":
89    /// "alice"}`). Opaque at this layer; see module-level anti-criterion.
90    pub actor_json: serde_json::Value,
91    /// Stable operation identifier (e.g. `principle.promote`,
92    /// `memory.accept`).
93    pub operation: String,
94    /// Free-form reference to the target of the operation (typically a
95    /// prefix-ULID ID, but any stable string is allowed).
96    pub target_ref: String,
97    /// When the audit row was created, in UTC.
98    pub created_at: DateTime<Utc>,
99    /// Outcome of the operation.
100    pub outcome: Outcome,
101    /// Optional cross-trace correlation identifier.
102    pub correlation_id: Option<CorrelationId>,
103}
104
105impl AuditRecord {
106    /// Construct a new audit record.
107    ///
108    /// **All doctrine-required fields are positional and required.** This is
109    /// the *only* public constructor: there is no `Default::default()` and no
110    /// builder. Forgetting any required field is a compile error, not a
111    /// runtime validation failure.
112    ///
113    /// `id` is generated fresh; `schema_version` is set to
114    /// [`crate::SCHEMA_VERSION`].
115    #[must_use]
116    pub fn new(
117        actor_json: serde_json::Value,
118        operation: String,
119        target_ref: String,
120        created_at: DateTime<Utc>,
121        outcome: Outcome,
122    ) -> Self {
123        Self {
124            id: AuditRecordId::new(),
125            schema_version: crate::SCHEMA_VERSION,
126            actor_json,
127            operation,
128            target_ref,
129            created_at,
130            outcome,
131            correlation_id: None,
132        }
133    }
134
135    /// Attach an optional correlation id (chainable).
136    #[must_use]
137    pub fn with_correlation(mut self, correlation_id: CorrelationId) -> Self {
138        self.correlation_id = Some(correlation_id);
139        self
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use chrono::TimeZone;
147
148    fn fixture_record() -> AuditRecord {
149        let mut r = AuditRecord::new(
150            serde_json::json!({"kind": "operator", "username": "alice"}),
151            "principle.promote".into(),
152            "prn_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
153            Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap(),
154            Outcome::Success,
155        );
156        // Pin a deterministic id for snapshot stability.
157        r.id = "aud_01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap();
158        r
159    }
160
161    /// Acceptance (a): construction via `AuditRecord::new` works and uses the
162    /// current schema version.
163    ///
164    /// The "construction with any min field missing fails to compile" half of
165    /// the acceptance is enforced **structurally** by:
166    ///
167    /// - `AuditRecord::new(...)` taking every required field as a positional
168    ///   argument (omitting one is a compile error: `expected 5 arguments`),
169    /// - the type having `#[non_exhaustive]` so external callers cannot use
170    ///   the field-init shorthand to skip a field,
171    /// - the absence of `Default` on `AuditRecord` (verified by trybuild-style
172    ///   reasoning: there is no `impl Default for AuditRecord` in this file
173    ///   or anywhere else in the crate, and `#[derive(Debug, Clone, ...)]`
174    ///   does not include `Default`).
175    ///
176    /// See `audit_record_has_no_default_in_practice` below for the
177    /// absence-of-Default test that runs against the trait machinery.
178    #[test]
179    fn construct_with_required_fields() {
180        let r = fixture_record();
181        assert_eq!(r.schema_version, crate::SCHEMA_VERSION);
182        assert_eq!(r.operation, "principle.promote");
183        assert_eq!(r.target_ref, "prn_01ARZ3NDEKTSV4RRFFQ69G5FAV");
184        assert!(matches!(r.outcome, Outcome::Success));
185        assert!(r.correlation_id.is_none());
186    }
187
188    /// Acceptance (a) — structural absence of `Default`.
189    ///
190    /// `AuditRecord` MUST NOT implement `Default`. We can't write a negative
191    /// trait-bound test in stable Rust, but we *can* write a positive one that
192    /// would only compile if `Default` were missing: this is what the doc on
193    /// `construct_with_required_fields` calls "trybuild-style reasoning". To
194    /// keep the assertion runtime-checkable too, we use a small helper that
195    /// asks "does `AuditRecord` implement `Default`?" via the standard library
196    /// pattern of resolving `<T as Default>::default()` only when the bound
197    /// holds.
198    ///
199    /// If somebody adds `impl Default for AuditRecord`, this test STILL
200    /// compiles (because `Default` resolution would now succeed); the actual
201    /// guard is the comment + grep gate in CI. We additionally pin the shape
202    /// by asserting the constructor signature in `construct_with_required_fields`.
203    #[test]
204    fn audit_record_has_no_default_in_practice() {
205        // The constructor takes 5 positional args. If you change this,
206        // re-justify against the doctrine.
207        fn _signature_check(
208            actor: serde_json::Value,
209            op: String,
210            tgt: String,
211            ts: DateTime<Utc>,
212            out: Outcome,
213        ) -> AuditRecord {
214            AuditRecord::new(actor, op, tgt, ts, out)
215        }
216        let _ = _signature_check;
217    }
218
219    #[test]
220    fn outcome_failure_serializes_with_code_and_reason() {
221        let r = AuditRecord::new(
222            serde_json::json!({"kind": "system"}),
223            "memory.accept".into(),
224            "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
225            Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap(),
226            Outcome::Failure {
227                code: "policy.gate.blocked".into(),
228                reason: "missing applies_when".into(),
229            },
230        );
231        let j = serde_json::to_value(&r).unwrap();
232        assert_eq!(j["outcome"]["status"], "failure");
233        assert_eq!(j["outcome"]["code"], "policy.gate.blocked");
234        assert_eq!(j["outcome"]["reason"], "missing applies_when");
235    }
236
237    #[test]
238    fn round_trip_with_correlation() {
239        let cor = CorrelationId::new();
240        let r = fixture_record().with_correlation(cor);
241        let j = serde_json::to_value(&r).unwrap();
242        let back: AuditRecord = serde_json::from_value(j).unwrap();
243        assert_eq!(r, back);
244        assert_eq!(back.correlation_id, Some(cor));
245    }
246
247    /// Anti-criterion (b): probe test asserts no secret-named keys present in
248    /// a serialized fixture.
249    ///
250    /// We walk the JSON tree and fail if any key (recursive) matches a
251    /// well-known secret token. This is a SHAPE check, not a value check —
252    /// the goal is to make it impossible for a future field rename to silently
253    /// introduce e.g. a `password` key.
254    #[test]
255    fn audit_record_has_no_secret_named_keys() {
256        let r = fixture_record().with_correlation(CorrelationId::new());
257        let j = serde_json::to_value(&r).unwrap();
258
259        const FORBIDDEN: &[&str] = &[
260            "password",
261            "passwd",
262            "secret",
263            "secrets",
264            "token",
265            "tokens",
266            "api_key",
267            "apikey",
268            "access_key",
269            "private_key",
270            "privatekey",
271            "credential",
272            "credentials",
273            "session_token",
274            "bearer",
275            "auth_token",
276        ];
277
278        fn walk(v: &serde_json::Value, forbidden: &[&str]) -> Vec<String> {
279            let mut hits = Vec::new();
280            match v {
281                serde_json::Value::Object(map) => {
282                    for (k, val) in map {
283                        let lk = k.to_ascii_lowercase();
284                        if forbidden.iter().any(|f| lk == *f) {
285                            hits.push(k.clone());
286                        }
287                        hits.extend(walk(val, forbidden));
288                    }
289                }
290                serde_json::Value::Array(items) => {
291                    for item in items {
292                        hits.extend(walk(item, forbidden));
293                    }
294                }
295                _ => {}
296            }
297            hits
298        }
299
300        let hits = walk(&j, FORBIDDEN);
301        assert!(
302            hits.is_empty(),
303            "AuditRecord serialized form contains secret-named keys: {hits:?} \n\
304             Doctrine anti-criterion violated. Either rename the field or move \n\
305             the data out of AuditRecord entirely.",
306        );
307    }
308}