Skip to content

Audit and Hash Chain

Ankit Upadhyay edited this page Aug 13, 2026 · 1 revision

Audit and hash chain

This page describes openrunic's audit stream: what is recorded, how each record is cryptographically linked to the one before it, and how to verify the chain. It is for anyone writing a route that touches patient data, and for anyone investigating an access question after the fact.

Audit is not a feature bolted on later. AuditEvent was the first model in the schema and the first migration in the repository.

The AuditEvent model

Defined in packages/database/prisma/schema.prisma. The fields, in groups:

Group Fields
Identity id, tenantId, seq (BigInt, monotonic per tenant, starting at 1)
When occurredAt, createdAt, updatedAt
Who actorType, actorId, actorDisplay
What action, targetType, targetId, outcome (defaults to success)
Clinical context patientId, encounterId, facilityId
Why purposeOfUse, breakglass
Where from sourceIp, userAgent
Detail metadata (JSON)
Chain prevHash, hash

actorType is a string rather than an enum, so a plugin can introduce an actor kind without a migration. The values in use are user, patient, system, service, and adapter.

purposeOfUse carries the HL7 purpose-of-use code, for example TREAT for treatment, HPAYMT for payment, and HOPERAT for healthcare operations. breakglass marks an emergency access that bypassed a normal restriction; it has its own index so those events can be reviewed as a set.

Two unique constraints do the structural work:

  • @@unique([tenantId, seq]) makes a duplicate sequence number impossible, so the chain cannot fork.
  • @@unique([tenantId, hash]) makes a replayed event impossible.

A gap in seq means events were lost. That is detectable precisely because the sequence is dense by construction.

Rows are never updated and never deleted. updatedAt exists only because every model has it, and a differing updatedAt is itself evidence of tampering, since nothing in the application ever writes an audit row twice.

The chain

Each event's hash covers the event's own content and the previous event's hash. Changing any historical event invalidates every hash after it.

flowchart LR
    G["genesis<br/>64 zeros"] --> E1["seq 1<br/>prevHash = genesis<br/>hash = H1"]
    E1 --> E2["seq 2<br/>prevHash = H1<br/>hash = H2"]
    E2 --> E3["seq 3<br/>prevHash = H2<br/>hash = H3"]
    E3 --> E4["seq 4<br/>prevHash = H3<br/>hash = H4"]
Loading

The algorithm is SHA-256, hex-encoded. The formula is:

hash = sha256Hex(prevHash + "\n" + canonicalJson(chainedPayload))

The newline separator is deliberate. Without it, the concatenation could be re-split at a different offset, so two different pairs of previous hash and payload could produce the same input string.

For the first event in a tenant's chain, prevHash is AUDIT_GENESIS_HASH, which is 64 zero characters.

What is hashed

Only the 18 fields listed in AUDIT_CHAINED_FIELDS:

action, actorDisplay, actorId, actorType, breakglass, encounterId,
facilityId, metadata, occurredAt, outcome, patientId, purposeOfUse,
seq, sourceIp, targetId, targetType, tenantId, userAgent

Four fields are excluded. id, prevHash, and hash are not part of the content being attested. createdAt and updatedAt are excluded on purpose, so a row rewritten by a backup restore with a different write timestamp still verifies. That is the difference between a chain that survives disaster recovery and one that reports a false tamper alert the first time it is restored.

Canonicalisation

canonicalJson in packages/database/src/audit.ts produces a byte-stable serialisation:

  • Object keys are sorted lexicographically at every depth.
  • No insignificant whitespace.
  • undefined members are dropped, so an absent field and an explicitly undefined field hash identically.
  • Non-finite numbers throw a TypeError rather than serialising to something lossy.

Two field-specific rules matter:

  • occurredAt is hashed as its ISO 8601 string.
  • seq is hashed as its decimal string, because BigInt is not JSON and converting it to a Number would silently break the chain past 2^53.

Nullable fields normalise to null, breakglass defaults to false, and outcome defaults to success, so an event built two different ways with the same meaning hashes the same.

Writing an event

linkAuditEvent(event, tail) computes the next link. It returns { seq, prevHash, hash }, where seq is the tail's sequence plus one, or 1 when the tail is null.

The caller must read the tail and insert the new row in the same transaction. The unique constraint on [tenantId, seq] is the backstop that turns a lost race into a failed insert rather than a forked chain.

The runtime write path is createPrismaAuditSink in apps/api/src/audit/prisma-sink.ts. It reads the tail with findFirst({ orderBy: { seq: 'desc' }, select: { seq: true, hash: true } }), links the event, and creates the row, with both halves running on the caller's transaction handle.

recordWrite throws a TypeError when it does not recognise the unit of work, rather than falling back to a standalone client. Falling back would write the audit row outside the mutation's transaction, which is the exact failure the sink exists to prevent: a mutation that rolls back while its audit record commits, or the reverse.

recordReadBatch is the one path that legitimately stands alone. Reads are batched by the request-scoped collector and flushed after the response, in a finally. A flush failure is reported through an error callback and never rethrown, because failing a successful read because its audit write failed would be worse than the alternative, and the failure is still surfaced.

Verifying the chain

verifyAuditChain(events, tail = null) verifies a contiguous slice, oldest first. It returns either:

{ valid: true, checked, tail }

or

{ valid: false, checked, brokenAtSeq, reason }

where reason is one of tenant-mismatch, seq-not-contiguous, prev-hash-mismatch, or hash-mismatch.

The intended usage, from the function's own documentation, is to run it nightly over the whole chain, and on demand over a window when exporting for an investigation. Passing a tail verifies a mid-chain window without replaying the entire history.

There is no seal or notarise function. The chain is self-verifying; it does not depend on an external timestamping service.

Reading the audit trail

The web app has an audit viewer at /admin/audit. It deliberately has no edit control of any kind. It filters by action, actor, purpose of use, and date, exports to CSV, and its detail drawer renders the hash chain so that tamper evidence is visible to a human rather than only to a verification script.

What gets audited

Reads and writes of patient data both produce events, collected per request. Authorization denials are audited too: requirePermission writes an event with action authorisation.denied, target type Route, the request path as the target, and the requested permission and the principal's roles in the metadata, before returning 403.

The cross-tenant test suite asserts not just that a cross-tenant access is denied, but that the denial is recorded. An isolation breach nobody can see afterwards is not meaningfully prevented.

Rules for contributors

  • Never write an AuditEvent row directly. Go through the sink, so the tail read and the insert share a transaction.
  • Never update or delete an audit row, in application code or in a migration.
  • Never put free text that could contain patient identifiers into metadata. The project's position on this is recorded in ADR-0004: free-text identifiers in prose cannot be caught by deterministic redaction, so the mitigation is not to log free text in the first place.
  • If you add a field to AuditEvent that should be attested, add it to AUDIT_CHAINED_FIELDS. Adding a field without adding it there means the field is not covered by the hash, which is sometimes correct and always worth stating in a comment.

Related pages

Clone this wiki locally