Skip to main content

pylon_plugin/builtin/
audit_log.rs

1use std::sync::Mutex;
2
3use crate::Plugin;
4use pylon_auth::AuthContext;
5use serde_json::Value;
6
7/// An audit log entry.
8#[derive(Debug, Clone)]
9pub struct AuditEntry {
10    pub timestamp: String,
11    pub user_id: Option<String>,
12    pub action: String,
13    pub entity: String,
14    pub row_id: String,
15    pub data: Option<Value>,
16}
17
18/// Audit log plugin. Records all mutations for compliance/debugging.
19pub struct AuditLogPlugin {
20    entries: Mutex<Vec<AuditEntry>>,
21    max_entries: usize,
22}
23
24impl AuditLogPlugin {
25    pub fn new(max_entries: usize) -> Self {
26        Self {
27            entries: Mutex::new(Vec::new()),
28            max_entries,
29        }
30    }
31
32    pub fn entries(&self) -> Vec<AuditEntry> {
33        self.entries.lock().unwrap().clone()
34    }
35
36    pub fn len(&self) -> usize {
37        self.entries.lock().unwrap().len()
38    }
39
40    fn record(
41        &self,
42        action: &str,
43        entity: &str,
44        row_id: &str,
45        data: Option<&Value>,
46        auth: &AuthContext,
47    ) {
48        let entry = AuditEntry {
49            timestamp: now(),
50            user_id: auth.user_id.clone(),
51            action: action.to_string(),
52            entity: entity.to_string(),
53            row_id: row_id.to_string(),
54            data: data.cloned(),
55        };
56
57        let mut entries = self.entries.lock().unwrap();
58        entries.push(entry);
59
60        // Trim if over max.
61        if entries.len() > self.max_entries {
62            let excess = entries.len() - self.max_entries;
63            entries.drain(0..excess);
64        }
65    }
66}
67
68impl Plugin for AuditLogPlugin {
69    fn name(&self) -> &str {
70        "audit-log"
71    }
72
73    fn after_insert(&self, entity: &str, id: &str, data: &Value, auth: &AuthContext) {
74        self.record("insert", entity, id, Some(data), auth);
75    }
76
77    fn after_update(&self, entity: &str, id: &str, data: &Value, auth: &AuthContext) {
78        self.record("update", entity, id, Some(data), auth);
79    }
80
81    fn after_delete(&self, entity: &str, id: &str, auth: &AuthContext) {
82        self.record("delete", entity, id, None, auth);
83    }
84}
85
86fn now() -> String {
87    use std::time::{SystemTime, UNIX_EPOCH};
88    let ts = SystemTime::now()
89        .duration_since(UNIX_EPOCH)
90        .unwrap_or_default()
91        .as_secs();
92    format!("{ts}Z")
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn records_insert() {
101        let plugin = AuditLogPlugin::new(100);
102        let auth = AuthContext::authenticated("user-1".into());
103        let data = serde_json::json!({"title": "Test"});
104        plugin.after_insert("Todo", "t1", &data, &auth);
105
106        let entries = plugin.entries();
107        assert_eq!(entries.len(), 1);
108        assert_eq!(entries[0].action, "insert");
109        assert_eq!(entries[0].entity, "Todo");
110        assert_eq!(entries[0].row_id, "t1");
111        assert_eq!(entries[0].user_id, Some("user-1".into()));
112    }
113
114    #[test]
115    fn records_update_and_delete() {
116        let plugin = AuditLogPlugin::new(100);
117        let auth = AuthContext::authenticated("user-1".into());
118        plugin.after_update("Todo", "t1", &serde_json::json!({"done": true}), &auth);
119        plugin.after_delete("Todo", "t1", &auth);
120
121        assert_eq!(plugin.len(), 2);
122        let entries = plugin.entries();
123        assert_eq!(entries[0].action, "update");
124        assert_eq!(entries[1].action, "delete");
125        assert!(entries[1].data.is_none());
126    }
127
128    #[test]
129    fn trims_over_max() {
130        let plugin = AuditLogPlugin::new(2);
131        let auth = AuthContext::anonymous();
132        let data = serde_json::json!({});
133        plugin.after_insert("A", "1", &data, &auth);
134        plugin.after_insert("A", "2", &data, &auth);
135        plugin.after_insert("A", "3", &data, &auth);
136
137        assert_eq!(plugin.len(), 2);
138        let entries = plugin.entries();
139        assert_eq!(entries[0].row_id, "2"); // oldest trimmed
140        assert_eq!(entries[1].row_id, "3");
141    }
142
143    #[test]
144    fn anonymous_audit() {
145        let plugin = AuditLogPlugin::new(100);
146        let auth = AuthContext::anonymous();
147        plugin.after_insert("Todo", "t1", &serde_json::json!({}), &auth);
148        assert_eq!(plugin.entries()[0].user_id, None);
149    }
150}