1use serde::{Deserialize, Serialize};
34use std::time::{SystemTime, UNIX_EPOCH};
35use thiserror::Error;
36use uuid::Uuid;
37
38pub const LEGACY_ACTOR: &str = "legacy-pre-hipaa";
40
41pub const SYSTEM_ACTOR: &str = "system";
43
44pub const ANONYMOUS_ACTOR: &str = "anonymous";
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct AuditMetadata {
52 pub actor_id: String,
55
56 pub actor_session_id: Option<String>,
59
60 pub source_ip: Option<String>,
62
63 pub user_agent: Option<String>,
65
66 pub timestamp_utc_us: i64,
68
69 pub causation_id: Option<Uuid>,
72
73 pub correlation_id: Uuid,
76}
77
78#[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 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 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 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 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 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 #[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
176pub(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 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}