pylon_plugin/builtin/
audit_log.rs1use std::sync::Mutex;
2
3use crate::Plugin;
4use pylon_auth::AuthContext;
5use serde_json::Value;
6
7#[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
18pub 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 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"); 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}