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.
[]
= { = "0.1", = ["sqlite"] } # or "postgres"
Quick start
use ;
use json;
// inside an async fn, returning auditlog::Result<()>:
let backend = connect_sqlite.await?;
backend.migrate.await?;
// create
let mut post = Post ;
post.audited_create.await?;
// update — diff the old state against the new
let old = Post ;
post.title = "Hello, world".into;
post.audited_update.await?;
// destroy
post.audited_destroy.await?;
for audit in audits.await?
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 ;
// a record user → user_id + user_type
as_user.await?;
// or a plain string → username
as_user.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 = audits.await?; // ascending by version
let updates = query.updates.fetch.await?; // creates()/updates()/destroys()
let recent = query.descending.limit.fetch.await?;
let since_v3 = query.from_version.fetch.await?;
Revisions
Reconstruct historical state by folding the change sets:
let all = revisions.await?; // one per audit
let v2 = revision.await?; // None if out of range
let prev = revision_previous.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 SqlxBackend;
// convenience (single shared connection — required for :memory:)
let backend = connect_sqlite.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) —SqlxBackendon SQLite.postgres—SqlxBackendon 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_updatetakes the prior state so the crate can diff it. - Current user/request context is a
tokiotask-local, which keeps it isolated per task instead of leaking across the pooled threads of an async runtime. audited_changesis 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
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.