Audit trail engine for AdonisJS 7 + Lucid: one transactional store, N best-effort sinks, a Lucid CRUD mixin, polymorphic actors and entities, and a safe-by-default snapshot where what you hide from your API never reaches the trail.
npm i @jantstack/adonis-audit
node ace configure @jantstack/adonis-audit
node ace migration:runauditable ⊆ serializable. A field marked @column({ serializeAs: null }) — Lucid's equivalent of
Eloquent's $hidden — stays out of your API and out of the audit log. One declaration, made where
you were already thinking about what is sensitive, with two effects.
That sounds like a detail and isn't. The naive audit mixin reads model.$attributes, which is the
copy headed towards the database — one layer below where serializeAs acts. The result is a model
protected in its JSON and unprotected in its trail, which is exactly where the data lives longest and
where the most people can read it. And the worst case isn't creating, it's updating: a password
change records both the old hash and the new one, permanently, surviving even the deletion of the
account.
This package applies the same boundary to both paths, and adds a second net by field name
(password, *_token, *_secret, *_hash…) that applies even on top of an explicit toLog() —
because the realistic failure isn't forgetting toLog(), it's adding a refresh_token column six
months later and not remembering.
That second net walks deep, and this matters more than it appears: the real hole isn't in the first
layer. A json column named settings is legitimately serializable — the field itself isn't a secret,
so nobody marks it serializeAs: null — and its name matches no rule. Without walking, a
settings.api_key slips through both nets.
The walk has three ceilings — depth 12, cycles, and a 10,000-node budget — and all three fail
closed: whatever cannot be inspected is replaced by [audit: not inspected] rather than emitted
as-is. Giving up by returning the value would mean failing open exactly where someone would hide
something, and the marker records the truncation instead of silently dropping it.
What it does not cover, stated plainly: redaction is by field name. A secret interpolated into
free text — an event's description, a transition's describe() — passes through untouched. You
write those fields; treat them as public output.
These are not interchangeable drivers. Auditing is an append-only stream and in practice you want several destinations at once: the table to query, a SIEM for compliance, stdout for the collector.
audit.log(...)
├─→ store exactly one · the queryable one · honours your transaction
└─→ sinks[] zero or more · best-effort · never receive a trx
The two levels have different criticality, and that is the whole architecture:
| on failure | |
|---|---|
| store | the error propagates. Inside a transaction it rolls the business back with it: an operation whose trail could not be written should not be taken as done. |
| sink | logged, and life goes on. A downed log collector must not take down a login. |
Blurring the two levels is how trails get lost without anyone noticing.
Transactionality. AuditStore.supportsTransactions is part of the contract. Handing a trx to a
store that doesn't support it throws instead of silently discarding it — returning an "ok" without
delivering the atomicity that was asked for is the most damaging way to fail at auditing, because the
trail appears to exist.
Reading. Every store can append; not every store can query. That's why AuditReader is a
separate, optional capability, and the provider warns at boot if your store cannot read — not on
the first request to your history endpoint.
import { compose } from '@adonisjs/core/helpers'
import { auditable } from '@jantstack/adonis-audit'
export default class Invoice extends compose(BaseModel, auditable({ eventPrefix: 'invoice' })) {
@column() declare total: number
// Not in the API ⇒ not in the trail. No further ceremony.
@column({ serializeAs: null }) declare internalMargin: number
}Emits invoice.created, invoice.updated (only the fields that changed) and invoice.deleted. If
the model is inside a transaction, the trail takes part in it.
Transitions, so you don't end up with anonymous diffs:
auditable({
eventPrefix: 'invoice',
transitions: [{
when: (m) => m.$original.status === 'draft' && m.status === 'issued',
event: 'invoice.issued',
}],
})import audit from '@jantstack/adonis-audit/services/main'
await audit.log('org.archived', organization, {
previous: { archived: false },
trx, // takes part in your transaction
})
// An explicit `actor: null` ≠ omitting it. A failed login has no actor, and that IS the data.
await audit.log('auth.login_failed', null, { actor: null, description: email })import { AuditContext, defineAuditConfig, DatabaseAuditStore } from '@jantstack/adonis-audit'
export default defineAuditConfig({
store: new DatabaseAuditStore(),
sinks: [new StdoutAuditSink()],
redact: ['salaryBand', /^internal_/],
resolveActor: () => {
const ctx: any = AuditContext.current()?.ctx
if (!ctx) return null // jobs, ace commands, seeders
const user = ctx.auth?.user
if (!user) return null
const guard = ctx.auth?.authenticatedViaGuard
return { type: guard === 'admins' ? 'admins' : 'users', uuid: user.uuid }
},
})resolveActor is yours to supply because the guards are yours: the package has no idea whether you
have users, admins, integrations or something else entirely.
Use AuditContext.current() and not AdonisJS's HttpContext.get(). The latter depends on
useAsyncLocalStorage, which ships disabled — and without it, it returns null with no warning, so
every event would end up with no actor and nothing would tell you. The package's own context is
populated by AuditContextMiddleware, which configure registers in the router stack for you.
Implement AuditStore and, if it can query, AuditReader too. Then judge it with the same judge
as the ones that ship with the package:
import { runAuditStoreContract } from '@jantstack/adonis-audit/testing'
test.group('my ClickHouse store', () => {
runAuditStoreContract({
test,
makeStore: () => new ClickHouseAuditStore(),
reset: async () => { /* ... */ },
})
})The suite is deliberately asymmetric: the read cases skip themselves if your store doesn't implement
AuditReader, and that skip is announced — "didn't fail" must not be mistaken for "complies".
Included: DatabaseAuditStore (transactional and queryable) and NullAuditStore. The second is not
filler: the contract suite passing against it is what proves the contract is real, and not the
database implementation wearing a different name.
ActivityLog rejects save() and delete() on an existing row: an audit trail you can rewrite
proves nothing.
That is resistance inside the application, not a guarantee. The query builder doesn't fire model
hooks, so ActivityLog.query().delete() still works — deliberately, since that's how you purge and
how the tests clean up. The real guarantee is set in the database:
REVOKE UPDATE, DELETE ON activity_logs FROM my_application_role;If you need to void an event, record a new one that compensates for it. That is the correct move in an append-only ledger, and it leaves a record of the voiding too.
Node ≥ 20.6 · AdonisJS ^7 · Lucid ^22 · PostgreSQL, MySQL and SQLite.
The current/previous columns are serialized by hand because the engines disagree: Postgres returns
json already parsed, SQLite and MySQL return the string. Without that the package would "work" on
Postgres and hand you strings on the other two.
MIT