-
Notifications
You must be signed in to change notification settings - Fork 1
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.
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.
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"]
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.
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.
canonicalJson in packages/database/src/audit.ts produces a byte-stable serialisation:
- Object keys are sorted lexicographically at every depth.
- No insignificant whitespace.
-
undefinedmembers are dropped, so an absent field and an explicitly undefined field hash identically. - Non-finite numbers throw a
TypeErrorrather than serialising to something lossy.
Two field-specific rules matter:
-
occurredAtis hashed as its ISO 8601 string. -
seqis 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.
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.
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.
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.
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.
- Never write an
AuditEventrow 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
AuditEventthat should be attested, add it toAUDIT_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.
openrunic is an open-source operating system for human health. Pre-alpha: do not run it in production, and never put real patient data into it.
Repository · Licence (AGPL-3.0-only) · Security policy · Contributing · Code of conduct
Where this wiki and the repository disagree, the repository is right.