Skip to main content

adminx_core/
audit.rs

1// adminx-core/src/audit.rs
2//
3// The audit seam. Default CRUD records who changed what, through a pluggable
4// backend — the same pattern `storage` and `authz` use. With no auditor
5// registered nothing is recorded and no extra query is issued, so the cost is
6// zero until a crate like `adminx-audit` opts in.
7//
8// Why this lives on `Resource` and not `Storage`: only the resource layer holds
9// the `ReqCtx`, and without it there is no *whodunnit*. The trade-off is that a
10// resource which overrides `create`/`update`/`delete` outright bypasses
11// auditing — such an override owns the recording itself. See `Resource`.
12
13use 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/// What happened to a record.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Event {
24    Create,
25    Update,
26    Delete,
27}
28
29impl Event {
30    /// The token stored in the `event` column.
31    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/// One recorded change to one record.
41///
42/// `changes` is PaperTrail's `object_changes` shape — a map of column name to a
43/// `[old, new]` pair, holding *only* the columns that actually changed. A create
44/// has `null` on the left, a delete `null` on the right.
45#[derive(Debug, Clone)]
46pub struct AuditEntry {
47    /// The resource's `base_path()`, identifying what kind of record changed.
48    pub item_type: String,
49    /// Primary key of the affected record. Empty when a create didn't report one.
50    pub item_id: String,
51    pub event: Event,
52    /// `claims.sub` of the acting principal; `None` when auth is unconfigured.
53    pub whodunnit: Option<String>,
54    /// `claims.email`, denormalized so the log stays readable after the admin
55    /// user is renamed or removed.
56    pub whodunnit_email: Option<String>,
57    pub changes: Map<String, Value>,
58}
59
60impl AuditEntry {
61    /// Build an entry, lifting the actor out of the request context.
62    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/// A pluggable audit sink. Unlike [`Authorizer`](crate::authz::Authorizer) this
85/// *is* async and does I/O — it runs once per mutation, not several times per
86/// request.
87#[async_trait]
88pub trait Auditor: Send + Sync {
89    async fn record(&self, entry: AuditEntry) -> Result<(), StorageError>;
90
91    /// Recorded entries for one record, newest first, capped at `limit`.
92    ///
93    /// Rows come back in whatever shape the backend stored them; the history
94    /// page reads the `event` / `whodunnit_email` / `changes` / `created_at`
95    /// keys and tolerates their absence. The default returns nothing, so a
96    /// write-only sink (shipping to syslog, say) needn't implement reading —
97    /// the history page then simply shows an empty log.
98    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    /// Whether a failed audit write should fail the operation that triggered it.
108    ///
109    /// The default is best-effort: the error is logged and the write stands, so
110    /// a sick audit table cannot take the panel down. Returning `true` surfaces
111    /// the failure to the caller as a 500 instead — see [`emit`] for exactly
112    /// what that does and does not guarantee.
113    fn strict(&self) -> bool {
114        false
115    }
116}
117
118static AUDITOR: OnceCell<Box<dyn Auditor>> = OnceCell::new();
119
120/// Register the global audit sink. Set-once, matching `set_storage` and
121/// `set_authorizer`.
122pub 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
128/// The registered auditor, if any.
129pub fn auditor() -> Option<&'static dyn Auditor> {
130    AUDITOR.get().map(|b| b.as_ref())
131}
132
133/// Whether auditing is on. Default CRUD checks this before spending a read to
134/// capture before-state, so an unaudited app issues exactly the queries it did
135/// before this module existed.
136pub fn is_enabled() -> bool {
137    AUDITOR.get().is_some()
138}
139
140/// Hand an entry to the registered auditor.
141///
142/// Returns `Some(response)` only when the auditor is [`strict`](Auditor::strict)
143/// *and* the write failed — the caller returns it in place of its success
144/// response.
145///
146/// **What strict mode does not do:** the mutation has already been committed by
147/// the time this runs, and [`Storage`](crate::storage::Storage) exposes no
148/// transaction spanning both writes. So a strict failure reports a 500 for a
149/// change that *did* land. It converts a silent hole in the audit log into a
150/// loud one; it is not atomicity. Recording before the mutation would be worse —
151/// it would log changes that never happened.
152pub 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
172/// How many versions the history page shows. An audit table only grows, so the
173/// per-record view is capped rather than paginated for now.
174pub const HISTORY_LIMIT: u64 = 100;
175
176/// Entries for one record, shaped for the history template: the stored
177/// `changes` document is expanded into a per-field `[old, new]` list so the
178/// view layer doesn't have to parse JSON.
179///
180/// Returns an empty list when auditing is off, so callers can render the page
181/// unconditionally.
182pub 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
196/// One stored row -> the shape the template renders.
197fn present(row: &Value) -> Value {
198    // `changes` is stored as a JSON string (the one representation every backend
199    // keeps identically); a backend that stored it natively works here too.
200    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
229/// Render a stored value for the diff table. `null` becomes an em dash so an
230/// absent value reads differently from an empty string — the distinction the
231/// diff deliberately preserves.
232fn 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
241/// Columns never worth recording: they change on every write and say nothing
242/// about intent.
243const NOISE: [&str; 2] = ["updated_at", "created_at"];
244
245/// Diff a submitted write against the row it replaced, keeping only the columns
246/// that actually changed.
247///
248/// `before` is the stored row (`None` for a create). Returns `[old, new]` pairs.
249pub 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
266/// The inverse shape, for a delete: every stored column moves from its value to
267/// `null`, so the log holds the whole record as it last existed.
268pub 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
278/// Loose scalar equality.
279///
280/// Everything posted through an HTML form arrives as a string, so a stored
281/// integer `5` would compare unequal to a submitted `"5"` and strict JSON
282/// equality would report every field as modified on every save. Compare scalars
283/// by their text; anything structural falls back to exact equality.
284fn 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        // Null vs a value, or two differing objects/arrays: a real change.
291        _ => 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        // The exact false positive that would otherwise mark every row dirty.
332        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}