Skip to main content

made_core/entities/
audit_record.rs

1//! [`AuditRecord`] — one tamper-evident fact in a ceremony's journal.
2//!
3//! Publishing a typed event does not make an audit. A message can be
4//! lost, duplicated, published after persistence failed, or stored
5//! without a verifiable order. A record binds a fact to its position
6//! and to every fact before it, so removing, altering, reordering or
7//! inserting one is detectable without trusting the store that held it.
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use time::{format_description::well_known::Rfc3339, OffsetDateTime};
12
13use crate::error::DomainError;
14use crate::value_objects::{
15    AuditActor, AuditEventType, AuditRecordHash, AuditSequence, CeremonyId, CeremonyName,
16    CeremonyVersion, EventId, TraceContext,
17};
18
19/// Domain separator. A digest computed under a different scheme can
20/// never collide with one computed under this version, and bumping it
21/// is how the algorithm is versioned.
22const CANONICAL_SCHEME: &[u8] = b"underpass.made.audit-record.v1";
23
24/// Version of the record's field set. It participates in the digest,
25/// so records written under different shapes cannot be silently mixed
26/// into one chain.
27pub const AUDIT_RECORD_SCHEMA_VERSION: u32 = 1;
28
29/// One fact in a ceremony's audit journal.
30///
31/// Immutable by construction: the digest is computed once, from the
32/// content and the previous record's digest, and every accessor is
33/// read-only. There is no setter that could leave the digest stale.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct AuditRecord {
36    event_id: EventId,
37    event_type: AuditEventType,
38    schema_version: u32,
39    ceremony_id: CeremonyId,
40    definition_name: CeremonyName,
41    definition_version: CeremonyVersion,
42    sequence: AuditSequence,
43    #[serde(with = "time::serde::rfc3339")]
44    occurred_at: OffsetDateTime,
45    actor: AuditActor,
46    #[serde(default)]
47    correlation_id: Option<EventId>,
48    #[serde(default)]
49    causation_id: Option<EventId>,
50    #[serde(default)]
51    trace_id: Option<String>,
52    #[serde(default)]
53    previous_record_hash: Option<AuditRecordHash>,
54    record_hash: AuditRecordHash,
55}
56
57/// Everything a record states except its position and its digest.
58///
59/// Separating the fact from its place in the chain keeps the caller
60/// from choosing either: the journal assigns the sequence and the
61/// record computes its own digest.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct AuditFact {
64    pub event_id: EventId,
65    pub event_type: AuditEventType,
66    pub ceremony_id: CeremonyId,
67    pub definition_name: CeremonyName,
68    pub definition_version: CeremonyVersion,
69    pub occurred_at: OffsetDateTime,
70    pub actor: AuditActor,
71    pub correlation_id: Option<EventId>,
72    pub causation_id: Option<EventId>,
73    pub trace: Option<TraceContext>,
74}
75
76impl AuditRecord {
77    /// Seal a fact as the first record of a ceremony's journal.
78    pub fn first(fact: AuditFact) -> Result<Self, DomainError> {
79        Self::seal(fact, AuditSequence::FIRST, None)
80    }
81
82    /// Seal a fact as the record that follows `previous`.
83    ///
84    /// The successor's position and previous digest are taken from the
85    /// predecessor rather than supplied, so a caller cannot append a
86    /// record that claims to follow something it does not.
87    pub fn following(fact: AuditFact, previous: &Self) -> Result<Self, DomainError> {
88        if fact.ceremony_id != previous.ceremony_id {
89            return Err(DomainError::InvariantViolated {
90                reason: "an audit record must belong to the same ceremony as its predecessor",
91            });
92        }
93        Self::seal(fact, previous.sequence.next(), Some(previous.record_hash))
94    }
95
96    fn seal(
97        fact: AuditFact,
98        sequence: AuditSequence,
99        previous_record_hash: Option<AuditRecordHash>,
100    ) -> Result<Self, DomainError> {
101        let trace_id = fact.trace.map(|trace| trace.trace_id().to_owned());
102        let mut record = Self {
103            event_id: fact.event_id,
104            event_type: fact.event_type,
105            schema_version: AUDIT_RECORD_SCHEMA_VERSION,
106            ceremony_id: fact.ceremony_id,
107            definition_name: fact.definition_name,
108            definition_version: fact.definition_version,
109            sequence,
110            occurred_at: fact.occurred_at,
111            actor: fact.actor,
112            correlation_id: fact.correlation_id,
113            causation_id: fact.causation_id,
114            trace_id,
115            previous_record_hash,
116            record_hash: AuditRecordHash::from_bytes([0; 32]),
117        };
118        record.record_hash = record.compute_hash()?;
119        Ok(record)
120    }
121
122    #[must_use]
123    pub fn event_id(&self) -> &EventId {
124        &self.event_id
125    }
126
127    #[must_use]
128    pub fn event_type(&self) -> AuditEventType {
129        self.event_type
130    }
131
132    #[must_use]
133    pub fn schema_version(&self) -> u32 {
134        self.schema_version
135    }
136
137    #[must_use]
138    pub fn ceremony_id(&self) -> &CeremonyId {
139        &self.ceremony_id
140    }
141
142    #[must_use]
143    pub fn definition_name(&self) -> &CeremonyName {
144        &self.definition_name
145    }
146
147    #[must_use]
148    pub fn definition_version(&self) -> &CeremonyVersion {
149        &self.definition_version
150    }
151
152    #[must_use]
153    pub fn sequence(&self) -> AuditSequence {
154        self.sequence
155    }
156
157    #[must_use]
158    pub fn occurred_at(&self) -> OffsetDateTime {
159        self.occurred_at
160    }
161
162    #[must_use]
163    pub fn actor(&self) -> &AuditActor {
164        &self.actor
165    }
166
167    #[must_use]
168    pub fn correlation_id(&self) -> Option<&EventId> {
169        self.correlation_id.as_ref()
170    }
171
172    #[must_use]
173    pub fn causation_id(&self) -> Option<&EventId> {
174        self.causation_id.as_ref()
175    }
176
177    #[must_use]
178    pub fn trace_id(&self) -> Option<&str> {
179        self.trace_id.as_deref()
180    }
181
182    #[must_use]
183    pub fn previous_record_hash(&self) -> Option<AuditRecordHash> {
184        self.previous_record_hash
185    }
186
187    #[must_use]
188    pub fn record_hash(&self) -> AuditRecordHash {
189        self.record_hash
190    }
191
192    /// Whether the digest still matches the content.
193    ///
194    /// A record that fails this was altered after it was sealed, or was
195    /// never sealed by this implementation.
196    pub fn digest_is_intact(&self) -> Result<bool, DomainError> {
197        Ok(self.compute_hash()? == self.record_hash)
198    }
199
200    /// Whether this record legitimately continues `previous`.
201    ///
202    /// Checks the three ways a chain breaks at a join: the wrong
203    /// ceremony, a position that is not the immediate successor, and a
204    /// previous digest that does not match the record it names.
205    #[must_use]
206    pub fn continues(&self, previous: &Self) -> bool {
207        self.ceremony_id == previous.ceremony_id
208            && self.sequence.follows(previous.sequence)
209            && self.previous_record_hash == Some(previous.record_hash)
210    }
211
212    fn compute_hash(&self) -> Result<AuditRecordHash, DomainError> {
213        let occurred_at =
214            self.occurred_at
215                .format(&Rfc3339)
216                .map_err(|_| DomainError::InvariantViolated {
217                    reason: "audit record timestamp cannot be rendered canonically",
218                })?;
219
220        let mut canonical = Vec::new();
221        canonical.extend_from_slice(CANONICAL_SCHEME);
222        canonical.extend_from_slice(&self.schema_version.to_be_bytes());
223        write_field(&mut canonical, self.event_id.as_str().as_bytes());
224        write_field(&mut canonical, self.event_type.as_str().as_bytes());
225        write_field(&mut canonical, self.ceremony_id.as_str().as_bytes());
226        write_field(&mut canonical, self.definition_name.as_str().as_bytes());
227        write_field(&mut canonical, self.definition_version.as_str().as_bytes());
228        canonical.extend_from_slice(&self.sequence.value().to_be_bytes());
229        write_field(&mut canonical, occurred_at.as_bytes());
230        write_field(&mut canonical, self.actor.actor_id().as_bytes());
231        write_field(&mut canonical, self.actor.kind().as_str().as_bytes());
232        write_optional(
233            &mut canonical,
234            self.actor.role_id().map(|role| role.as_str().as_bytes()),
235        );
236        write_optional(
237            &mut canonical,
238            self.correlation_id
239                .as_ref()
240                .map(|id| id.as_str().as_bytes()),
241        );
242        write_optional(
243            &mut canonical,
244            self.causation_id.as_ref().map(|id| id.as_str().as_bytes()),
245        );
246        write_optional(&mut canonical, self.trace_id.as_deref().map(str::as_bytes));
247        write_optional(
248            &mut canonical,
249            self.previous_record_hash
250                .as_ref()
251                .map(|hash| hash.as_bytes().as_slice()),
252        );
253
254        let digest = Sha256::digest(&canonical);
255        Ok(AuditRecordHash::from_bytes(digest.into()))
256    }
257}
258
259/// Length-prefixed so no field value can be mistaken for a boundary
260/// between fields, whatever bytes it contains.
261fn write_field(buffer: &mut Vec<u8>, value: &[u8]) {
262    buffer.extend_from_slice(&(value.len() as u64).to_be_bytes());
263    buffer.extend_from_slice(value);
264}
265
266/// An absent field is one byte; a present one is tagged before its
267/// length, so absence and emptiness are distinguishable.
268fn write_optional(buffer: &mut Vec<u8>, value: Option<&[u8]>) {
269    match value {
270        None => buffer.push(0),
271        Some(value) => {
272            buffer.push(1);
273            write_field(buffer, value);
274        }
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::value_objects::{AuditActorKind, RoleId};
282    use serde_json::Value;
283    use time::macros::datetime;
284
285    /// One way of editing a stored record, applied to its JSON.
286    type Tampering = Box<dyn FnOnce(&mut Value)>;
287
288    fn fact(event_id: &str, event_type: AuditEventType) -> AuditFact {
289        AuditFact {
290            event_id: EventId::new(event_id).unwrap(),
291            event_type,
292            ceremony_id: CeremonyId::new("ceremony-1").unwrap(),
293            definition_name: CeremonyName::new("planning_ceremony").unwrap(),
294            definition_version: CeremonyVersion::v1(),
295            occurred_at: datetime!(2026-07-29 09:00:00 UTC),
296            actor: AuditActor::new("engineer-1", AuditActorKind::Human, None).unwrap(),
297            correlation_id: None,
298            causation_id: None,
299            trace: None,
300        }
301    }
302
303    fn chain_of_three() -> [AuditRecord; 3] {
304        let first =
305            AuditRecord::first(fact("e1", AuditEventType::CeremonyInstanceStarted)).unwrap();
306        let second =
307            AuditRecord::following(fact("e2", AuditEventType::StepStarted), &first).unwrap();
308        let third =
309            AuditRecord::following(fact("e3", AuditEventType::StepCompleted), &second).unwrap();
310        [first, second, third]
311    }
312
313    /// Round-trip through JSON so a field can be altered exactly the way
314    /// someone editing the stored record would.
315    fn tampered(record: &AuditRecord, mutate: impl FnOnce(&mut Value)) -> AuditRecord {
316        let mut json = serde_json::to_value(record).unwrap();
317        mutate(&mut json);
318        serde_json::from_value(json).unwrap()
319    }
320
321    #[test]
322    fn the_first_record_opens_the_chain() {
323        let record =
324            AuditRecord::first(fact("e1", AuditEventType::CeremonyInstanceStarted)).unwrap();
325
326        assert!(record.sequence().is_first());
327        assert!(record.previous_record_hash().is_none());
328        assert!(record.digest_is_intact().unwrap());
329        assert_eq!(record.schema_version(), AUDIT_RECORD_SCHEMA_VERSION);
330    }
331
332    #[test]
333    fn a_successor_continues_its_predecessor() {
334        let [first, second, third] = chain_of_three();
335
336        assert!(second.continues(&first));
337        assert!(third.continues(&second));
338        assert!(second.digest_is_intact().unwrap());
339    }
340
341    #[test]
342    fn sealing_the_same_fact_at_the_same_position_is_deterministic() {
343        let once = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
344        let twice = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
345
346        assert_eq!(once.record_hash(), twice.record_hash());
347    }
348
349    #[test]
350    fn altering_any_field_breaks_the_digest() {
351        let [first, ..] = chain_of_three();
352
353        let cases: Vec<(&str, Tampering)> = vec![
354            (
355                "event_type",
356                Box::new(|json: &mut Value| json["event_type"] = "step_failed".into()),
357            ),
358            (
359                "actor identity",
360                Box::new(|json: &mut Value| json["actor"]["actor_id"] = "someone-else".into()),
361            ),
362            (
363                "actor kind",
364                Box::new(|json: &mut Value| json["actor"]["kind"] = "engine".into()),
365            ),
366            (
367                "timestamp",
368                Box::new(|json: &mut Value| {
369                    json["occurred_at"] = "2026-07-29T10:00:00Z".into();
370                }),
371            ),
372            (
373                "sequence",
374                Box::new(|json: &mut Value| json["sequence"] = 7.into()),
375            ),
376            (
377                "ceremony",
378                Box::new(|json: &mut Value| json["ceremony_id"] = "ceremony-2".into()),
379            ),
380            (
381                "definition version",
382                Box::new(|json: &mut Value| json["definition_version"] = "2.0".into()),
383            ),
384        ];
385
386        for (label, mutate) in cases {
387            let altered = tampered(&first, mutate);
388
389            assert!(
390                !altered.digest_is_intact().unwrap(),
391                "altering the {label} left the digest intact"
392            );
393        }
394    }
395
396    #[test]
397    fn removing_a_record_breaks_the_chain() {
398        let [first, _removed, third] = chain_of_three();
399
400        assert!(!third.continues(&first));
401    }
402
403    #[test]
404    fn reordering_records_breaks_the_chain() {
405        let [first, second, third] = chain_of_three();
406
407        assert!(!second.continues(&third));
408        assert!(!first.continues(&second));
409    }
410
411    #[test]
412    fn an_inserted_record_cannot_be_woven_into_the_chain() {
413        let [first, second, _] = chain_of_three();
414        let forged =
415            AuditRecord::following(fact("forged", AuditEventType::StepFailed), &first).unwrap();
416
417        // The forgery is a well-formed successor of the first record...
418        assert!(forged.continues(&first));
419        // ...but the record that really followed still names the first
420        // one, so both cannot occupy the position.
421        assert!(second.continues(&first));
422        assert_eq!(forged.sequence(), second.sequence());
423        assert_ne!(forged.record_hash(), second.record_hash());
424        // And nothing that followed the genuine record follows the
425        // forgery.
426        assert!(!second.continues(&forged));
427    }
428
429    #[test]
430    fn a_record_from_another_ceremony_cannot_follow() {
431        let first = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
432        let mut foreign = fact("e2", AuditEventType::StepCompleted);
433        foreign.ceremony_id = CeremonyId::new("ceremony-2").unwrap();
434
435        assert!(matches!(
436            AuditRecord::following(foreign, &first),
437            Err(DomainError::InvariantViolated { .. })
438        ));
439    }
440
441    #[test]
442    fn field_boundaries_cannot_be_shifted_between_neighbours() {
443        // The classic attack on a concatenated digest: move a character
444        // from one field into the next and keep the same byte stream.
445        // Length prefixes are what make these two records differ.
446        let mut left = fact("e1", AuditEventType::StepStarted);
447        left.actor = AuditActor::new(
448            "ab",
449            AuditActorKind::Agent,
450            Some(RoleId::new("reviewer").unwrap()),
451        )
452        .unwrap();
453
454        let mut right = fact("e1", AuditEventType::StepStarted);
455        right.actor = AuditActor::new(
456            "a",
457            AuditActorKind::Agent,
458            Some(RoleId::new("breviewer").unwrap()),
459        )
460        .unwrap();
461
462        let left = AuditRecord::first(left).unwrap();
463        let right = AuditRecord::first(right).unwrap();
464
465        assert_ne!(left.record_hash(), right.record_hash());
466    }
467
468    #[test]
469    fn an_absent_optional_field_differs_from_a_present_one() {
470        let without = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
471        let mut with = fact("e1", AuditEventType::StepStarted);
472        with.correlation_id = Some(EventId::new("c1").unwrap());
473        let with = AuditRecord::first(with).unwrap();
474
475        assert_ne!(without.record_hash(), with.record_hash());
476    }
477}