# 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.
```toml
[dependencies]
auditlog = { version = "0.1", features = ["sqlite"] } # or "postgres"
```
## Quick start
```rust
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`](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:
```rust
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()`:
| `.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
```rust
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:
```rust
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
| 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
```rust
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.
- `postgres` — `SqlxBackend` 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`](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
```sh
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`](https://crates.io/crates/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](LICENSE).