Skip to main content
zerotal

Audit

@zerotal/audit provides automatic, zero-boilerplate audit logging for Zerotal models and manual events. Every create, update, and delete is captured — with old and new values, the authenticated actor, and request metadata — and stored in a queryable audit_logs table.

Getting Started

@zerotal/audit is a workspace package, so there is no separate install step:

# in your project root
bun add @zerotal/audit

Register the provider

Add AuditProvider to the providers array in bootstrap/providers.ts, after DatabaseProvider (the audit table lives in your database):

// bootstrap/providers.ts
import { AuditProvider } from "@zerotal/audit";

export default [
  DatabaseProvider,
  SessionProvider,
  AuthProvider,
  AuditProvider, // ← add after DatabaseProvider
  // ...
];

Registering the provider switches on the following:

  • onRegister — binds the Auditor service as the "audit" singleton and registers the schema concern that provisions the audit_logs table.
  • onBooted — drains the pending Auditable registry, wiring each composed model to the live Auditor once the container is ready.

Note — No migration is required. A boot-time schema concern creates the audit_logs table automatically (idempotently, and skipped for the null driver). Register the provider and the table appears on first boot.

Configuration

Create config/audit.ts with the AuditConfig() helper so every field stays type-checked. The file is auto-discovered — its settings are namespaced under audit in the config tree:

// config/audit.ts
import { AuditConfig } from "@zerotal/audit";

export default AuditConfig({
  driver: "database", // 'database' | 'null'
  table: "audit_logs",
  pruneKeep: 0, // 0 = unlimited; cap records kept per model instance
  captureRequest: true, // attach IP, user-agent, URL automatically
});
FieldRequiredDefaultDescription
driveryes"database"Storage backend — "database" or "null".
tableno"audit_logs"Table name used by the database driver.
pruneKeepno0Max audit records kept per model instance. 0 = unlimited.
captureRequestnotrueAttach IP, user-agent, and URL from the active request automatically.

Auditing models

The Auditable mixin

Compose Auditable into any Model subclass. The audit system hooks into the ORM lifecycle and records created, updated, and deleted events automatically. Like every Zerotal mixin it takes only (Base) — configure it with overridable static fields on the model.

// app/models/User.ts
import { Auditable } from "@zerotal/audit";
import { Model, column, table } from "@zerotal/orm";

@table("users")
export class User extends Model.using(Auditable) {
  protected static auditExcept = ["password", "rememberToken"];

  @column({ type: "string" }) name: string;
  @column({ type: "string" }) email: string;
  @column({ type: "string" }) password?: string;
  @column({ type: "string" }) rememberToken?: string;
}

Auditable composes with the auth mixins as the outermost wrapper:

// app/models/User.ts
export class User extends Auditable(WithRoles(WithPermissions(AuthUser))) {
  protected static auditExcept = ["password"];
}

Danger — Never audit columns containing credentials. Exclude password, rememberToken, API tokens, etc. via auditExcept (shown above).

Configure auditing with overridable static fields, read per event from the concrete model:

Static fieldTypeDescription
auditOnlystring[]Allowlist — only these columns appear in old_values / new_values.
auditExceptstring[]Denylist — exclude these columns (applied when auditOnly is not set).
auditTypestringOverride the auditable_type string (defaults to the class name).

The mixin also adds two instance methods, auditLog() and auditLogs(), covered in Manual audit events and Querying audit logs.

Programmatic registration

For a model you'd rather not wrap in the mixin, register it from a provider's onBooted() (after the container has resolved the "audit" binding). Configure it with the same static fields:

// in a ServiceProvider.onBooted()
import { registerAudit } from "@zerotal/audit";

registerAudit(User);

Which should I use? Reach for the Auditable mixin by default — it also gives you the auditLog() / auditLogs() helpers. Use registerAudit() only when you cannot change a model's class hierarchy (e.g. a model from another package).

Both routes attach the same AuditObserver, which is what actually watches the model's lifecycle. An update is recorded in two phases, and the reason is worth knowing if you ever hook the same events yourself: the previous values only exist until the ORM refreshes its snapshot, so the observer captures them during saving and pairs them with the current values in updated. That is why an audit row shows a real before and after rather than the same values twice.

AuditObserver is exported for the rare case of composing it into an observer of your own; using the mixin or registerAudit() is the supported path.

Manual audit events

Log any custom event — logins, exports, settings changes — via the Audit facade. Pass the model instance the event concerns; auditable_type and auditable_id are derived from it, so logs are always linked to a record:

function log(event: AuditEvent, model: Model, payload?: InstanceAuditPayload): Promise<void>;
function log(event: AuditEvent, payload: Omit<AuditPayload, "event">): Promise<void>;
// in a controller or service (within a request context)
import { Audit } from "@zerotal/audit";

await Audit.log("login.success", user, {
  tags: { method: "github_oauth" },
});

await Audit.log("report.exported", report, {
  tags: { format: "csv", rows: 5000 },
});

For an event not tied to a model, pass a raw payload with auditable_type. Outside a request (queue job, CLI command) supply the actor explicitly:

// in a queue job or CLI command
await Audit.log("subscription.renewed", {
  auditable_type: "Subscription",
  auditable_id: sub.id,
  actor_type: "user",
  actor_id: sub.userId,
});

When a model is Auditable, the same call is available as an instance method:

// in a controller or service
await user.auditLog("login.success", { tags: { method: "github_oauth" } });

The facade reads the authenticated user and request details (IP, user-agent, URL) automatically from the active request context. You only need to supply them when operating outside a request.

Warning — Audit failures never crash the application; a failed write is logged to the console and swallowed. Treat the audit log as best-effort, not as a transactional guarantee.

Querying audit logs

AuditLog is a full Model with scopes and chainable queries:

// in a controller or service
import { AuditLog } from "@zerotal/audit";

// Last 25 events for a specific model instance
const history = await AuditLog.query()
  .where("auditable_type", "User")
  .where("auditable_id", String(user.id))
  .orderBy("id", "desc")
  .limit(25)
  .get();

// Built-in scopes return a chainable query builder
const userHistory = await AuditLog.forModel("User", user.id).get();
const actorLog = await AuditLog.byActor(user.id).get();
const loginEvents = await AuditLog.ofEvent("login.success").get();

// Paginate
const page = await AuditLog.query()
  .where("actor_id", user.id)
  .orderBy("id", "desc")
  .paginate(20, 1);

The Audit facade exposes the same queries plus a convenience read:

// in a controller or service
const logs = await Audit.logs(User, user.id).orderBy("id", "desc").limit(25).get();
const byActor = await Audit.logsByActor(user.id).get();
const events = await Audit.logsOfEvent("login.success").get();

// Eager array of recent records for a model
const recent = await Audit.historyFor("User", user.id, 25);

An Auditable model also offers an instance shortcut:

// in a controller or service
const logs = await user.auditLogs().orderBy("id", "desc").limit(25).get();

Inspecting a record

// in a controller or service
const entry = history[0];

entry.event; // "updated"
entry.auditableType; // "User"
entry.auditableId; // "42"
entry.actorId; // 7
entry.oldValues; // { email: "old@example.com" }
entry.newValues; // { email: "new@example.com" }
entry.ipAddress; // "203.0.113.1"
entry.url; // "http://localhost:3000/profile"

entry.changedKeys; // ["email"]

What gets recorded

Eventold_valuesnew_values
created(empty)full snapshot
updatedchanged fields (before)changed fields (after)
deletedfull snapshot(empty)
customwhatever you passwhatever you pass

For updated, only the diff is stored — unchanged fields are not included. Snapshots use the model's toJSON(), so columns marked hidden are excluded automatically.

Drivers

DriverClassDescription
databaseDatabaseDriverStores to audit_logs using @zerotal/orm. Default.
nullNullDriverDiscards all entries. Useful in tests.

Both classes are exported, so a custom driver can wrap one rather than reimplementing it — decorating DatabaseDriver to also ship entries to a SIEM, for instance.

Switch the driver in config:

// config/audit.ts
import { AuditConfig } from "@zerotal/audit";

export default AuditConfig({ driver: "null" }); // suppress auditing

Or swap it entirely in a ServiceProvider by binding a custom AuditDriver implementation to the "audit" singleton.

Testing

In tests, swap to the NullDriver so no database is needed. Bind a fresh Auditor over the "audit" key:

// tests/setup.ts
import { Application } from "zerotal";
import { Auditor, NullDriver } from "@zerotal/audit";

// Bind a fresh NullDriver so tests don't write to a DB
app.container.singleton(
  "audit",
  () => new Auditor(new NullDriver(), { driver: "null", captureRequest: false }),
);

Security notes

Danger — Never audit columns containing credentials. Exclude password, rememberToken, API tokens, etc. via auditExcept.

Warningaudit_logs is append-only by design. The AuditLog model disables timestamps; do not add updated_at or allow row updates.

Note — Grant read access to audit_logs only to admin roles.

References

Audit facade — resolves the Auditor bound at "audit":

MethodSignatureDescription
log(event, model, payload?) => Promise<void> · (event, payload) => Promise<void>Record a manual event from a model instance or payload.
historyFor(type: string, id: string | number, limit?: number) => Promise<AuditRecord[]>Eager array of recent records for a model.
logs(model: AuditableRef, id: string | number) => ModelQueryBuilder<AuditLog>Chainable query of one model's audit history.
logsByActor(actorId: number) => ModelQueryBuilder<AuditLog>Chainable query of every log by an actor.
logsOfEvent(event: AuditEvent) => ModelQueryBuilder<AuditLog>Chainable query of every log for an event name.

AuditLog model — scopes and helpers:

MemberSignatureDescription
forModel(type: string, id: string | number) => ModelQueryBuilder<AuditLog>Scope to one model instance's history.
byActor(actorId: number) => ModelQueryBuilder<AuditLog>Scope to a single actor.
ofEvent(event: AuditEvent) => ModelQueryBuilder<AuditLog>Scope to a single event name.
changedKeysstring[]Getter — keys present in newValues.

Auditable(Base) instance methods:

MethodSignatureDescription
auditLog(event: AuditEvent, payload?: InstanceAuditPayload) => Promise<void>Log a custom event against this instance.
auditLogs() => ModelQueryBuilder<AuditLog>Chainable query of this instance's history.

Next steps

  • ORM Lifecycle — the model hooks the audit system listens to.
  • Authentication — how the actor on each entry is resolved.
  • Authorization — gate read access to audit logs by role.
  • Testing — swap in the null driver for isolated tests.