auditlog 0.1.0

Audit trail for your data models — an ORM-agnostic core with a pluggable, async sqlx backend (SQLite & Postgres).
Documentation

auditlog

An audit trail for your data models.

auditlog records every create / update / destroy of your models into a single polymorphic audits table, capturing what changed (a diff), who changed it, when, from where, and an optional comment — then lets you query that history and reconstruct any past revision.

auditlog is ORM-agnostic: implement the small Auditable trait for your model and hand it a Backend. A ready-to-use async SqlxBackend (SQLite & Postgres) is included, plus an in-memory MemoryBackend for tests.

[dependencies]
auditlog = { version = "0.1", features = ["sqlite"] }   # or "postgres"

Quick start

use auditlog::{Auditable, AuditOptions, AuditId, ValueMap, SqlxBackend};
use serde_json::json;

struct Post { id: i64, title: String, body: String }

impl Auditable for Post {
    fn auditable_type() -> &'static str { "Post" }
    fn auditable_id(&self) -> AuditId { self.id.into() }
    fn audited_attributes(&self) -> ValueMap {
        let mut m = ValueMap::new();
        m.insert("id".into(), json!(self.id));
        m.insert("title".into(), json!(self.title));
        m.insert("body".into(), json!(self.body));
        m
    }
    fn audit_options() -> AuditOptions { AuditOptions::default() }
}

// inside an async fn, returning auditlog::Result<()>:
let backend = SqlxBackend::connect_sqlite("sqlite::memory:").await?;
backend.migrate().await?;

// create
let mut post = Post { id: 1, title: "Hello".into(), body: "...".into() };
post.audited_create(&backend).await?;

// update — diff the old state against the new
let old = Post { id: 1, title: "Hello".into(), body: "...".into() };
post.title = "Hello, world".into();
post.audited_update(&backend, &old).await?;

// destroy
post.audited_destroy(&backend).await?;

for audit in Post::audits(&backend, 1).await? {
    println!("v{} {} {:?}", audit.version, audit.action, audit.audited_changes);
}

You give the crate the before and after state; it computes the diff, applies your config, and writes the audit. That is the whole ORM-agnostic contract — call audited_create after you persist, and audited_update / audited_destroy before.

See examples/blog.rs for a complete runnable walkthrough (cargo run --example blog).

Who made the change

Wrap a unit of work in an as_user scope. The acting user — and remote address / request id — live in a tokio task-local, so they are isolated per task and restored when the scope ends:

use auditlog::{as_user, Actor};

// a record user → user_id + user_type
as_user(Actor::record("User", 7), async {
    post.audited_create(&backend).await
}).await?;

// or a plain string → username
as_user(Actor::name("import job"), async {
    post.audited_create(&backend).await
}).await?;

For full control (user + IP + request id together, e.g. from web middleware) use with_context(AuditContext::new().with_user(..).with_remote_address(..).with_request_uuid(..), fut).

Configuration

All audit options are available via AuditOptions::builder():

Builder method Option Effect
.only(["a","b"]) only: Audit only these columns
.except(["password"]) except: Audit everything except these (plus the defaults)
.on([Action::Create, Action::Update]) on: Which actions produce audits
.comment_required(true) comment_required Require a comment (else the op errors / aborts)
.update_with_comment_only(false) update_with_comment_only A comment alone won't create an update audit
.max_audits(10) max_audits Cap retained audits; older ones are combined
.redacted(["password"]) redacted: Log that a column changed, but not its value
.redaction_value(json!("***")) redaction_value Custom redaction placeholder (default [REDACTED])
.encrypted(["ssn"]) (encrypted attrs) Mask as [FILTERED]
.associated_with("Company") associated_with: Record a parent record on each audit

Instance conditions map to trait methods you override: audit_if(&self) -> bool (if:) and audit_unless(&self) -> bool (unless:). The default-ignored columns (id, lock_version, created_at, updated_at, created_on, updated_on) and global settings are configurable via auditlog::config(|c| ...).

The shape of audited_changes is:

  • create{ "title": "Hello" } (single values, full filtered snapshot)
  • update{ "title": ["Hello", "Hello, world"] } ([old, new] pairs)
  • destroy{ "title": "Hello, world", ... } (single values, full snapshot)

Querying & scopes

let all      = Post::audits(&backend, 1).await?;                   // ascending by version
let updates  = Post::query(&backend, 1).updates().fetch().await?; // creates()/updates()/destroys()
let recent   = Post::query(&backend, 1).descending().limit(5).fetch().await?;
let since_v3 = Post::query(&backend, 1).from_version(3).fetch().await?;

Revisions

Reconstruct historical state by folding the change sets:

let all  = Post::revisions(&backend, 1).await?;          // one per audit
let v2   = Post::revision(&backend, 1, 2).await?;        // None if out of range
let prev = Post::revision_previous(&backend, 1).await?;  // second-most-recent

A revision returns the folded attribute map plus a new_record flag (true when the record was destroyed at that point — saving it would re-insert the row). Because the crate doesn't own your persistence, you apply the revision to your own model/ORM. Audit::undo_plan() similarly returns an UndoPlan (Delete / Recreate / Restore) describing how to reverse a change.

Enable / disable

Scope API
Process-global master switch auditlog::set_auditing_enabled(false)
Per type (persistent) Post::disable_auditing() / Post::enable_auditing()
Per scope (task-local, restored on exit) without_auditing(fut).await / with_auditing(fut).await

Effective auditing = global master and the type's flag and no active without_auditing scope and the instance if/unless checks. with_auditing cannot re-enable auditing when the global master switch is off.

Backends & migrations

use auditlog::SqlxBackend;

// convenience (single shared connection — required for :memory:)
let backend = SqlxBackend::connect_sqlite("sqlite://audits.db").await?;
backend.migrate().await?;        // creates the `audits` table + indexes if absent

// or bring your own pool:
// let backend = SqlxBackend::sqlite(my_pool);
// let backend = SqlxBackend::postgres(my_pg_pool);

Need a different store (another ORM, a queue, a remote service)? Implement the Backend trait — six methods — and everything else works unchanged. MemoryBackend is provided for tests.

Schema

The audits table schema: auditable_type/auditable_id, associated_type/associated_id, user_type/user_id/username, action, audited_changes (JSON), version, comment, remote_address, request_uuid, created_at, with the five indexes (auditable_index, associated_index, user_index, request-uuid, created-at) plus a unique index on (auditable_type, auditable_id, version) to guard version races. All polymorphic ids are stored as TEXT, so integer and UUID primary keys work uniformly.

Feature flags

  • sqlite (default)SqlxBackend on SQLite.
  • postgresSqlxBackend on Postgres.

The core (trait, change tracking, context, revisions, MemoryBackend) has no DB dependency.

Design notes

auditlog's behavior is defined in docs/SPEC.md, the authoritative behavior specification. A few intentional, idiomatic-Rust design choices:

  • You call the audit methods explicitly (audited_create/_update/_destroy) — there is no single ORM to hook, so audits are recorded by an explicit call rather than an automatic save callback. audited_update takes the prior state so the crate can diff it.
  • Current user/request context is a tokio task-local, which keeps it isolated per task instead of leaking across the pooled threads of an async runtime.
  • audited_changes is stored as JSON.
  • Enum representation is whatever your audited_attributes() returns — there is no global enum-introspection step, since the crate doesn't know your column types.
  • undo / revisions return a plan / attribute map for you to apply, rather than mutating your records directly.

Testing

cargo test                      # 14 unit + 37 behavior + 1 global-state + 7 doc tests (SQLite, no Docker)
cargo test --features pg-tests  # additionally runs tests/postgres.rs against a real Postgres container
cargo run --example blog

The tests/behavior.rs suite covers the full behavior surface (change-set shapes, versioning, on/only/except, comments, redaction, max_audits combine, user attribution, enable/disable, revisions, undo, associated audits).

tests/postgres.rs exercises the real Postgres backend end-to-end — transactional version assignment, $n-placeholder SQL, JSON audited_changes round-trip, RFC 3339 created_at ordering, the max_audits combine transaction, and associated audits — using testcontainers to spin up a throwaway Postgres container. It is gated behind the pg-tests feature, so the (heavy) Docker-client dependency is only compiled when you opt in; normal cargo test never pulls it. If Docker isn't running the test prints a SKIP notice and passes, so it is CI-matrix friendly.

License

Released under the MIT License. See LICENSE.