use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditOutcome {
Success,
Denied,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub timestamp: DateTime<Utc>,
pub request_id: String,
pub actor: String,
pub action: String,
pub resource: String,
pub outcome: AuditOutcome,
#[serde(skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
}
pub trait AuditLogger: Send + Sync {
fn log_event(&self, event: &AuditEvent);
}
pub struct JsonAuditLogger;
impl AuditLogger for JsonAuditLogger {
fn log_event(&self, event: &AuditEvent) {
match serde_json::to_string(event) {
Ok(json) => {
tracing::info!(
audit_event = %json,
actor = %event.actor,
action = %event.action,
outcome = ?event.outcome,
"audit"
);
}
Err(e) => {
tracing::warn!(
error = %e,
actor = %event.actor,
action = %event.action,
"Failed to serialize audit event"
);
}
}
}
}
pub struct NoopAuditLogger;
impl AuditLogger for NoopAuditLogger {
fn log_event(&self, _event: &AuditEvent) {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_true")]
pub log_to_stdout: bool,
}
fn default_true() -> bool {
true
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
enabled: false,
log_to_stdout: true,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_json_audit_logger_does_not_panic() {
let logger = JsonAuditLogger;
let event = AuditEvent {
timestamp: Utc::now(),
request_id: "req-001".to_string(),
actor: "user@example.com".to_string(),
action: "generate_data".to_string(),
resource: "/api/stream/start".to_string(),
outcome: AuditOutcome::Success,
tenant_id: Some("tenant-1".to_string()),
ip_address: Some("192.168.1.1".to_string()),
user_agent: Some("datasynth-cli/0.5.0".to_string()),
};
logger.log_event(&event);
}
#[test]
fn test_noop_audit_logger() {
let logger = NoopAuditLogger;
let event = AuditEvent {
timestamp: Utc::now(),
request_id: "req-002".to_string(),
actor: "anonymous".to_string(),
action: "view_metrics".to_string(),
resource: "/metrics".to_string(),
outcome: AuditOutcome::Denied,
tenant_id: None,
ip_address: None,
user_agent: None,
};
logger.log_event(&event);
}
#[test]
fn test_audit_event_serialization_roundtrip() {
let event = AuditEvent {
timestamp: Utc::now(),
request_id: "req-003".to_string(),
actor: "admin-key-ab12".to_string(),
action: "manage_config".to_string(),
resource: "/api/config".to_string(),
outcome: AuditOutcome::Error,
tenant_id: None,
ip_address: Some("10.0.0.1".to_string()),
user_agent: None,
};
let json = serde_json::to_string(&event).expect("should serialize");
let deserialized: AuditEvent = serde_json::from_str(&json).expect("should deserialize");
assert_eq!(deserialized.request_id, "req-003");
assert_eq!(deserialized.actor, "admin-key-ab12");
assert_eq!(deserialized.action, "manage_config");
assert_eq!(deserialized.outcome, AuditOutcome::Error);
assert!(deserialized.tenant_id.is_none());
assert_eq!(deserialized.ip_address, Some("10.0.0.1".to_string()));
assert!(deserialized.user_agent.is_none());
}
#[test]
fn test_audit_config_defaults() {
let config = AuditConfig::default();
assert!(!config.enabled);
assert!(config.log_to_stdout);
}
#[test]
fn test_audit_outcome_serialization() {
assert_eq!(
serde_json::to_string(&AuditOutcome::Success).unwrap(),
"\"success\""
);
assert_eq!(
serde_json::to_string(&AuditOutcome::Denied).unwrap(),
"\"denied\""
);
assert_eq!(
serde_json::to_string(&AuditOutcome::Error).unwrap(),
"\"error\""
);
}
}