use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
use uuid::Uuid;
pub const LEGACY_ACTOR: &str = "legacy-pre-hipaa";
pub const SYSTEM_ACTOR: &str = "system";
pub const ANONYMOUS_ACTOR: &str = "anonymous";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AuditMetadata {
pub actor_id: String,
pub actor_session_id: Option<String>,
pub source_ip: Option<String>,
pub user_agent: Option<String>,
pub timestamp_utc_us: i64,
pub causation_id: Option<Uuid>,
pub correlation_id: Uuid,
}
#[derive(Debug, Error, PartialEq)]
pub enum AuditError {
#[error("audit.actor_id is empty (set 'system'/'anonymous' explicitly if intended)")]
EmptyActorId,
#[error("audit.timestamp_utc_us must be > 0 (was {0})")]
InvalidTimestamp(i64),
#[error("audit metadata is still in pending placeholder state — CommandBus did not stamp it before append")]
PendingNotStamped,
}
impl AuditMetadata {
pub fn new(actor_id: impl Into<String>, correlation_id: Uuid) -> Result<Self, AuditError> {
let s = Self {
actor_id: actor_id.into(),
actor_session_id: None,
source_ip: None,
user_agent: None,
timestamp_utc_us: now_us(),
causation_id: None,
correlation_id,
};
s.validate()?;
Ok(s)
}
pub fn system() -> Self {
Self {
actor_id: SYSTEM_ACTOR.to_string(),
actor_session_id: None,
source_ip: None,
user_agent: None,
timestamp_utc_us: now_us(),
causation_id: None,
correlation_id: Uuid::new_v4(),
}
}
pub fn pending() -> Self {
Self {
actor_id: String::new(),
actor_session_id: None,
source_ip: None,
user_agent: None,
timestamp_utc_us: 0,
causation_id: None,
correlation_id: Uuid::nil(),
}
}
pub fn is_pending(&self) -> bool {
self.actor_id.is_empty() && self.timestamp_utc_us == 0 && self.correlation_id.is_nil()
}
pub fn validate(&self) -> Result<(), AuditError> {
if self.is_pending() {
return Err(AuditError::PendingNotStamped);
}
if self.actor_id.trim().is_empty() {
return Err(AuditError::EmptyActorId);
}
if self.timestamp_utc_us <= 0 {
return Err(AuditError::InvalidTimestamp(self.timestamp_utc_us));
}
Ok(())
}
#[cfg(any(test, feature = "test-utils"))]
pub fn test_default() -> Self {
Self {
actor_id: "test".to_string(),
actor_session_id: None,
source_ip: None,
user_agent: None,
timestamp_utc_us: now_us(),
causation_id: None,
correlation_id: Uuid::new_v4(),
}
}
}
pub(crate) fn now_us() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pending_fails_validation() {
assert_eq!(
AuditMetadata::pending().validate(),
Err(AuditError::PendingNotStamped)
);
}
#[test]
fn test_empty_actor_rejected() {
let mut m = AuditMetadata::system();
m.actor_id = " ".to_string();
assert_eq!(m.validate(), Err(AuditError::EmptyActorId));
}
#[test]
fn test_zero_timestamp_rejected() {
let mut m = AuditMetadata::system();
m.timestamp_utc_us = 0;
m.correlation_id = Uuid::new_v4();
assert!(matches!(m.validate(), Err(AuditError::InvalidTimestamp(0))));
}
#[test]
fn test_negative_timestamp_rejected() {
let mut m = AuditMetadata::system();
m.timestamp_utc_us = -1;
assert!(matches!(
m.validate(),
Err(AuditError::InvalidTimestamp(-1))
));
}
#[test]
fn test_system_passes() {
AuditMetadata::system()
.validate()
.expect("system audit must validate");
}
#[test]
fn test_test_default_passes() {
AuditMetadata::test_default()
.validate()
.expect("test_default must validate");
}
#[test]
fn test_legacy_actor_passes() {
let m = AuditMetadata::new(LEGACY_ACTOR, Uuid::new_v4()).expect("legacy must construct");
m.validate().expect("legacy must validate");
assert_eq!(m.actor_id, "legacy-pre-hipaa");
}
#[test]
fn test_new_rejects_empty() {
assert_eq!(
AuditMetadata::new("", Uuid::new_v4()),
Err(AuditError::EmptyActorId)
);
}
#[test]
fn test_is_pending_recognizes_placeholder() {
assert!(AuditMetadata::pending().is_pending());
assert!(!AuditMetadata::system().is_pending());
assert!(!AuditMetadata::test_default().is_pending());
}
#[test]
fn test_serde_roundtrip() {
let m = AuditMetadata::system();
let s = serde_json::to_string(&m).unwrap();
let back: AuditMetadata = serde_json::from_str(&s).unwrap();
assert_eq!(m, back);
}
}