Skip to main content

modo/audit/
log.rs

1use std::sync::Arc;
2
3use crate::db::{ConnExt, Database};
4use crate::error::Result;
5use crate::id;
6
7use super::backend::AuditLogBackend;
8use super::entry::AuditEntry;
9
10/// Concrete audit log service.
11///
12/// Wraps an [`AuditLogBackend`] behind `Arc` for cheap cloning.
13/// Register with `.with_service(audit_log)` and extract as
14/// `Service(audit): Service<AuditLog>`.
15///
16/// Two write methods:
17/// - [`record()`](Self::record) — propagates errors via `Result`
18/// - [`record_silent()`](Self::record_silent) — traces errors, never fails
19#[derive(Clone)]
20pub struct AuditLog(Arc<dyn AuditLogBackend>);
21
22impl AuditLog {
23    /// Create with the built-in SQLite backend writing to the `audit_log` table.
24    pub fn new(db: Database) -> Self {
25        Self(Arc::new(SqliteAuditBackend { db }))
26    }
27
28    /// Create with a custom backend.
29    pub fn from_backend(backend: Arc<dyn AuditLogBackend>) -> Self {
30        Self(backend)
31    }
32
33    /// Record an audit event, propagating errors via `Result`.
34    ///
35    /// # Errors
36    ///
37    /// Returns an error if the backend write fails (e.g. database
38    /// connection lost, constraint violation).
39    pub async fn record(&self, entry: &AuditEntry) -> Result<()> {
40        self.0.record(entry).await
41    }
42
43    /// Record an audit event. Traces errors, never fails.
44    pub async fn record_silent(&self, entry: &AuditEntry) {
45        if let Err(e) = self.0.record(entry).await {
46            tracing::error!(
47                error = %e,
48                action = %entry.action(),
49                actor = %entry.actor(),
50                "audit log write failed"
51            );
52        }
53    }
54
55    /// Create an in-memory audit log for testing.
56    ///
57    /// Returns the `AuditLog` and a handle to the backend for inspecting
58    /// captured entries.
59    #[cfg(any(test, feature = "test-helpers"))]
60    pub fn memory() -> (Self, Arc<MemoryAuditBackend>) {
61        let backend = Arc::new(MemoryAuditBackend {
62            entries: std::sync::Mutex::new(Vec::new()),
63        });
64        (Self(backend.clone()), backend)
65    }
66}
67
68struct SqliteAuditBackend {
69    db: Database,
70}
71
72impl AuditLogBackend for SqliteAuditBackend {
73    fn record<'a>(
74        &'a self,
75        entry: &'a AuditEntry,
76    ) -> std::pin::Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
77        Box::pin(async move {
78            let id = id::ulid();
79            let metadata_json = entry
80                .metadata_value()
81                .map(|v| v.to_string())
82                .unwrap_or_else(|| "{}".to_string());
83
84            let (ip, user_agent, fingerprint) = match entry.client_info_value() {
85                Some(ci) => (
86                    ci.ip_value().map(String::from),
87                    ci.user_agent_value().map(String::from),
88                    ci.fingerprint_value().map(String::from),
89                ),
90                None => (None, None, None),
91            };
92
93            self.db
94                .conn()
95                .execute_raw(
96                    "INSERT INTO audit_log \
97                     (id, actor, action, resource_type, resource_id, metadata, ip, user_agent, fingerprint, tenant_id) \
98                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
99                    libsql::params![
100                        id,
101                        entry.actor(),
102                        entry.action(),
103                        entry.resource_type(),
104                        entry.resource_id(),
105                        metadata_json,
106                        ip,
107                        user_agent,
108                        fingerprint,
109                        entry.tenant_id_value(),
110                    ],
111                )
112                .await
113                .map_err(crate::error::Error::from)?;
114
115            Ok(())
116        })
117    }
118}
119
120/// In-memory audit backend for testing.
121#[cfg(any(test, feature = "test-helpers"))]
122pub struct MemoryAuditBackend {
123    entries: std::sync::Mutex<Vec<AuditEntry>>,
124}
125
126#[cfg(any(test, feature = "test-helpers"))]
127impl MemoryAuditBackend {
128    /// Return a clone of all captured entries.
129    pub fn entries(&self) -> Vec<AuditEntry> {
130        self.entries.lock().unwrap().clone()
131    }
132}
133
134#[cfg(any(test, feature = "test-helpers"))]
135impl AuditLogBackend for MemoryAuditBackend {
136    fn record<'a>(
137        &'a self,
138        entry: &'a AuditEntry,
139    ) -> std::pin::Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
140        self.entries.lock().unwrap().push(entry.clone());
141        Box::pin(async { Ok(()) })
142    }
143}