Docs menu

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 typeWritten whenFields set
role.set.member, role.set.auditor, role.set.adminAn admin sets a user's roleactorId, subjectId (the user)
role.deletedAn admin deletes a user's roleactorId, subjectId
member.addedAn admin adds a user to a room, one event per userroomId, actorId, subjectId
member.removedAn admin removes a user from a roomroomId, actorId, subjectId
message.injectedAn admin injects a messageroomId, messageSeq, actorId, subjectId (the asserted sender)
message.redactedAn admin redacts a messageroomId, messageSeq, actorId
retention.setAn admin sets the retention windowactorId, value (days)
dsar.exportedAn admin exports a user's dataactorId, subjectId
audit.readAn auditor reads, verifies, or lists checkpointsactorId

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/verify surface checks for the auditor role specifically — admin does not get audit access by holding the admin role 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_log table.

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