Audit

Immutable trail of every data operation

The audit block records who did what, to which record, and when. When enabled, a database audit transport is attached to the logger and writes structured rows to the audit_logs table — capturing the entity name, entity id, acting user, operation and outcome.

Because it hangs off the central logger, audit coverage spans both generated entity routes (create/update/delete) and the auth routes (login, logout, password change, session revocation), giving you one coherent security timeline.

You control signal-to-noise with suppressReasons (drop routine credential-absence rows that logged-out clients generate), minSeverity (persist only events at or above a severity), and dedup (collapse repeated identical failures into one counted row) — keeping the trail high-signal without ever losing a genuine security event.

Enabling audit#

A single switch attaches the database audit transport. Without it, audit entries are not persisted.

enabledbooleanOptional

Turn on persistent audit logging. On startup the framework wires a DatabaseAuditTransport to the audit_logs table; every audited operation then writes a row with entity_name, entity_id, user_id, action and timestamp.

Defaultfalse

Signal control#

The auth middleware writes an audit row on every rejected request, so logged-out clients polling a protected endpoint (e.g. /auth/me) would otherwise flood audit_logs with 'No session token' rows. These three levers keep the trail high-signal. (The old per-operation actions map was removed in 0.9.600 — it was declared but never consumed.)

config.nucleus.json — audit
1{2  "audit": {3    "enabled": true,4    "suppressReasons": ["No session token", "Session expired"],5    "minSeverity": "info",6    "dedup": { "enabled": true, "windowSeconds": 60 }7  }8}
suppressReasonsstring[]Optional

Reason summaries that are NOT persisted to audit_logs (still emitted at debug level). Defaults to the routine credential-absence/expiry reasons that flood the table when logged-out clients poll protected endpoints — they carry no actor and no attack signal. Set to [] to capture everything (legacy). Genuine failures (Session revoked, tenant-binding mismatch, invalid API key, login failures, all data changes) are never in the default set and always persist.

Default["No session token","Invalid session","Session expired","Session inactive timeout","Authentication secrets not defined"]
minSeverity'info' | 'low' | 'medium' | 'high' | 'critical'Optional

Minimum severity that gets persisted; rows below it are dropped. Default info persists everything. Raise to medium for a security-only log that excludes routine reads and successful logins.

Default"info"
dedupobjectOptional

Opt-in: collapse repeated identical auth, authz, and security-anomaly failures (same IP, HTTP operation, reason summary, and acting user — a four-part slot key) within a window into ONE row whose occurrence_count grows, instead of one row per attempt. Two otherwise-identical failures that hit different operations occupy different slots and are NOT collapsed. Off by default — protects against a misbehaving client or brute-force storm bloating the table.

enabledbooleanOptional

Turn on failure de-duplication.

Defaultfalse
windowSecondsnumberOptional

Dedup window in seconds.

Default60

Under the hood — what a row captures#

Audit isn't a separate subsystem — it's the logger's audit path. logger.audit() (and trace() when auditEnabled) builds an entry and the DatabaseAuditTransport writes it, so audit and logs share one component and one correlation id.

captured columnsaudit_logs rowOptional

Each row records id, entity_name, entity_id, operation_type, user_id, ip_address, user_agent, a human summary, severity + category (auth | authz | data_change | admin | config | security_anomaly | system), occurrence_count + last_occurrence_at (for deduped rows), the before/after old_values and new_values, plus created_at, the request path and query — a complete forensic record, not just 'who and when'. old_values/new_values are passed through logger.audit()'s redaction before write (redactSensitiveData with the logger's configured redactKeys), so matching keys are stored as [REDACTED]. That key set is configurable, not a fixed built-in list — see 'redacted diff keys' below.

old / new valuesstructured diffOptional

Writes capture the prior and resulting field values, so an audit row is also a diff: you can see exactly what changed, which is what makes the trail useful for compliance reviews and incident forensics.

redacted diff keysconfigurable setOptional

The old_values/new_values diff is redacted against the logger's redactKeys, assembled once at boot — it is NOT a fixed built-in secret list. The set is the union of the built-in secret keys (password hashes, reset/refresh/verification tokens, secrets and keys), config.logging.redactKeys, AND every config.entities column marked sensitive:true. Sensitive column names are registered in both spellings (snake_case as declared plus camelCase, since drizzle rows come back camelCase) and pushed into the logger via configure({ redactKeys }). So marking a PII/secret column sensitive:true guarantees it is stored as [REDACTED] in the durable audit_logs diff — not merely hidden from API list/detail responses. (Earlier releases omitted redactKeys on the two audit calls and fell back to the built-in list, leaving such columns in cleartext in audit_logs while the log line was redacted; that is fixed.)

correlationlog-onlyOptional

A correlationId is attached to the in-memory log entry (and shown in console output), but it is NOT persisted on the audit_logs row — the DB audit transport does not write it. So a stored audit row cannot be cross-referenced to log lines by correlation id; pivot instead on the columns it does persist (user_id, entity_id, path, query, created_at).

Related sections