auditlog 0.1.0

Audit trail for your data models — an ORM-agnostic core with a pluggable, async sqlx backend (SQLite & Postgres).
Documentation
//! Global configuration and per-model audit options.

use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, Mutex, RwLock};

use serde_json::Value;

use crate::action::Action;
use crate::changes::REDACTED;

/// Default columns never recorded in any diff.
pub const DEFAULT_IGNORED_ATTRIBUTES: &[&str] = &[
    "lock_version",
    "created_at",
    "updated_at",
    "created_on",
    "updated_on",
];

/// Process-global configuration settings.
#[derive(Clone, Debug)]
pub struct GlobalConfig {
    /// Columns never audited for any model (merged with each model's own exclusions and primary
    /// key). Defaults to [`DEFAULT_IGNORED_ATTRIBUTES`].
    pub ignored_attributes: Vec<String>,
    /// Global retention cap per record; a per-model `max_audits` overrides this.
    pub max_audits: Option<usize>,
}

impl Default for GlobalConfig {
    fn default() -> Self {
        GlobalConfig {
            ignored_attributes: DEFAULT_IGNORED_ATTRIBUTES
                .iter()
                .map(|s| s.to_string())
                .collect(),
            max_audits: None,
        }
    }
}

static GLOBAL: LazyLock<RwLock<GlobalConfig>> =
    LazyLock::new(|| RwLock::new(GlobalConfig::default()));
static GLOBAL_ENABLED: AtomicBool = AtomicBool::new(true);
static TYPE_ENABLED: LazyLock<Mutex<HashMap<String, bool>>> =
    LazyLock::new(|| Mutex::new(HashMap::new()));

/// Mutate the global configuration.
///
/// ```
/// auditlog::config(|c| {
///     c.max_audits = Some(10);
///     c.ignored_attributes.push("internal_token".into());
/// });
/// ```
pub fn config(f: impl FnOnce(&mut GlobalConfig)) {
    let mut g = GLOBAL.write().expect("auditlog global config poisoned");
    f(&mut g);
}

/// Snapshot the current global configuration.
pub fn global_config() -> GlobalConfig {
    GLOBAL
        .read()
        .expect("auditlog global config poisoned")
        .clone()
}

/// The global retention cap (if any).
pub fn global_max_audits() -> Option<usize> {
    GLOBAL
        .read()
        .expect("auditlog global config poisoned")
        .max_audits
}

/// Set the process-global master auditing switch.
pub fn set_auditing_enabled(enabled: bool) {
    GLOBAL_ENABLED.store(enabled, Ordering::SeqCst);
}

/// Whether the process-global master switch is on.
pub fn auditing_enabled() -> bool {
    GLOBAL_ENABLED.load(Ordering::SeqCst)
}

/// Persistently enable/disable auditing for one auditable type. Within a
/// [`crate::without_auditing`] / [`crate::with_auditing`] scope, the scope wins for the duration
/// of the block.
pub fn set_type_enabled(auditable_type: &str, enabled: bool) {
    TYPE_ENABLED
        .lock()
        .expect("auditlog type-enabled poisoned")
        .insert(auditable_type.to_string(), enabled);
}

/// Whether a type's persistent flag is on (defaults to `true`).
pub fn type_enabled(auditable_type: &str) -> bool {
    TYPE_ENABLED
        .lock()
        .expect("auditlog type-enabled poisoned")
        .get(auditable_type)
        .copied()
        .unwrap_or(true)
}

/// Per-model audit options controlling how a model is audited. Build with
/// [`AuditOptions::builder`].
#[derive(Clone, Debug)]
pub struct AuditOptions {
    /// Audit only these columns (whitelist). Mutually exclusive with [`AuditOptions::except`].
    pub only: Option<Vec<String>>,
    /// Audit everything except these columns (blacklist), in addition to the defaults.
    pub except: Option<Vec<String>>,
    /// Which actions produce audits (and trigger comment-required validation).
    pub on: Vec<Action>,
    /// Require a comment for audited create/update/destroy actions.
    pub comment_required: bool,
    /// Whether a comment alone (no attribute changes) triggers an update audit. Default `true`.
    pub update_with_comment_only: bool,
    /// Per-model retention cap; overrides the global [`GlobalConfig::max_audits`].
    pub max_audits: Option<usize>,
    /// Columns whose changes are logged but whose values are replaced with
    /// [`AuditOptions::redaction_value`].
    pub redacted: Vec<String>,
    /// The placeholder used for redacted columns. Default `"[REDACTED]"`.
    pub redaction_value: Value,
    /// Columns treated as encrypted; their values are replaced with `"[FILTERED]"`.
    pub encrypted: Vec<String>,
    /// The polymorphic type name recorded as the `associated` record, when
    /// [`crate::Auditable::audit_associated`] returns a value.
    pub associated_with: Option<String>,
}

impl Default for AuditOptions {
    fn default() -> Self {
        AuditOptions {
            only: None,
            except: None,
            on: vec![Action::Create, Action::Update, Action::Destroy],
            comment_required: false,
            update_with_comment_only: true,
            max_audits: None,
            redacted: Vec::new(),
            redaction_value: Value::String(REDACTED.to_string()),
            encrypted: Vec::new(),
            associated_with: None,
        }
    }
}

impl AuditOptions {
    /// Start building options.
    pub fn builder() -> AuditOptionsBuilder {
        AuditOptionsBuilder {
            options: AuditOptions::default(),
        }
    }

    /// The effective retention cap: the per-model value if set, otherwise the global one.
    pub fn effective_max_audits(&self) -> Option<usize> {
        self.max_audits.or_else(global_max_audits)
    }

    /// Whether `action` is in the audited `on` set.
    pub fn audits_action(&self, action: Action) -> bool {
        self.on.contains(&action)
    }

    /// The set of columns that are never audited, given the full column list, the primary key, and
    /// the optional single-table-inheritance column.
    ///
    /// With `default_ignored = [primary_key, inheritance_column] ∪ ignored_attributes`:
    /// * `only` present → `(all_columns ∪ default_ignored) − only`
    /// * `except` present → `default_ignored ∪ except`
    /// * else → `default_ignored`
    pub fn non_audited_columns(
        &self,
        all_columns: &[String],
        primary_key: &str,
        inheritance_column: Option<&str>,
    ) -> HashSet<String> {
        let mut default_ignored: HashSet<String> =
            global_config().ignored_attributes.into_iter().collect();
        default_ignored.insert(primary_key.to_string());
        if let Some(type_col) = inheritance_column {
            default_ignored.insert(type_col.to_string());
        }

        if let Some(only) = &self.only {
            let only_set: HashSet<&String> = only.iter().collect();
            let mut result: HashSet<String> = all_columns.iter().cloned().collect();
            result.extend(default_ignored.iter().cloned());
            result.retain(|c| !only_set.contains(c));
            result
        } else if let Some(except) = &self.except {
            default_ignored.extend(except.iter().cloned());
            default_ignored
        } else {
            default_ignored
        }
    }

    /// Filter a full attribute map down to only the audited columns.
    pub(crate) fn filter_attributes(
        &self,
        attrs: &crate::changes::ValueMap,
        primary_key: &str,
        inheritance_column: Option<&str>,
    ) -> crate::changes::ValueMap {
        let all_columns: Vec<String> = attrs.keys().cloned().collect();
        let non_audited = self.non_audited_columns(&all_columns, primary_key, inheritance_column);
        attrs
            .iter()
            .filter(|(k, _)| !non_audited.contains(*k))
            .map(|(k, v)| (k.clone(), v.clone()))
            .collect()
    }
}

/// Builder for [`AuditOptions`].
#[derive(Clone, Debug)]
pub struct AuditOptionsBuilder {
    options: AuditOptions,
}

impl AuditOptionsBuilder {
    /// Audit only these columns. Clears any previously set `except`.
    pub fn only<I, S>(mut self, columns: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.options.only = Some(columns.into_iter().map(Into::into).collect());
        self.options.except = None;
        self
    }

    /// Audit everything except these columns. Clears any previously set `only`.
    pub fn except<I, S>(mut self, columns: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.options.except = Some(columns.into_iter().map(Into::into).collect());
        self.options.only = None;
        self
    }

    /// Restrict which actions produce audits.
    pub fn on<I>(mut self, actions: I) -> Self
    where
        I: IntoIterator<Item = Action>,
    {
        self.options.on = actions.into_iter().collect();
        self
    }

    /// Require a comment for audited actions.
    pub fn comment_required(mut self, required: bool) -> Self {
        self.options.comment_required = required;
        self
    }

    /// Whether a comment alone triggers an update audit (default `true`).
    pub fn update_with_comment_only(mut self, allowed: bool) -> Self {
        self.options.update_with_comment_only = allowed;
        self
    }

    /// Cap retained audits per record.
    pub fn max_audits(mut self, max: usize) -> Self {
        self.options.max_audits = Some(max);
        self
    }

    /// Redact the given columns (values replaced by the redaction placeholder).
    pub fn redacted<I, S>(mut self, columns: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.options.redacted = columns.into_iter().map(Into::into).collect();
        self
    }

    /// Use a custom redaction placeholder.
    pub fn redaction_value(mut self, value: impl Into<Value>) -> Self {
        self.options.redaction_value = value.into();
        self
    }

    /// Mark the given columns as encrypted (values replaced by `"[FILTERED]"`).
    pub fn encrypted<I, S>(mut self, columns: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.options.encrypted = columns.into_iter().map(Into::into).collect();
        self
    }

    /// Record a polymorphic associated/parent record of the given type on each audit.
    pub fn associated_with(mut self, type_name: impl Into<String>) -> Self {
        self.options.associated_with = Some(type_name.into());
        self
    }

    /// Finish building.
    ///
    /// # Panics
    /// Panics if both `only` and `except` were somehow set (the setters keep them mutually
    /// exclusive, so this only triggers on direct struct construction misuse) or if `on` is empty.
    pub fn build(self) -> AuditOptions {
        assert!(
            !(self.options.only.is_some() && self.options.except.is_some()),
            "audited options `only` and `except` are mutually exclusive"
        );
        assert!(
            !self.options.on.is_empty(),
            "audited options `on` must list at least one action"
        );
        self.options
    }
}

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

    fn cols(c: &[&str]) -> Vec<String> {
        c.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn non_audited_default_includes_pk_and_timestamps() {
        let opts = AuditOptions::default();
        let na = opts.non_audited_columns(&cols(&["id", "name", "created_at"]), "id", None);
        assert!(na.contains("id"));
        assert!(na.contains("created_at"));
        assert!(!na.contains("name"));
    }

    #[test]
    fn non_audited_includes_sti_inheritance_column() {
        let opts = AuditOptions::default();
        let na = opts.non_audited_columns(&cols(&["id", "type", "name"]), "id", Some("type"));
        assert!(na.contains("type"));
        assert!(!na.contains("name"));
    }

    #[test]
    fn only_audits_just_those_columns() {
        let opts = AuditOptions::builder().only(["name"]).build();
        let na = opts.non_audited_columns(&cols(&["id", "name", "email"]), "id", None);
        assert!(na.contains("id"));
        assert!(na.contains("email"));
        assert!(!na.contains("name"));
    }

    #[test]
    fn except_excludes_extra_columns() {
        let opts = AuditOptions::builder().except(["password"]).build();
        let na = opts.non_audited_columns(&cols(&["id", "name", "password"]), "id", None);
        assert!(na.contains("password"));
        assert!(na.contains("id"));
        assert!(!na.contains("name"));
    }

    #[test]
    fn only_and_except_are_mutually_exclusive() {
        let opts = AuditOptions::builder().except(["a"]).only(["b"]).build();
        assert!(opts.except.is_none());
        assert_eq!(opts.only, Some(vec!["b".to_string()]));
    }
}