//! Structured audit log — persistent, machine-readable event journal. //! //! Events are serialized as JSON lines and appended to a file or SQL table. //! Each event carries a correlation `trace_id` for cross-referencing with //! RPC request traces. use std::fs::OpenOptions; use std::io::Write as IoWrite; use std::path::{Path, PathBuf}; use std::sync::Mutex; use serde::Serialize; // ── Audit event types ───────────────────────────────────────────────────── /// Action categories for the audit log. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case")] pub enum AuditAction { AuthRegister, AuthLoginSuccess, AuthLoginFailure, Enqueue, BatchEnqueue, Fetch, FetchWait, KeyUpload, HybridKeyUpload, BanUser, UnbanUser, ReportMessage, AccountDelete, DeviceRegister, DeviceRevoke, BlobUpload, RecoveryStore, RecoveryFetch, RecoveryDelete, } /// Outcome of an audited action. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case")] pub enum AuditOutcome { Success, Denied, Error, RateLimited, } /// A single audit event record. #[derive(Debug, Clone, Serialize)] pub struct AuditEvent { /// ISO-8601 timestamp. pub timestamp: String, /// RPC correlation ID. pub trace_id: String, /// Hex-encoded actor identity key (truncated for privacy when redact=true). pub actor: String, /// The action performed. pub action: AuditAction, /// Target identifier (recipient key, username, etc.). #[serde(skip_serializing_if = "Option::is_none")] pub target: Option, /// Outcome of the action. pub outcome: AuditOutcome, /// Free-form details. #[serde(skip_serializing_if = "Option::is_none")] pub details: Option, } // ── Audit logger trait ──────────────────────────────────────────────────── /// Trait for audit log backends. pub trait AuditLogger: Send + Sync { fn log(&self, event: AuditEvent); } // ── File-backed implementation ─────────────────────────────────────────── /// Appends JSON-line events to a file. pub struct FileAuditLogger { path: PathBuf, file: Mutex, } impl FileAuditLogger { /// Open (or create) the audit log file at `path`. pub fn open(path: &Path) -> Result { let file = OpenOptions::new() .create(true) .append(true) .open(path)?; Ok(Self { path: path.to_path_buf(), file: Mutex::new(file), }) } /// Return the path to the audit log file. pub fn path(&self) -> &Path { &self.path } } impl AuditLogger for FileAuditLogger { fn log(&self, event: AuditEvent) { let Ok(mut line) = serde_json::to_string(&event) else { tracing::warn!("audit: failed to serialize event"); return; }; line.push('\n'); let Ok(mut f) = self.file.lock() else { tracing::warn!("audit: log file lock poisoned"); return; }; if let Err(e) = f.write_all(line.as_bytes()) { tracing::warn!(error = %e, "audit: failed to write event"); } } } // ── No-op implementation ───────────────────────────────────────────────── /// Does nothing. Used when audit logging is disabled. pub struct NoopAuditLogger; impl AuditLogger for NoopAuditLogger { fn log(&self, _event: AuditEvent) {} } // ── Helpers ────────────────────────────────────────────────────────────── /// Format identity key bytes as hex, optionally truncated for privacy. pub fn format_actor(identity_key: &[u8], redact: bool) -> String { let full = hex::encode(identity_key); if redact && full.len() > 12 { format!("{}...", &full[..12]) } else { full } } /// Current ISO-8601 UTC timestamp. pub fn now_iso8601() -> String { // Use SystemTime to avoid pulling in chrono. let d = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default(); let secs = d.as_secs(); // Simple UTC formatting: enough for audit logs. format!("{secs}") } #[cfg(test)] mod tests { use super::*; use std::io::Read; #[test] fn file_audit_logger_writes_json_lines() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("audit.jsonl"); let logger = FileAuditLogger::open(&path).expect("open"); logger.log(AuditEvent { timestamp: "1709500000".to_string(), trace_id: "test-trace-001".to_string(), actor: "abcdef123456".to_string(), action: AuditAction::Enqueue, target: Some("recipient-hex".to_string()), outcome: AuditOutcome::Success, details: None, }); logger.log(AuditEvent { timestamp: "1709500001".to_string(), trace_id: "test-trace-002".to_string(), actor: "abcdef123456".to_string(), action: AuditAction::AuthLoginFailure, target: None, outcome: AuditOutcome::Denied, details: Some("bad password".to_string()), }); drop(logger); let mut content = String::new(); std::fs::File::open(&path) .expect("open for read") .read_to_string(&mut content) .expect("read"); let lines: Vec<&str> = content.trim().split('\n').collect(); assert_eq!(lines.len(), 2); // Verify JSON parses. let v: serde_json::Value = serde_json::from_str(lines[0]).expect("parse line 0"); assert_eq!(v["action"], "enqueue"); assert_eq!(v["outcome"], "success"); assert_eq!(v["trace_id"], "test-trace-001"); let v: serde_json::Value = serde_json::from_str(lines[1]).expect("parse line 1"); assert_eq!(v["action"], "auth_login_failure"); assert_eq!(v["details"], "bad password"); } #[test] fn format_actor_truncates_when_redacted() { let key = vec![0xAA; 32]; let full = format_actor(&key, false); assert_eq!(full.len(), 64); let redacted = format_actor(&key, true); assert!(redacted.ends_with("...")); assert_eq!(redacted.len(), 15); // 12 hex chars + "..." } #[test] fn noop_logger_does_not_panic() { let logger = NoopAuditLogger; logger.log(AuditEvent { timestamp: "0".to_string(), trace_id: "noop".to_string(), actor: "none".to_string(), action: AuditAction::Fetch, target: None, outcome: AuditOutcome::Success, details: None, }); } }