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:
@@ -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` /
|
||||
|
||||
Reference in New Issue
Block a user