docs(audit): hash-chain tamper evidence shipped, default off (ScadaBridge#41)

Component-AuditLog Security & Tamper-Evidence describes the per-month chain,
the chaining job and its lock, both purge paths (checkpoint anchor,
tombstones), pre-existing rows, verification and what an unsigned chain does
not prove. alog.md §11.4/§16, CLAUDE.md, HighLevelReqs AL-11/12,
Component-CLI, Component-ManagementService, docs/components/CLI.md and the
CLI README drop the "no-op placeholder" wording.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 16:28:54 -05:00
parent 548bb9a725
commit dd99a9f5a5
8 changed files with 109 additions and 36 deletions
+61 -11
View File
@@ -789,10 +789,11 @@ MS SQL for direct-write events). Unredacted secrets never persist.
enforced by two application-level controls, not by database permissions:
1. **CI grep guard** — `AuditLogAppendOnlyGuardTests` scans the ConfigurationDatabase
source for any `UPDATE`/`DELETE` DML targeting `dbo.AuditLog` and fails the build
on a hit. Exactly two maintenance-path mutations are allow-listed by an explicit
`// AUDIT-PURGE-ALLOWED` marker (the per-channel retention `DELETE TOP` and the
one-time `SourceNode` sentinel backfill `UPDATE`); every other UPDATE/DELETE trips
the guard.
on a hit. Exactly three mutations are allow-listed by an explicit
`// AUDIT-PURGE-ALLOWED` marker (the per-channel retention `DELETE TOP`, the
one-time `SourceNode` sentinel backfill `UPDATE`, and the hash-chain link `UPDATE`
that sets only `ChainSeq`/`RowHash` while they are NULL — ScadaBridge#41); every
other UPDATE/DELETE trips the guard.
2. **Code review** — the marker is deliberately specific so an unrelated mutation
cannot inherit the exemption without a reviewer noticing.
- **Optional DBA hardening (two DB roles).** For a deployment that wants
@@ -815,13 +816,61 @@ MS SQL for direct-write events). Unredacted secrets never persist.
**AuditExport** permission.
- **Payload redaction at write.** See Payload Capture Policy. Unredacted
secrets never persist; the safety net over-redacts on misconfiguration.
- **Hash-chain tamper evidence (T1) — deferred to v1.x.** A future `RowHash`
column, computed per partition as `SHA-256(prev.RowHash || canonical(row))`, will
be verifiable offline via `scadabridge audit verify-chain --month YYYY-MM`. The
`verify-chain` CLI command is a no-op placeholder today. Off by default in v1.
- **Hash-chain tamper evidence (T1) — shipped (ScadaBridge#41), default OFF.**
`AuditLog:HashChain:Enabled` (default `false`, plus `IntervalSeconds` 60,
`BatchSize` 1000, `MaxBatchesPerTick` 100) starts the central
`AuditLogHashChainService` on both central nodes.
- **Model.** One chain per **partition = UTC calendar month of `OccurredAtUtc`**
(the `ps_AuditLog_Month` month). Each chained row carries `ChainSeq` (1, 2, 3 …,
gap-free within its month) and `RowHash = SHA-256(prev.RowHash || canonical(row))`,
`prev` being the row at `ChainSeq - 1` (32 zero bytes for the first). The canonical
v1 form (`AuditChainHasher`) is a domain tag, the month and sequence number, then
the ten writable columns in table order, each string length-prefixed with a NULL
marker; the computed columns derive from `DetailsJson` and are covered by it. A
known-answer test pins the encoding — changing it invalidates every stored chain.
- **Who writes the chain.** Not the hot path: the two central nodes and the site
ingest INSERT concurrently, so a write-time chain would need a global serialisation
point on every audit write. A **chaining job** links unchained rows (oldest month
first, in `OccurredAtUtc, EventId` order within a pass) under an exclusive SQL
Server application lock (`sp_getapplock`, zero timeout — the peer node's pass just
skips its tick), with an optimistic head check and a `ChainSeq IS NULL` row-count
check that roll the pass back rather than leave a gap. This is alog.md §11.4's
"chaining job", run every minute instead of only after a month closes, so a row is
tamper-evident about a minute after it reaches central. Chain order is chaining
order, so late-arriving site rows simply take the next sequence number.
- **Purges.** `dbo.AuditLogChainHead` holds each month's `(LastSeq, LastHash)` — which
is what exposes deletion of the newest rows — and the checkpoint anchor
`(PurgedThroughSeq, PurgedThroughHash)` that the monthly partition switch sets
inside its own transaction; verification resumes after it. The per-channel retention
DELETE writes a `dbo.AuditLogChainTombstone` (month, `ChainSeq`, `RowHash`,
`EventId`) for every chained row it removes, in the same transaction, via
`OUTPUT … INTO`; verification bridges the gap with it. Both purge paths and the
SourceNode backfill hold the same lock exclusively. The backfill skips chained rows
(a chained row's `SourceNode` is frozen) — run it before enabling the chain.
- **Pre-existing rows** start unchained (`ChainSeq`/`RowHash` NULL). Enabling the job
chains the historical backlog oldest month first, so those rows are protected from
the moment they are chained, not from when they were written. Verification reports
still-unchained rows as `UnchainedRows`, never as a break.
- **Verification.** `scadabridge audit verify-chain --month YYYY-MM` sends the
`VerifyAuditChain` management command (OperationalAudit roles; refused for a
site-scoped caller because a month's chain spans every site; long-running Ask). It
takes the lock SHARED, recomputes every link in sequence order and reports `Intact`,
`NotChained` or `Broken` with the **first break**: partition, `ChainSeq`, `EventId`
and kind — `HashMismatch` (an edited row, or rows reordered — the sequence number is
inside the hashed bytes), `MissingRow` (a deleted row with no tombstone),
`DuplicateSequence`, `IncompleteLink`, or `HeadMismatch` (rows deleted from the end).
CLI exit code 3 on `Broken`.
- **What it does NOT prove.** The chain is unsigned and anchored only inside the same
database: a principal that can rewrite rows AND recompute every later link AND the
head can produce a consistent forgery. Signing the head (key custody) and anchoring
it outside the database are open owner decisions (ScadaBridge#41).
- **Roles (DBA hardening).** `AddAuditLogHashChain` adds `scadabridge_audit_chainer`
(SELECT on `AuditLog`, column-level `UPDATE (ChainSeq, RowHash)`, read/write on the
head), grants the purger the head/tombstone rights its purge paths need, and DENYs
INSERT/UPDATE/DELETE on both chain tables to `scadabridge_audit_writer`.
- **Parquet archival (T2) — deferred to v1.x.** Long-term cold storage of purged
monthly partitions as Parquet files (suitable for offline analytics) will be
added in a future milestone. T1 and T2 are not shipped as part of M5.
added in a future milestone. (T1 shipped later — see above; T2 is still deferred.)
- **Site SQLite security.** File permissions: read/write by the ScadaBridge
service account only. Not backed up off-machine — site SQLite is a buffer,
not a record.
@@ -995,8 +1044,9 @@ orphaned entries) and in the CLI's `audit tree` output.
- **[CLI (#19)](Component-CLI.md)** — `scadabridge audit query`,
`scadabridge audit export`, `scadabridge audit tree --execution-id <guid>`,
`scadabridge audit backfill-source-node --sentinel <s> --before <date>`, and
`scadabridge audit verify-chain` (no-op placeholder for the deferred hash-chain
feature); same permission requirements as the UI.
`scadabridge audit verify-chain --month <YYYY-MM>` (hash-chain verification via the
`VerifyAuditChain` management command, ScadaBridge#41); same permission requirements
as the UI.
- **[KPI History (#26)](Component-KpiHistory.md)** — emits `IKpiSampleSource`
(`AuditLogKpiSampleSource`, Global) consumed by the KpiHistory recorder (#26),
reusing the existing audit-KPI reads; the resulting `totalEventsLastHour` /