1use crate::error::CoreError;
14use crate::request::ReqCtx;
15use crate::response::ApiResponse;
16use crate::storage::StorageError;
17use async_trait::async_trait;
18use once_cell::sync::OnceCell;
19use serde_json::{json, Map, Value};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Event {
24 Create,
25 Update,
26 Delete,
27}
28
29impl Event {
30 pub fn as_str(&self) -> &'static str {
32 match self {
33 Event::Create => "create",
34 Event::Update => "update",
35 Event::Delete => "delete",
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
46pub struct AuditEntry {
47 pub item_type: String,
49 pub item_id: String,
51 pub event: Event,
52 pub whodunnit: Option<String>,
54 pub whodunnit_email: Option<String>,
57 pub changes: Map<String, Value>,
58}
59
60impl AuditEntry {
61 pub fn new(
63 ctx: &ReqCtx,
64 item_type: impl Into<String>,
65 item_id: impl Into<String>,
66 event: Event,
67 changes: Map<String, Value>,
68 ) -> Self {
69 let (whodunnit, whodunnit_email) = match &ctx.claims {
70 Some(c) => (Some(c.sub.clone()), Some(c.email.clone())),
71 None => (None, None),
72 };
73 Self {
74 item_type: item_type.into(),
75 item_id: item_id.into(),
76 event,
77 whodunnit,
78 whodunnit_email,
79 changes,
80 }
81 }
82}
83
84#[async_trait]
88pub trait Auditor: Send + Sync {
89 async fn record(&self, entry: AuditEntry) -> Result<(), StorageError>;
90
91 async fn history(
99 &self,
100 _item_type: &str,
101 _item_id: &str,
102 _limit: u64,
103 ) -> Result<Vec<Value>, StorageError> {
104 Ok(Vec::new())
105 }
106
107 fn strict(&self) -> bool {
114 false
115 }
116}
117
118static AUDITOR: OnceCell<Box<dyn Auditor>> = OnceCell::new();
119
120pub fn set_auditor(auditor: Box<dyn Auditor>) {
123 if AUDITOR.set(auditor).is_err() {
124 tracing::warn!("adminx auditor already initialized; ignoring reset");
125 }
126}
127
128pub fn auditor() -> Option<&'static dyn Auditor> {
130 AUDITOR.get().map(|b| b.as_ref())
131}
132
133pub fn is_enabled() -> bool {
137 AUDITOR.get().is_some()
138}
139
140pub async fn emit(entry: AuditEntry) -> Option<ApiResponse> {
153 let auditor = auditor()?;
154 match auditor.record(entry).await {
155 Ok(()) => None,
156 Err(e) => {
157 tracing::error!("adminx: failed to record audit entry: {e}");
158 if auditor.strict() {
159 Some(
160 CoreError::Internal(format!(
161 "the change was applied but could not be recorded to the audit log: {e}"
162 ))
163 .into(),
164 )
165 } else {
166 None
167 }
168 }
169 }
170}
171
172pub const HISTORY_LIMIT: u64 = 100;
175
176pub async fn history(item_type: &str, item_id: &str) -> Vec<Value> {
183 let Some(auditor) = auditor() else {
184 return Vec::new();
185 };
186 let rows = match auditor.history(item_type, item_id, HISTORY_LIMIT).await {
187 Ok(rows) => rows,
188 Err(e) => {
189 tracing::error!("adminx: failed to read audit history: {e}");
190 return Vec::new();
191 }
192 };
193 rows.iter().map(present).collect()
194}
195
196fn present(row: &Value) -> Value {
198 let changes = match row.get("changes") {
201 Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(Value::Null),
202 Some(other) => other.clone(),
203 None => Value::Null,
204 };
205
206 let mut fields = Vec::new();
207 if let Value::Object(map) = &changes {
208 for (name, pair) in map {
209 let (old, new) = match pair {
210 Value::Array(a) if a.len() == 2 => (display(&a[0]), display(&a[1])),
211 other => (String::new(), display(other)),
212 };
213 fields.push(json!({ "name": name, "old": old, "new": new }));
214 }
215 }
216
217 json!({
218 "id": row.get("id").cloned().unwrap_or(Value::Null),
219 "event": row.get("event").and_then(|v| v.as_str()).unwrap_or(""),
220 "whodunnit_email": row
221 .get("whodunnit_email")
222 .and_then(|v| v.as_str())
223 .unwrap_or("—"),
224 "created_at": row.get("created_at").and_then(|v| v.as_str()).unwrap_or(""),
225 "fields": fields,
226 })
227}
228
229fn display(v: &Value) -> String {
233 match v {
234 Value::Null => "—".to_string(),
235 Value::String(s) if s.is_empty() => "(empty)".to_string(),
236 Value::String(s) => s.clone(),
237 other => other.to_string(),
238 }
239}
240
241const NOISE: [&str; 2] = ["updated_at", "created_at"];
244
245pub fn diff(before: Option<&Value>, after: &Map<String, Value>) -> Map<String, Value> {
250 let mut out = Map::new();
251 for (key, new) in after {
252 if NOISE.contains(&key.as_str()) {
253 continue;
254 }
255 let old = before
256 .and_then(|b| b.get(key))
257 .cloned()
258 .unwrap_or(Value::Null);
259 if !same(&old, new) {
260 out.insert(key.clone(), json!([old, new]));
261 }
262 }
263 out
264}
265
266pub fn diff_removed(before: &Value) -> Map<String, Value> {
269 let mut out = Map::new();
270 if let Value::Object(map) = before {
271 for (key, old) in map {
272 out.insert(key.clone(), json!([old, Value::Null]));
273 }
274 }
275 out
276}
277
278fn same(a: &Value, b: &Value) -> bool {
285 if a == b {
286 return true;
287 }
288 match (scalar_text(a), scalar_text(b)) {
289 (Some(x), Some(y)) => x == y,
290 _ => false,
292 }
293}
294
295fn scalar_text(v: &Value) -> Option<String> {
296 match v {
297 Value::String(s) => Some(s.clone()),
298 Value::Number(n) => Some(n.to_string()),
299 Value::Bool(b) => Some(b.to_string()),
300 _ => None,
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 fn map(v: Value) -> Map<String, Value> {
309 match v {
310 Value::Object(m) => m,
311 _ => panic!("expected object"),
312 }
313 }
314
315 #[test]
316 fn create_diff_has_null_on_the_left() {
317 let changes = diff(None, &map(json!({"title": "Hello"})));
318 assert_eq!(changes["title"], json!([Value::Null, "Hello"]));
319 }
320
321 #[test]
322 fn only_changed_columns_are_recorded() {
323 let before = json!({"title": "Old", "body": "Same"});
324 let changes = diff(Some(&before), &map(json!({"title": "New", "body": "Same"})));
325 assert_eq!(changes.len(), 1);
326 assert_eq!(changes["title"], json!(["Old", "New"]));
327 }
328
329 #[test]
330 fn form_strings_do_not_read_as_changes_against_typed_columns() {
331 let before = json!({"views": 5, "published": true});
333 let changes = diff(Some(&before), &map(json!({"views": "5", "published": "true"})));
334 assert!(changes.is_empty(), "expected no changes, got {changes:?}");
335 }
336
337 #[test]
338 fn a_real_numeric_change_is_still_caught() {
339 let before = json!({"views": 5});
340 let changes = diff(Some(&before), &map(json!({"views": "6"})));
341 assert_eq!(changes["views"], json!([5, "6"]));
342 }
343
344 #[test]
345 fn null_and_empty_string_are_distinct() {
346 let before = json!({"nickname": Value::Null});
347 let changes = diff(Some(&before), &map(json!({"nickname": ""})));
348 assert_eq!(changes["nickname"], json!([Value::Null, ""]));
349 }
350
351 #[test]
352 fn timestamps_are_not_recorded_as_changes() {
353 let before = json!({"title": "A", "updated_at": "2026-01-01"});
354 let changes = diff(
355 Some(&before),
356 &map(json!({"title": "A", "updated_at": "2026-07-22"})),
357 );
358 assert!(changes.is_empty());
359 }
360
361 #[test]
362 fn delete_captures_the_whole_row() {
363 let changes = diff_removed(&json!({"id": 1, "title": "Gone"}));
364 assert_eq!(changes["title"], json!(["Gone", Value::Null]));
365 assert_eq!(changes["id"], json!([1, Value::Null]));
366 }
367}