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 via `registry.add(audit_log)` at startup and extract in
14/// handlers as `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, device_name, device_type, fingerprint) =
85                match entry.client_info_value() {
86                    Some(ci) => (
87                        ci.ip_value().map(String::from),
88                        ci.user_agent_value().map(String::from),
89                        ci.device_name_value().map(String::from),
90                        ci.device_type_value().map(String::from),
91                        ci.fingerprint_value().map(String::from),
92                    ),
93                    None => (None, None, None, None, None),
94                };
95
96            self.db
97                .conn()
98                .execute_raw(
99                    "INSERT INTO audit_log \
100                     (id, actor, action, resource_type, resource_id, metadata, \
101                     ip, user_agent, device_name, device_type, fingerprint, tenant_id) \
102                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
103                    libsql::params![
104                        id,
105                        entry.actor(),
106                        entry.action(),
107                        entry.resource_type(),
108                        entry.resource_id(),
109                        metadata_json,
110                        ip,
111                        user_agent,
112                        device_name,
113                        device_type,
114                        fingerprint,
115                        entry.tenant_id_value(),
116                    ],
117                )
118                .await
119                .map_err(crate::error::Error::from)?;
120
121            Ok(())
122        })
123    }
124}
125
126/// In-memory audit backend for testing.
127#[cfg(any(test, feature = "test-helpers"))]
128pub struct MemoryAuditBackend {
129    entries: std::sync::Mutex<Vec<AuditEntry>>,
130}
131
132#[cfg(any(test, feature = "test-helpers"))]
133impl MemoryAuditBackend {
134    /// Return a clone of all captured entries.
135    pub fn entries(&self) -> Vec<AuditEntry> {
136        self.entries.lock().unwrap().clone()
137    }
138}
139
140#[cfg(any(test, feature = "test-helpers"))]
141impl AuditLogBackend for MemoryAuditBackend {
142    fn record<'a>(
143        &'a self,
144        entry: &'a AuditEntry,
145    ) -> std::pin::Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
146        self.entries.lock().unwrap().push(entry.clone());
147        Box::pin(async { Ok(()) })
148    }
149}