Audit log
dotwire writes an audit event for every admin mutation and every audit read. Events are hash-chained and stored in an append-only Postgres table, so tampering is detectable. Reading the log is itself logged.
What gets recorded
Audit events carry ids only, never message content. That is what lets redaction erase a message without rewriting the audit trail.
| Event type | Written when | Fields set |
|---|---|---|
role.set.member, role.set.auditor, role.set.admin | An admin sets a user's role | actorId, subjectId (the user) |
role.deleted | An admin deletes a user's role | actorId, subjectId |
member.added | An admin adds a user to a room, one event per user | roomId, actorId, subjectId |
member.removed | An admin removes a user from a room | roomId, actorId, subjectId |
message.injected | An admin injects a message | roomId, messageSeq, actorId, subjectId (the asserted sender) |
message.redacted | An admin redacts a message | roomId, messageSeq, actorId |
retention.set | An admin sets the retention window | actorId, value (days) |
dsar.exported | An admin exports a user's data | actorId, subjectId |
audit.read | An auditor reads, verifies, or lists checkpoints | actorId |
Ordinary member sends don't produce audit events.
If dotwire can't publish the audit event, the call fails with 503 audit_unavailable. Nothing is left half-done:
- Role, membership, and retention changes are rolled back.
- A redaction or DSAR export doesn't start.
- An injected message is erased from both stores before it reaches any client, the same way a redaction erases it.
Each of these calls is safe to retry.
Who can read it
Contract (AUTH.md, "Authorization pipeline")
Audit is auditor-only. The
GET /audit/GET /audit/verifysurface checks for theauditorrole specifically —admindoes not get audit access by holding theadminrole alone.
The auditor role grants no room access. See Auth.
How the chain works
Audit events go to their own JetStream stream (AUDIT, subject audit.events), separate from room traffic. A single durable consumer, the audit writer, reads them in order and computes:
hash = SHA-256(prev_hash ‖ canonical_event)
- Each event is reduced to a fixed canonical form before hashing, with timestamps truncated to microseconds, so the same event always hashes to the same bytes.
- The first event chains from a fixed genesis hash, so it verifies the same way as every later event.
- The writer batch-inserts the results into the
audit_logtable.
Hash-chaining needs a strict total order, so exactly one audit writer runs at a time. dotwire enforces that with a Postgres session-level advisory lock. In a multi-node deployment, other Api or All nodes wait in standby and retry the lock every Nats:AuditWriter:StandbyRetrySeconds (5 by default). If the active writer's connection drops, the lock is released and a standby takes over.
Append-only is enforced in the database: the runtime role has no UPDATE or DELETE grants on the audit tables, and a guard trigger backs that up.
Checkpoints
Once per calendar day, the writer records the chain's tail id and hash in the append-only audit_checkpoints table. Verification can start from the latest checkpoint instead of from genesis.
Export checkpoint rows to cold storage on a regular schedule. An independently held checkpoint gives an auditor an anchor to verify against even if the live database is compromised.
Read and verify
Node.js (@dotwire/host)
const page = await host.readAuditLog(0, 100);
const verification = await host.verifyAuditLog(true);
const checkpoints = await host.getAuditCheckpoints();
.NET (Dotwire.Host)
var page = await client.ReadAuditLogAsync(afterId: 0, limit: 100);
var verification = await client.VerifyAuditLogAsync(full: true);
var checkpoints = await client.GetAuditCheckpointsAsync();
Both SDKs sign these calls as their configured auditor user, host-auditor by default.
Verification recomputes each link and compares it with the stored hashes. With full set to false (the default), it starts at the most recent checkpoint. With full set to true, it starts at genesis. A result looks like this:
{
"ok": true,
"fromId": 0,
"toId": 1284,
"checked": 1284,
"firstInvalidId": null,
"anchor": null
}
ok is false when any link fails, and firstInvalidId names the first entry that failed. anchor is the checkpoint day verification started from, or null for a full run or when there are no checkpoints yet.
Each call to read, verify, or list checkpoints writes an audit.read event.
Related
- HTTP API: audit
- Compliance: how the audit log maps to framework controls.