1use 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
19const CANONICAL_SCHEME: &[u8] = b"underpass.made.audit-record.v1";
23
24pub const AUDIT_RECORD_SCHEMA_VERSION: u32 = 1;
28
29#[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#[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 pub fn first(fact: AuditFact) -> Result<Self, DomainError> {
79 Self::seal(fact, AuditSequence::FIRST, None)
80 }
81
82 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 pub fn digest_is_intact(&self) -> Result<bool, DomainError> {
197 Ok(self.compute_hash()? == self.record_hash)
198 }
199
200 #[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
259fn 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
266fn 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 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 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 assert!(forged.continues(&first));
419 assert!(second.continues(&first));
422 assert_eq!(forged.sequence(), second.sequence());
423 assert_ne!(forged.record_hash(), second.record_hash());
424 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 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}