ADR-0044: Security events go to an append-only, hash-chained table
Status: Accepted · Date: 2026-08-19 · Amended: 2026-08-29 to 2026-09-02 (D6, D7, D8), 2026-09-04 to 2026-10-03 (ADR-0047, 0049, 0050, 0056 to 0058, 0062), 2026-10-13, 2026-10-14, 2026-10-16
Context
Seventeen security events are logged by the API, counted by log site, and eight by the BFF. Before this decision that was all: a log rotates, whoever can reach the host can write to it, and nothing about it is evidence. PCI DSS v4 10.3.2 and NIST SP 800-53 AU-9 ask that audit logs be protected from modification, and PSD2 Art. 72 that an institution can reconstruct what happened.
Decision
- D1 — a failed audit write fails the business action.
IAuditService.Recordonly callsAddand the row rides the caller'sSaveChangesAsync, so both land or neither: better a blocked bank than an emptied one, because a movement never recorded cannot be accounted for to the customer whose money moved (WhenTheAuditRowCannotBeWritten_TheBusinessChangeIsRolledBackToo). A refusal usesRecordRefusalAsync, on its own scope and committed at once, because the rollback of what was refused would take its record along; each method throws on the other's outcomes. D1 has no exception: the tripwire of ADR-0057 revokes nothing, so its unwritable row fails the request loudly (WhenTheTripwiresAuditRowCannotBeWritten_TheAnswerIs500_AndTheOtherSessionStaysActive). - D2 — the chain now, the SQL Server ledger later. Each
AuditEventsrow carries an HMAC-SHA256 over its fields,Sequenceincluded, and the previous row's hash: keyed, because every field is enumerable and anyone holding the table could recompute a bare digest. Its payload also holds the version that rendered it and, fromv3, an identity of its key (not a widening: everyRowHashis the same oracle for a guessed key), so a new field or a rotated key invalidates nothing already written; a row that cannot be rendered, or names a key the verifier lacks, is a break. The operator toolAzureBank.AuditVerifierwalks the chain (verify) and records its tail as an anchor, in a second chain underAudit:AnchorKey(anchor). The ledger would refuse a rewrite and an RFC 3161 timestamp would date the tail from outside: neither replaces the other and both are deferred, not rejected, becauseAPPEND_ONLYis a one-way door on a moving schema and an anchor is only as fresh as its unattended job (docs/deferred/anchoring-the-audit-trail.md). - D3 — the chain is applied in the
SaveChangesfunnel.AzureBankDbContextreads the tail underUPDLOCK, HOLDLOCK, assignsSequenceand the hashes and inserts in one transaction, because the writer cannot hold a lock across the insert and aSaveChangesInterceptorwould be dropped in silence under test, whereCustomWebApplicationFactoryrebuilds the registration. - D4 — the eight BFF events stay log-only.
RateLimitExceeded,CrossSiteRequestBlocked,StepUpRequired,StepUpWithoutSession,RawRefreshBlocked,RawAuthEntryBlocked,SessionRequiredandRefreshRejectedare raised in the BFF, which has no database, and giving it one means a second writer againstAuditEvents: a larger decision than this one. - D5 — no personal data in the table. Actor and subject are ids, and
Detailis JSON capped at 1024 characters, so that no stack trace, with the personal data inside it, reaches a table that is never purged. A movement's row carries no amount, counterparty or account, because the ledger row itsSubjectIdreaches holds them and an amount beside an actor id is financial data about an identifiable person: a deposit'sDetailis null, the others name only the authorisation they consumed (AuditDetails), and a refusal's is theErrorCodesconstant and nothing else. - D6 — retention is a policy, and this table is never purged. The period is five years, designed for ten, after the business relationship ends (AMLR, Regulation (EU) 2024/1624, Art. 77; PSD2 Art. 21; AMLD Art. 40 for ten); none binds a system that is no obliged entity or payment institution. No purge is safe: an empty table reads as new, one without its oldest rows as tampered (
DeletingTheOLDESTRows_IsLOUD_WhichIsWhyRetentionCannotPurgeThisTable). Erasure is not discharged: three redesigns would do it (a chain of droppable periods, per-subject encryption, making ledger rows deletable), none proposed; only a demo copy's ledger is deleted (ADR-0062). - D7 — a key ring, so a rotation stops destroying the history it protects.
Audit:RetiredChainKeysholds each retired key with theLastSequenceit stopped at, and verification selects the key a row names: trying keys in turn would accept a row a retired key mints at any sequence (ARowThatLIESAboutItsKeyIsCaught_WhichIsWhyTheRingSELECTSRatherThanTRIES). A retired key only reads, and only inside its epoch, which starts after the previous retirement (TheNEWESTRetiredKeyCannotREAUTHORWhatOlderKeysWrote_BecauseAnEpochHasTwoEnds). The founding key is named, never assumed: "whatever adds a second key must add a ring entry for the FOUNDING key rather than silently re-point history at whatever is current", soAudit:FoundingChainKeynames the entry that answers for rows with no identity.AuditKeyRingStartupCheckbuilds the ring at startup; an anchor names its tail's key.Audit:AnchorKeyhas no ring: a rotation ends its chain. - D8 — the evidence pack reads by transaction, and says which half the chain vouches for. The verifier's
evidence <transactionNumber>assembles what PSD2 Art. 72 asks for one movement: its ledger row, the consumedStepUpAuthorization, its audit rows and the chain verdict. The success row names that authorisation under its hash, and the pack checks the unchained table against it (ATransferWhoseAuthorisationRowIsGone_IsBOUNDAUTHORISATIONMISSING_AndTheChainStaysIntact). - What is wired, and what is not. Sixteen events write a row, at nine
_audit.Record(and eight_audit.RecordRefusalAsync(sites. Seven are administrative:AccountDeleted,AccountNumberRevealed,AzureTagRenamed,PinEnrolled,RefreshTokenUnknown,RefreshTokenReuse(the tripwire) andPinChanged, which alone writes no log line (ADR-0047). Four are money movements (B1 in the code),MoneyDeposited,MoneyWithdrawn,MoneyTransferredandMoneyTransferredInternally: one row each, a transfer's on its outgoing ledger row, dropped with a failed attempt (ADepositThatRetries_WritesExactlyOneAuditRow), and no log line: evidence to keep, not an alert. Five are refusals:MoneyWithdrawalRefused,MoneyTransferRefusedandAccountDeletionRefused(ADR-0049) for no step-up authorisation, or a PIN compared and found wrong at the four mints (ADR-0056),PinChangeRefusedfor one at a PIN change, andPinVerificationRefusedfor one at the PIN check that raises a session; the last two share one site. A request the lock refuses unread, registration refusals and business validation (insufficient funds, the daily limit) stay log-only, because a row the caller can repeat at will is an unbounded write on the tail lock (AWithdrawalRefusedForFunds_WritesNoRow_AndThatIsTheDecision); retry collisions as health signals, not acts of a principal;RefreshRenewalRateHighbecause a renewal writes nothing (ADR-0057).AuditChainUnavailableis log-only by necessity: its row needs the lock that failed.
Rejected
- Rejected: a transactional outbox to keep availability, because its own write is fail-closed too.
- Rejected:
SET LOCK_TIMEOUTfor the tail bound, because a pooled session would carry it on. - Rejected: an endpoint instead of the
AzureBank.AuditVerifiertool, because no role guards one. - Rejected: a soft delete for retention, because a flag is not erasure and the chain cannot see it.
- Rejected: re-hashing history on a rotation, because that is the operation an anchor detects.
- Rejected: an options validator for the ring's rules, because a rule kept in two places drifts.
Consequences
- Audited operations stop when the table is unwritable. No standard requires that: NIST SP 800-53 AU-5 asks for an alert, and AU-5(4), shutdown on failure, is in no SP 800-53B baseline.
- Every audited save queues on one tail lock, bounded by
Audit:TailTimeoutSeconds(5): a save that cannot take it moves no money and answers 503 (AuditChainContentionSqlServerTests, ADR-0058). - Readiness (
AuditChainHealthCheck) names an unreadable store, a read-only database or a refusedINSERT(AuditWritePermissionSqlServerTests): seedocs/runbooks/audit-chain-unavailable.md. Audit:ChainKeyandAudit:AnchorKeyare secrets of 32+ characters, apart from every other key.- Not covered (D2): the end of the table. The honest claim is narrow: this chain detects tampering by someone who holds the database but not the key whose epoch that row falls in, except at the end of the table (
TruncatingTheTAIL_IsNotDetected_AndThisPinsTheLimit), anchor records or not. - Not covered (D6): erasure. Pseudonymous ids are personal data while anyone can link them, and
EnforceTransactionImmutabilityrefuses to delete the ledger row a money event names, so a lawful deletion needs raw SQL around the guard: the compliant act and the attack are the same act. - Not covered: an authorisation's row is unchained, a successful mint writes no row, an intact verdict is no inclusion proof, no schedule verifies the chain, and partitioning the chain is open.
Verified by
AuditChainTests,AuditChainSqlServerTests,AuditTrailPersistenceTests(reads the table).TheEventInventoryThisAdrStatesIsStillTheOneInTheSource(counts),AuditProseGuardTests(names).
Related
ADR-0009, ADR-0042, ADR-0045, ADR-0047, ADR-0049, ADR-0050, ADR-0056, ADR-0057, ADR-0058, ADR-0062.