auditlog 0.1.0

Audit trail for your data models — an ORM-agnostic core with a pluggable, async sqlx backend (SQLite & Postgres).
Documentation
//! The audit-writing engine: change computation, the write/comment decisions, and `max_audits`
//! pruning. This drives the create / update / destroy audit recording, the audit write itself, and
//! the combine-if-needed pruning step.

use chrono::Utc;
use serde_json::Value;
use uuid::Uuid;

use crate::action::Action;
use crate::audit::{Audit, NewAudit};
use crate::backend::{AuditQuery, Backend, Order};
use crate::changes::{AuditedChanges, FILTERED, ValueMap};
use crate::config::AuditOptions;
use crate::context::{self, AuditContext};
use crate::error::{AuditError, Result};
use crate::id::AuditId;

/// Everything the engine needs about the auditable record, gathered by the [`crate::Auditable`]
/// trait methods.
pub(crate) struct Meta<'a> {
    pub auditable_type: &'a str,
    pub auditable_id: AuditId,
    pub primary_key: &'a str,
    pub inheritance_column: Option<&'a str>,
    pub options: &'a AuditOptions,
    pub associated: Option<(String, AuditId)>,
    /// `audit_if() && !audit_unless()`.
    pub instance_enabled: bool,
}

/// Gather the [`Meta`] for an auditable record from its trait methods.
pub(crate) fn meta_for<'a, T: crate::auditable::Auditable>(
    record: &T,
    options: &'a AuditOptions,
) -> Meta<'a> {
    Meta {
        auditable_type: T::auditable_type(),
        auditable_id: record.auditable_id(),
        primary_key: T::primary_key(),
        inheritance_column: T::inheritance_column(),
        options,
        associated: record.audit_associated(),
        instance_enabled: record.audit_if() && !record.audit_unless(),
    }
}

fn is_blank(comment: &Option<String>) -> bool {
    comment
        .as_ref()
        .map(|s| s.trim().is_empty())
        .unwrap_or(true)
}

fn apply_masks(changes: &mut AuditedChanges, options: &AuditOptions) {
    if !options.redacted.is_empty() {
        changes.mask(&options.redacted, &options.redaction_value);
    }
    if !options.encrypted.is_empty() {
        changes.mask(&options.encrypted, &Value::String(FILTERED.to_string()));
    }
}

/// Record a `create` audit from the record's full attribute snapshot.
pub(crate) async fn create(
    backend: &dyn Backend,
    meta: &Meta<'_>,
    all_attrs: ValueMap,
    comment: Option<String>,
) -> Result<Option<Audit>> {
    if !meta.options.audits_action(Action::Create) {
        return Ok(None);
    }
    let ctx = context::current_context();
    let enabled =
        context::type_auditing_enabled(meta.auditable_type, &ctx) && meta.instance_enabled;

    let mut changes = AuditedChanges::snapshot(meta.options.filter_attributes(
        &all_attrs,
        meta.primary_key,
        meta.inheritance_column,
    ));
    apply_masks(&mut changes, meta.options);

    if enabled && meta.options.comment_required && !changes.is_empty() && is_blank(&comment) {
        return Err(AuditError::CommentRequired {
            action: Action::Create,
        });
    }
    if !enabled {
        return Ok(None);
    }

    finish(backend, meta, Action::Create, changes, comment, &ctx)
        .await
        .map(Some)
}

/// Record an `update` audit by diffing `old_attrs` against `new_attrs`.
pub(crate) async fn update(
    backend: &dyn Backend,
    meta: &Meta<'_>,
    old_attrs: ValueMap,
    new_attrs: ValueMap,
    comment: Option<String>,
) -> Result<Option<Audit>> {
    if !meta.options.audits_action(Action::Update) {
        return Ok(None);
    }
    let ctx = context::current_context();
    let enabled =
        context::type_auditing_enabled(meta.auditable_type, &ctx) && meta.instance_enabled;

    let filtered_old =
        meta.options
            .filter_attributes(&old_attrs, meta.primary_key, meta.inheritance_column);
    let filtered_new =
        meta.options
            .filter_attributes(&new_attrs, meta.primary_key, meta.inheritance_column);
    let mut changes = AuditedChanges::diff(&filtered_old, &filtered_new);
    apply_masks(&mut changes, meta.options);
    let changed = !changes.is_empty();

    if enabled && meta.options.comment_required && changed && is_blank(&comment) {
        return Err(AuditError::CommentRequired {
            action: Action::Update,
        });
    }
    if !enabled {
        return Ok(None);
    }

    // Write the audit unless there were no changes and either the comment is blank or
    // `update_with_comment_only` is disabled (a comment-only update produces no audit then).
    let skip = !changed && (is_blank(&comment) || !meta.options.update_with_comment_only);
    if skip {
        return Ok(None);
    }

    finish(backend, meta, Action::Update, changes, comment, &ctx)
        .await
        .map(Some)
}

/// Record a `destroy` audit from the record's full attribute snapshot.
pub(crate) async fn destroy(
    backend: &dyn Backend,
    meta: &Meta<'_>,
    all_attrs: ValueMap,
    comment: Option<String>,
) -> Result<Option<Audit>> {
    if !meta.options.audits_action(Action::Destroy) {
        return Ok(None);
    }
    let ctx = context::current_context();
    let enabled =
        context::type_auditing_enabled(meta.auditable_type, &ctx) && meta.instance_enabled;

    let mut changes = AuditedChanges::snapshot(meta.options.filter_attributes(
        &all_attrs,
        meta.primary_key,
        meta.inheritance_column,
    ));
    apply_masks(&mut changes, meta.options);

    // When a comment is required and `destroy` is among the audited actions, a destroy with a
    // blank comment is rejected before any audit is written.
    if enabled && meta.options.comment_required && is_blank(&comment) {
        return Err(AuditError::CommentRequired {
            action: Action::Destroy,
        });
    }
    if !enabled {
        return Ok(None);
    }

    finish(backend, meta, Action::Destroy, changes, comment, &ctx)
        .await
        .map(Some)
}

/// Build the `NewAudit` from the context, insert it, and run `max_audits` pruning for non-create
/// actions. This is the single audit-write path (comment-clearing is implicit here since the
/// comment is passed by value).
async fn finish(
    backend: &dyn Backend,
    meta: &Meta<'_>,
    action: Action,
    changes: AuditedChanges,
    comment: Option<String>,
    ctx: &AuditContext,
) -> Result<Audit> {
    let (user_id, user_type, username) = match &ctx.user {
        Some(actor) => actor.clone().into_columns(),
        None => (None, None, None),
    };
    let request_uuid = ctx
        .request_uuid
        .clone()
        .or_else(|| Some(Uuid::new_v4().to_string()));
    let (associated_type, associated_id) = match &meta.associated {
        Some((t, i)) => (Some(t.clone()), Some(i.clone())),
        None => (None, None),
    };

    let new_audit = NewAudit {
        auditable_type: meta.auditable_type.to_string(),
        auditable_id: meta.auditable_id.clone(),
        associated_type,
        associated_id,
        user_type,
        user_id,
        username,
        action,
        audited_changes: changes,
        comment,
        remote_address: ctx.remote_address.clone(),
        request_uuid,
        created_at: Utc::now(),
    };

    let audit = backend.insert(new_audit).await?;

    if action != Action::Create {
        // Only a deadlock during combine is swallowed (it means a concurrent identical combine
        // already won); the backend is responsible for treating deadlock-class errors as success.
        // Any other failure propagates.
        maybe_combine(backend, meta).await?;
    }

    Ok(audit)
}

/// `combine_audits_if_needed`: when the kept-audit count exceeds `max_audits`, fold the oldest
/// `extra + 1` audits into the newest of that group and delete the rest.
async fn maybe_combine(backend: &dyn Backend, meta: &Meta<'_>) -> Result<()> {
    let Some(max) = meta.options.effective_max_audits() else {
        return Ok(());
    };
    // Note: `max == 0` is NOT a no-op — it collapses the entire history into the newest audit on
    // every non-create write (`extra = count - 0`).

    let count = backend
        .count_for_auditable(meta.auditable_type, &meta.auditable_id)
        .await?;
    let extra = count - max as i64;
    if extra <= 0 {
        return Ok(());
    }

    let query = AuditQuery {
        order: Order::VersionAsc,
        limit: Some(extra + 1),
        ..Default::default()
    };
    let group = backend
        .audits_for_auditable(meta.auditable_type, &meta.auditable_id, &query)
        .await?;
    if group.len() < 2 {
        return Ok(());
    }

    let target = group.last().expect("group is non-empty");
    let mut merged = AuditedChanges::empty();
    for a in &group {
        merged.merge_in(&a.audited_changes);
    }
    let combined_comment = format!(
        "{}\nThis audit is the result of multiple audits being combined.",
        target.comment.as_deref().unwrap_or("")
    );
    let older_ids: Vec<i64> = group
        .iter()
        .filter(|a| a.version < target.version)
        .map(|a| a.id)
        .collect();

    backend
        .combine(target.id, &merged, Some(&combined_comment), &older_ids)
        .await
}