Skip to main content

arc_core/
audit.rs

1//! # Audit Module
2//!
3//! HIPAA §164.312(b) audit metadata for every persisted event.
4//!
5//! Every event in the store carries an `AuditMetadata` value documenting *who*
6//! caused the change, *when* it happened, *from where*, and *why* (causation).
7//! The `EventStore::append` implementation rejects events whose audit fails
8//! [`AuditMetadata::validate`].
9//!
10//! ## Lifecycle
11//!
12//! 1. An aggregate's `handle()` returns `Vec<Event>` with `Event::audit ==
13//!    AuditMetadata::pending()`. Aggregates do not deal with audit data.
14//! 2. The `CommandBus` builds one [`AuditMetadata`] from the request-scoped
15//!    `CommandContext` (with `timestamp_utc_us = now`) and stamps it on every
16//!    event before calling `append`.
17//! 3. Every store implementation calls `audit.validate()?` at the top of its
18//!    `append` method as a defense-in-depth assertion.
19//!
20//! ## Why typed, not free-form JSON
21//!
22//! Field names cannot drift; required fields cannot be silently null; an
23//! auditor's `SELECT WHERE actor_id = ?` query is reliable across every event
24//! ever written.
25//!
26//! ## Reserved actor identifiers
27//!
28//! - `"system"`  — internal jobs, seeders, migrations
29//! - `"anonymous"` — unauthenticated requests (e.g. self-registration)
30//! - `"legacy-pre-hipaa"` — backfill sentinel for events written before this
31//!   module existed (see migration `add_hipaa_audit`)
32
33use serde::{Deserialize, Serialize};
34use std::time::{SystemTime, UNIX_EPOCH};
35use thiserror::Error;
36use uuid::Uuid;
37
38/// Sentinel `actor_id` for events written before HIPAA-1 landed.
39pub const LEGACY_ACTOR: &str = "legacy-pre-hipaa";
40
41/// Sentinel `actor_id` for system-internal commands (seeders, cron, migrations).
42pub const SYSTEM_ACTOR: &str = "system";
43
44/// Sentinel `actor_id` for unauthenticated requests.
45pub const ANONYMOUS_ACTOR: &str = "anonymous";
46
47/// Audit fields stamped on every persisted event.
48///
49/// See module docs for the lifecycle and reserved actor identifiers.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct AuditMetadata {
52    /// Required. Aggregate UUID, `"system"`, `"anonymous"`, or `"legacy-pre-hipaa"`.
53    /// Must be non-empty after trim.
54    pub actor_id: String,
55
56    /// Optional. The session this command was dispatched in. Pair with HIPAA-4
57    /// server-side session store for revocation.
58    pub actor_session_id: Option<String>,
59
60    /// Optional. Source IP. `None` for system-internal commands.
61    pub source_ip: Option<String>,
62
63    /// Optional. `User-Agent` header.
64    pub user_agent: Option<String>,
65
66    /// Required. Microseconds since UNIX epoch. Must be `> 0`.
67    pub timestamp_utc_us: i64,
68
69    /// Optional. Event ID that triggered the command that produced this event
70    /// (saga / projection-driven follow-up).
71    pub causation_id: Option<Uuid>,
72
73    /// Required. Groups every event from one logical request together.
74    /// `CommandContext::system` synthesizes one for internal jobs.
75    pub correlation_id: Uuid,
76}
77
78/// Validation errors for [`AuditMetadata`].
79#[derive(Debug, Error, PartialEq)]
80pub enum AuditError {
81    #[error("audit.actor_id is empty (set 'system'/'anonymous' explicitly if intended)")]
82    EmptyActorId,
83
84    #[error("audit.timestamp_utc_us must be > 0 (was {0})")]
85    InvalidTimestamp(i64),
86
87    #[error("audit metadata is still in pending placeholder state — CommandBus did not stamp it before append")]
88    PendingNotStamped,
89}
90
91impl AuditMetadata {
92    /// Construct a new validated [`AuditMetadata`] from raw inputs.
93    ///
94    /// `timestamp_utc_us` is set from the system clock. Use
95    /// [`AuditMetadata::with_timestamp`] to supply an explicit value (testing,
96    /// replay, deterministic builds).
97    pub fn new(actor_id: impl Into<String>, correlation_id: Uuid) -> Result<Self, AuditError> {
98        let s = Self {
99            actor_id: actor_id.into(),
100            actor_session_id: None,
101            source_ip: None,
102            user_agent: None,
103            timestamp_utc_us: now_us(),
104            causation_id: None,
105            correlation_id,
106        };
107        s.validate()?;
108        Ok(s)
109    }
110
111    /// Convenience constructor for system-internal jobs (cron, seeders, migrations).
112    /// Synthesizes a `correlation_id`. Always passes validation.
113    pub fn system() -> Self {
114        Self {
115            actor_id: SYSTEM_ACTOR.to_string(),
116            actor_session_id: None,
117            source_ip: None,
118            user_agent: None,
119            timestamp_utc_us: now_us(),
120            causation_id: None,
121            correlation_id: Uuid::new_v4(),
122        }
123    }
124
125    /// Placeholder value used by `Event::new`. The `CommandBus` overwrites this
126    /// with a real value from the request `CommandContext` before `append`.
127    /// Calling `validate()` on a pending value returns
128    /// [`AuditError::PendingNotStamped`] — the store rejects the write.
129    pub fn pending() -> Self {
130        Self {
131            actor_id: String::new(),
132            actor_session_id: None,
133            source_ip: None,
134            user_agent: None,
135            timestamp_utc_us: 0,
136            causation_id: None,
137            correlation_id: Uuid::nil(),
138        }
139    }
140
141    /// True when `audit == pending()`. Stores reject pending audits at append-time.
142    pub fn is_pending(&self) -> bool {
143        self.actor_id.is_empty() && self.timestamp_utc_us == 0 && self.correlation_id.is_nil()
144    }
145
146    /// Defense-in-depth validation. Stores call this at the top of `append`.
147    pub fn validate(&self) -> Result<(), AuditError> {
148        if self.is_pending() {
149            return Err(AuditError::PendingNotStamped);
150        }
151        if self.actor_id.trim().is_empty() {
152            return Err(AuditError::EmptyActorId);
153        }
154        if self.timestamp_utc_us <= 0 {
155            return Err(AuditError::InvalidTimestamp(self.timestamp_utc_us));
156        }
157        Ok(())
158    }
159
160    /// Convenience: produce a cheap, valid `AuditMetadata` for tests.
161    /// Sets `actor_id = "test"`, fresh `correlation_id`, current timestamp.
162    #[cfg(any(test, feature = "test-utils"))]
163    pub fn test_default() -> Self {
164        Self {
165            actor_id: "test".to_string(),
166            actor_session_id: None,
167            source_ip: None,
168            user_agent: None,
169            timestamp_utc_us: now_us(),
170            causation_id: None,
171            correlation_id: Uuid::new_v4(),
172        }
173    }
174}
175
176/// Microseconds since UNIX epoch.
177pub(crate) fn now_us() -> i64 {
178    SystemTime::now()
179        .duration_since(UNIX_EPOCH)
180        .map(|d| d.as_micros() as i64)
181        .unwrap_or(0)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn test_pending_fails_validation() {
190        assert_eq!(
191            AuditMetadata::pending().validate(),
192            Err(AuditError::PendingNotStamped)
193        );
194    }
195
196    #[test]
197    fn test_empty_actor_rejected() {
198        let mut m = AuditMetadata::system();
199        m.actor_id = "   ".to_string();
200        assert_eq!(m.validate(), Err(AuditError::EmptyActorId));
201    }
202
203    #[test]
204    fn test_zero_timestamp_rejected() {
205        let mut m = AuditMetadata::system();
206        m.timestamp_utc_us = 0;
207        // is_pending() guards on all-three-zero; here only ts is 0
208        m.correlation_id = Uuid::new_v4();
209        assert!(matches!(m.validate(), Err(AuditError::InvalidTimestamp(0))));
210    }
211
212    #[test]
213    fn test_negative_timestamp_rejected() {
214        let mut m = AuditMetadata::system();
215        m.timestamp_utc_us = -1;
216        assert!(matches!(
217            m.validate(),
218            Err(AuditError::InvalidTimestamp(-1))
219        ));
220    }
221
222    #[test]
223    fn test_system_passes() {
224        AuditMetadata::system()
225            .validate()
226            .expect("system audit must validate");
227    }
228
229    #[test]
230    fn test_test_default_passes() {
231        AuditMetadata::test_default()
232            .validate()
233            .expect("test_default must validate");
234    }
235
236    #[test]
237    fn test_legacy_actor_passes() {
238        let m = AuditMetadata::new(LEGACY_ACTOR, Uuid::new_v4()).expect("legacy must construct");
239        m.validate().expect("legacy must validate");
240        assert_eq!(m.actor_id, "legacy-pre-hipaa");
241    }
242
243    #[test]
244    fn test_new_rejects_empty() {
245        assert_eq!(
246            AuditMetadata::new("", Uuid::new_v4()),
247            Err(AuditError::EmptyActorId)
248        );
249    }
250
251    #[test]
252    fn test_is_pending_recognizes_placeholder() {
253        assert!(AuditMetadata::pending().is_pending());
254        assert!(!AuditMetadata::system().is_pending());
255        assert!(!AuditMetadata::test_default().is_pending());
256    }
257
258    #[test]
259    fn test_serde_roundtrip() {
260        let m = AuditMetadata::system();
261        let s = serde_json::to_string(&m).unwrap();
262        let back: AuditMetadata = serde_json::from_str(&s).unwrap();
263        assert_eq!(m, back);
264    }
265}