auditlog 0.1.0

Audit trail for your data models — an ORM-agnostic core with a pluggable, async sqlx backend (SQLite & Postgres).
Documentation
//! The `audited_changes` payload and the rules for computing it.
//!
//! This is the single most correctness-sensitive part of auditlog. The shape of the change set
//! depends on the [`Action`]:
//!
//! | Action  | Shape                                  |
//! |---------|----------------------------------------|
//! | Create  | `{ column: value }` (single values)    |
//! | Update  | `{ column: [old, new] }` (2-elem pairs)|
//! | Destroy | `{ column: value }` (single values)    |
//!
//! Because a create/destroy snapshot value may *itself* be a JSON array (an array-typed column),
//! the stored form is ambiguous on its own — it can only be interpreted with knowledge of the
//! row's `action`. All accessors therefore take an [`Action`].

use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::action::Action;

/// An ordered map of column name to JSON value.
pub type ValueMap = IndexMap<String, Value>;

/// The default placeholder used for redacted columns (`audited redacted: ...`).
pub const REDACTED: &str = "[REDACTED]";

/// The placeholder used for encrypted attributes (distinct from [`REDACTED`]).
pub const FILTERED: &str = "[FILTERED]";

/// The serialized `audited_changes` payload of one audit.
///
/// Stored verbatim as a JSON object. Use [`AuditedChanges::new_attributes`] /
/// [`AuditedChanges::old_attributes`] / [`AuditedChanges::typed`] (all action-aware) to interpret
/// it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AuditedChanges(pub ValueMap);

/// A single interpreted change entry.
#[derive(Clone, Debug, PartialEq)]
pub enum ChangeValue {
    /// A single value (create/destroy snapshot, or a legacy single-value update).
    Set(Value),
    /// An update pair `(old, new)`.
    Update(Value, Value),
}

impl AuditedChanges {
    /// An empty change set.
    pub fn empty() -> Self {
        AuditedChanges(IndexMap::new())
    }

    /// Build directly from a value map (values interpreted per the eventual action).
    pub fn from_map(map: ValueMap) -> Self {
        AuditedChanges(map)
    }

    /// Snapshot form used by `create` and `destroy`: every value stored as-is.
    pub fn snapshot(attrs: ValueMap) -> Self {
        AuditedChanges(attrs)
    }

    /// Diff form used by `update`: for every column whose value differs between `old` and `new`,
    /// store `[old, new]`. Column order follows `new`. A column present in `new` but missing in
    /// `old` is treated as having an old value of `null`.
    ///
    /// Equality uses [`serde_json::Value`] equality, which compares already-type-cast values — so
    /// `0` vs `"0"` *do* differ here. Callers are expected to feed already-normalized attribute
    /// maps (the same normalization their column would produce), so dirty tracking operates on
    /// type-cast values.
    pub fn diff(old: &ValueMap, new: &ValueMap) -> Self {
        let mut out = IndexMap::new();
        for (key, new_val) in new {
            let old_val = old.get(key);
            if old_val != Some(new_val) {
                let old_val = old_val.cloned().unwrap_or(Value::Null);
                out.insert(key.clone(), Value::Array(vec![old_val, new_val.clone()]));
            }
        }
        AuditedChanges(out)
    }

    /// Whether there are no recorded changes.
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    /// Number of changed columns.
    pub fn len(&self) -> usize {
        self.0.len()
    }

    /// Iterate raw `(column, stored_value)` entries.
    pub fn iter(&self) -> impl Iterator<Item = (&String, &Value)> {
        self.0.iter()
    }

    /// Whether a column is present.
    pub fn contains(&self, key: &str) -> bool {
        self.0.contains_key(key)
    }

    /// The new value of `column`, interpreted for `action`.
    pub fn new_value(&self, action: Action, column: &str) -> Option<Value> {
        self.0.get(column).map(|v| new_of(action, v))
    }

    /// The old value of `column`, interpreted for `action`.
    pub fn old_value(&self, action: Action, column: &str) -> Option<Value> {
        self.0.get(column).map(|v| old_of(action, v))
    }

    /// The changed attributes paired with their *new* values.
    pub fn new_attributes(&self, action: Action) -> ValueMap {
        self.0
            .iter()
            .map(|(k, v)| (k.clone(), new_of(action, v)))
            .collect()
    }

    /// The changed attributes paired with their *old* values.
    pub fn old_attributes(&self, action: Action) -> ValueMap {
        self.0
            .iter()
            .map(|(k, v)| (k.clone(), old_of(action, v)))
            .collect()
    }

    /// Interpret the whole change set into typed [`ChangeValue`]s.
    pub fn typed(&self, action: Action) -> IndexMap<String, ChangeValue> {
        self.0
            .iter()
            .map(|(k, v)| (k.clone(), typed_of(action, v)))
            .collect()
    }

    /// Replace the values of any listed columns with `placeholder`. If the stored value is an
    /// array (an update `[old, new]` pair *or* an array-typed snapshot column) each element is
    /// replaced, preserving arity; otherwise the single value is replaced. Columns not present are
    /// ignored. Used for both `redacted` and encrypted-attribute filtering.
    pub(crate) fn mask(&mut self, columns: &[String], placeholder: &Value) {
        for col in columns {
            if let Some(slot) = self.0.get_mut(col) {
                *slot = match slot {
                    Value::Array(items) => {
                        Value::Array(items.iter().map(|_| placeholder.clone()).collect())
                    }
                    _ => placeholder.clone(),
                };
            }
        }
    }

    /// Merge `other` on top of `self`, later keys winning. This is the merge applied when
    /// `combine_audits` folds several change sets into one.
    pub(crate) fn merge_in(&mut self, other: &AuditedChanges) {
        for (k, v) in &other.0 {
            self.0.insert(k.clone(), v.clone());
        }
    }
}

fn new_of(action: Action, v: &Value) -> Value {
    if action.is_update() {
        match v {
            Value::Array(items) if items.len() == 2 => items[1].clone(),
            other => other.clone(), // legacy single-value tolerance
        }
    } else {
        v.clone()
    }
}

fn old_of(action: Action, v: &Value) -> Value {
    if action.is_update() {
        match v {
            Value::Array(items) if items.len() == 2 => items[0].clone(),
            other => other.clone(),
        }
    } else {
        v.clone()
    }
}

fn typed_of(action: Action, v: &Value) -> ChangeValue {
    if action.is_update()
        && let Value::Array(items) = v
        && items.len() == 2
    {
        return ChangeValue::Update(items[0].clone(), items[1].clone());
    }
    ChangeValue::Set(v.clone())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn map(pairs: &[(&str, Value)]) -> ValueMap {
        pairs
            .iter()
            .map(|(k, v)| (k.to_string(), v.clone()))
            .collect()
    }

    #[test]
    fn create_snapshot_is_single_values() {
        let c = AuditedChanges::snapshot(map(&[("name", json!("Brandon")), ("status", json!(1))]));
        assert_eq!(
            c.new_attributes(Action::Create),
            map(&[("name", json!("Brandon")), ("status", json!(1))])
        );
        assert_eq!(
            c.old_attributes(Action::Create),
            map(&[("name", json!("Brandon")), ("status", json!(1))])
        );
    }

    #[test]
    fn update_diff_is_pairs() {
        let old = map(&[("name", json!("Brandon")), ("age", json!(30))]);
        let new = map(&[("name", json!("Changed")), ("age", json!(30))]);
        let c = AuditedChanges::diff(&old, &new);
        assert_eq!(c.len(), 1);
        assert_eq!(c.0.get("name"), Some(&json!(["Brandon", "Changed"])));
        assert_eq!(
            c.new_attributes(Action::Update),
            map(&[("name", json!("Changed"))])
        );
        assert_eq!(
            c.old_attributes(Action::Update),
            map(&[("name", json!("Brandon"))])
        );
    }

    #[test]
    fn typecast_equal_values_are_not_a_change() {
        // identical JSON values produce no diff entry
        let old = map(&[("logins", json!(0))]);
        let new = map(&[("logins", json!(0))]);
        assert!(AuditedChanges::diff(&old, &new).is_empty());
    }

    #[test]
    fn destroy_snapshot_like_create() {
        let c = AuditedChanges::snapshot(map(&[("name", json!("Brandon"))]));
        assert_eq!(
            c.new_attributes(Action::Destroy),
            map(&[("name", json!("Brandon"))])
        );
    }

    #[test]
    fn mask_update_redacts_both_sides() {
        let mut c = AuditedChanges::diff(
            &map(&[("password", json!("old"))]),
            &map(&[("password", json!("new"))]),
        );
        c.mask(&["password".to_string()], &json!(REDACTED));
        assert_eq!(
            c.0.get("password"),
            Some(&json!(["[REDACTED]", "[REDACTED]"]))
        );
    }

    #[test]
    fn mask_create_redacts_single() {
        let mut c = AuditedChanges::snapshot(map(&[("password", json!("secret"))]));
        c.mask(&["password".to_string()], &json!(REDACTED));
        assert_eq!(c.0.get("password"), Some(&json!("[REDACTED]")));
    }

    #[test]
    fn mask_array_typed_snapshot_is_element_wise() {
        // a snapshot of an array-typed column must keep its arity after masking
        let mut c = AuditedChanges::snapshot(map(&[("tags", json!(["a", "b", "c"]))]));
        c.mask(&["tags".to_string()], &json!(REDACTED));
        assert_eq!(
            c.0.get("tags"),
            Some(&json!(["[REDACTED]", "[REDACTED]", "[REDACTED]"]))
        );
    }

    #[test]
    fn merge_later_wins() {
        let mut a = AuditedChanges::snapshot(map(&[
            ("name", json!("Foobar")),
            ("username", json!("brandon")),
        ]));
        let b = AuditedChanges::from_map(map(&[("name", json!(["Foobar", "Awesome"]))]));
        a.merge_in(&b);
        assert_eq!(a.0.get("name"), Some(&json!(["Foobar", "Awesome"])));
        assert_eq!(a.0.get("username"), Some(&json!("brandon")));
    }

    #[test]
    fn legacy_single_value_update_tolerated() {
        // old storage format: update stored only the new scalar, not a pair
        let c = AuditedChanges::from_map(map(&[("name", json!("value"))]));
        assert_eq!(c.new_value(Action::Update, "name"), Some(json!("value")));
        assert_eq!(c.old_value(Action::Update, "name"), Some(json!("value")));
    }
}