use crate::error::CoreError;
use crate::request::ReqCtx;
use crate::response::ApiResponse;
use crate::storage::StorageError;
use async_trait::async_trait;
use once_cell::sync::OnceCell;
use serde_json::{json, Map, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event {
Create,
Update,
Delete,
}
impl Event {
pub fn as_str(&self) -> &'static str {
match self {
Event::Create => "create",
Event::Update => "update",
Event::Delete => "delete",
}
}
}
#[derive(Debug, Clone)]
pub struct AuditEntry {
pub item_type: String,
pub item_id: String,
pub event: Event,
pub whodunnit: Option<String>,
pub whodunnit_email: Option<String>,
pub changes: Map<String, Value>,
}
impl AuditEntry {
pub fn new(
ctx: &ReqCtx,
item_type: impl Into<String>,
item_id: impl Into<String>,
event: Event,
changes: Map<String, Value>,
) -> Self {
let (whodunnit, whodunnit_email) = match &ctx.claims {
Some(c) => (Some(c.sub.clone()), Some(c.email.clone())),
None => (None, None),
};
Self {
item_type: item_type.into(),
item_id: item_id.into(),
event,
whodunnit,
whodunnit_email,
changes,
}
}
}
#[async_trait]
pub trait Auditor: Send + Sync {
async fn record(&self, entry: AuditEntry) -> Result<(), StorageError>;
async fn history(
&self,
_item_type: &str,
_item_id: &str,
_limit: u64,
) -> Result<Vec<Value>, StorageError> {
Ok(Vec::new())
}
fn strict(&self) -> bool {
false
}
}
static AUDITOR: OnceCell<Box<dyn Auditor>> = OnceCell::new();
pub fn set_auditor(auditor: Box<dyn Auditor>) {
if AUDITOR.set(auditor).is_err() {
tracing::warn!("adminx auditor already initialized; ignoring reset");
}
}
pub fn auditor() -> Option<&'static dyn Auditor> {
AUDITOR.get().map(|b| b.as_ref())
}
pub fn is_enabled() -> bool {
AUDITOR.get().is_some()
}
pub async fn emit(entry: AuditEntry) -> Option<ApiResponse> {
let auditor = auditor()?;
match auditor.record(entry).await {
Ok(()) => None,
Err(e) => {
tracing::error!("adminx: failed to record audit entry: {e}");
if auditor.strict() {
Some(
CoreError::Internal(format!(
"the change was applied but could not be recorded to the audit log: {e}"
))
.into(),
)
} else {
None
}
}
}
}
pub const HISTORY_LIMIT: u64 = 100;
pub async fn history(item_type: &str, item_id: &str) -> Vec<Value> {
let Some(auditor) = auditor() else {
return Vec::new();
};
let rows = match auditor.history(item_type, item_id, HISTORY_LIMIT).await {
Ok(rows) => rows,
Err(e) => {
tracing::error!("adminx: failed to read audit history: {e}");
return Vec::new();
}
};
rows.iter().map(present).collect()
}
fn present(row: &Value) -> Value {
let changes = match row.get("changes") {
Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(Value::Null),
Some(other) => other.clone(),
None => Value::Null,
};
let mut fields = Vec::new();
if let Value::Object(map) = &changes {
for (name, pair) in map {
let (old, new) = match pair {
Value::Array(a) if a.len() == 2 => (display(&a[0]), display(&a[1])),
other => (String::new(), display(other)),
};
fields.push(json!({ "name": name, "old": old, "new": new }));
}
}
json!({
"id": row.get("id").cloned().unwrap_or(Value::Null),
"event": row.get("event").and_then(|v| v.as_str()).unwrap_or(""),
"whodunnit_email": row
.get("whodunnit_email")
.and_then(|v| v.as_str())
.unwrap_or("—"),
"created_at": row.get("created_at").and_then(|v| v.as_str()).unwrap_or(""),
"fields": fields,
})
}
fn display(v: &Value) -> String {
match v {
Value::Null => "—".to_string(),
Value::String(s) if s.is_empty() => "(empty)".to_string(),
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
const NOISE: [&str; 2] = ["updated_at", "created_at"];
pub fn diff(before: Option<&Value>, after: &Map<String, Value>) -> Map<String, Value> {
let mut out = Map::new();
for (key, new) in after {
if NOISE.contains(&key.as_str()) {
continue;
}
let old = before
.and_then(|b| b.get(key))
.cloned()
.unwrap_or(Value::Null);
if !same(&old, new) {
out.insert(key.clone(), json!([old, new]));
}
}
out
}
pub fn diff_removed(before: &Value) -> Map<String, Value> {
let mut out = Map::new();
if let Value::Object(map) = before {
for (key, old) in map {
out.insert(key.clone(), json!([old, Value::Null]));
}
}
out
}
fn same(a: &Value, b: &Value) -> bool {
if a == b {
return true;
}
match (scalar_text(a), scalar_text(b)) {
(Some(x), Some(y)) => x == y,
_ => false,
}
}
fn scalar_text(v: &Value) -> Option<String> {
match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn map(v: Value) -> Map<String, Value> {
match v {
Value::Object(m) => m,
_ => panic!("expected object"),
}
}
#[test]
fn create_diff_has_null_on_the_left() {
let changes = diff(None, &map(json!({"title": "Hello"})));
assert_eq!(changes["title"], json!([Value::Null, "Hello"]));
}
#[test]
fn only_changed_columns_are_recorded() {
let before = json!({"title": "Old", "body": "Same"});
let changes = diff(Some(&before), &map(json!({"title": "New", "body": "Same"})));
assert_eq!(changes.len(), 1);
assert_eq!(changes["title"], json!(["Old", "New"]));
}
#[test]
fn form_strings_do_not_read_as_changes_against_typed_columns() {
let before = json!({"views": 5, "published": true});
let changes = diff(Some(&before), &map(json!({"views": "5", "published": "true"})));
assert!(changes.is_empty(), "expected no changes, got {changes:?}");
}
#[test]
fn a_real_numeric_change_is_still_caught() {
let before = json!({"views": 5});
let changes = diff(Some(&before), &map(json!({"views": "6"})));
assert_eq!(changes["views"], json!([5, "6"]));
}
#[test]
fn null_and_empty_string_are_distinct() {
let before = json!({"nickname": Value::Null});
let changes = diff(Some(&before), &map(json!({"nickname": ""})));
assert_eq!(changes["nickname"], json!([Value::Null, ""]));
}
#[test]
fn timestamps_are_not_recorded_as_changes() {
let before = json!({"title": "A", "updated_at": "2026-01-01"});
let changes = diff(
Some(&before),
&map(json!({"title": "A", "updated_at": "2026-07-22"})),
);
assert!(changes.is_empty());
}
#[test]
fn delete_captures_the_whole_row() {
let changes = diff_removed(&json!({"id": 1, "title": "Gone"}));
assert_eq!(changes["title"], json!(["Gone", Value::Null]));
assert_eq!(changes["id"], json!([1, Value::Null]));
}
}