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.
enabledbooleanOptionalTurn 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.
falseSignal 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.)
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[]OptionalReason 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.
["No session token","Invalid session","Session expired","Session inactive timeout","Authentication secrets not defined"]minSeverity'info' | 'low' | 'medium' | 'high' | 'critical'OptionalMinimum 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.
"info"dedupobjectOptionalOpt-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.
enabledbooleanOptionalTurn on failure de-duplication.
falsewindowSecondsnumberOptionalDedup window in seconds.
60Under 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 rowOptionalEach 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 diffOptionalWrites 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 setOptionalThe 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-onlyOptionalA 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