Skip to content

Repository files navigation

@jantstack/adonis-audit

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:run

The idea in one line

auditable ⊆ 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.


Architecture: one store + N sinks

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.

Two asymmetries made explicit in the contract

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.


Usage

The mixin

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',
  }],
})

Manual events

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 })

config/audit.ts

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.


Writing your own store

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.


The trail is append-only

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.

Compatibility

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.

License

MIT

About

Audit trail engine for AdonisJS 7 + Lucid: one transactional store plus fan-out sinks, a Lucid CRUD mixin, and a safe-by-default snapshot where auditable ⊆ serializable.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages