feat(audit): off-box chain-head export, verify-heads, maintenance login (ScadaBridge#107)
Item 1 (owner decision: no signing). AuditLog:HashChain:HeadExport (default
off): both centrals periodically read every month's chain head (LastSeq,
LastHash, purge anchor, chained rows, tombstones; one snapshot under the
chain lock SHARED) and append the changed heads to a sink the DB login cannot
write - File (append-only JSONL per node per day, local or UNC) or Syslog
(RFC 5424 over UDP / TCP octet counting). `audit verify-heads --path` reads
those records back (file or syslog-collector lines) and the new
VerifyAuditChainHeads command checks each against the database: Matches /
Extended / Purged, or Mismatch / Regressed / MissingHead / MissingLink /
CountMismatch (exit 3). AuditChainHeadStoreTests forges a chain with the
production hasher: verify-chain says Intact, verify-heads says Mismatch.
Item 3. AuditLog:MaintenanceConnectionString (${secret:}-capable, unset =
today's single login) runs the chain job, chain verify / head export, the
partition switch-out + archive and the per-channel purge on
AuditMaintenanceContext (keyed audit-maintenance repository/archive, purge
actor prefers them). AuditMaintenanceLoginTests runs the hardened setup on
SQL Server: writer (datareader/datawriter/audit_writer) ingests and is
refused chaining and deleting; the chainer+purger login does the full cycle.
Two-login grants documented in Component-AuditLog.md.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -833,9 +833,9 @@ MS SQL for direct-write events). Unredacted secrets never persist.
|
||||
## Security & Tamper-Evidence
|
||||
|
||||
- **Append-only enforcement — what actually holds in the default deployment.**
|
||||
ScadaBridge runs with **one** connection principal for both the writer and the
|
||||
maintenance/purge paths (the runtime does not open a second, lower-privilege
|
||||
connection for purge). So in the default deployment the append-only invariant is
|
||||
By default ScadaBridge runs with **one** connection principal for both the writer and the
|
||||
maintenance/purge paths. (A second, maintenance login is supported but opt-in — see
|
||||
*Two-login hardening* below.) So in the default deployment the append-only invariant is
|
||||
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
|
||||
@@ -846,20 +846,41 @@ MS SQL for direct-write events). Unredacted secrets never persist.
|
||||
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
|
||||
database-level enforcement on top of the application controls, migrations provision
|
||||
two roles: `scadabridge_audit_writer` (`INSERT` + `SELECT` only; `DENY UPDATE` +
|
||||
`DENY DELETE` so a later `db_datawriter` membership cannot silently re-enable
|
||||
mutation) and `scadabridge_audit_purger` (the maintenance principal). To use them,
|
||||
a DBA provisions **two logins** — the runtime connection mapped to the writer role,
|
||||
and a separate maintenance job/connection mapped to the purger role. The purger role
|
||||
now carries the permissions its purge path genuinely needs: `SELECT` + `ALTER ON
|
||||
SCHEMA::dbo` **plus** `CREATE TABLE` (the switch-out dance CREATEs a staging table)
|
||||
and a scoped `DELETE ON dbo.AuditLog` (the per-channel retention override is a bounded
|
||||
row DELETE) — granted by the `FixAuditPurgerRoleGrants` migration (arch-review 04, S3).
|
||||
Without those two grants the purger role could not actually execute the switch-out or
|
||||
the per-channel purge; the earlier claim that "row-level `DELETE` is not granted even
|
||||
to purge" was stale once `PerChannelRetentionDays` shipped.
|
||||
- **Two-login hardening (ScadaBridge#107 item 3) — opt-in.** Migrations provision three
|
||||
roles: `scadabridge_audit_writer` (`INSERT` + `SELECT` on `AuditLog`; `DENY UPDATE` +
|
||||
`DENY DELETE` so a later `db_datawriter` membership cannot silently re-enable mutation;
|
||||
`DENY INSERT/UPDATE/DELETE` on `AuditLogChainHead` and `AuditLogChainTombstone`),
|
||||
`scadabridge_audit_chainer` (`SELECT` on `AuditLog`, column-level `UPDATE (ChainSeq, RowHash)`,
|
||||
`SELECT/INSERT/UPDATE` on the head, `SELECT` on tombstones) and `scadabridge_audit_purger`
|
||||
(`SELECT` + `ALTER ON SCHEMA::dbo` + `CREATE TABLE` for the switch-out staging table,
|
||||
`DELETE ON dbo.AuditLog` for the per-channel purge — `FixAuditPurgerRoleGrants`, arch-review 04
|
||||
S3 — and `SELECT/UPDATE` on the head, `SELECT/INSERT/DELETE` on tombstones). Because the writer
|
||||
role's DENYs win over any grant, the chain job and the purges **cannot** run on a login that is
|
||||
in the writer role. Set `AuditLog:MaintenanceConnectionString` (central nodes; `${secret:}`
|
||||
references resolve, as for every pre-host key) and the hash-chain job, chain verification /
|
||||
head export, the partition switch-out (with its Parquet archive) and the per-channel DELETE run
|
||||
on their own connection with that string (`AuditMaintenanceContext`); unset, they run on
|
||||
`ScadaBridge:Database:ConfigurationDb` exactly as before. A DBA provisions:
|
||||
|
||||
| Login | Used by | Database roles |
|
||||
|---|---|---|
|
||||
| runtime (writer) — `ScadaBridge:Database:ConfigurationDb` | everything else, incl. audit ingest | `db_datareader`, `db_datawriter`, `scadabridge_audit_writer` (+ whatever the rest of the app needs) |
|
||||
| maintenance — `AuditLog:MaintenanceConnectionString` | chain job, verify-chain / verify-heads, head export, partition switch-out + archive, per-channel purge | `scadabridge_audit_chainer`, `scadabridge_audit_purger` — and NOT `scadabridge_audit_writer` |
|
||||
|
||||
```sql
|
||||
CREATE USER [svc_scadabridge_audit_maint] FOR LOGIN [svc_scadabridge_audit_maint];
|
||||
ALTER ROLE scadabridge_audit_chainer ADD MEMBER [svc_scadabridge_audit_maint];
|
||||
ALTER ROLE scadabridge_audit_purger ADD MEMBER [svc_scadabridge_audit_maint];
|
||||
ALTER ROLE scadabridge_audit_writer ADD MEMBER [svc_scadabridge_app]; -- the runtime login
|
||||
```
|
||||
|
||||
`AuditMaintenanceLoginTests` runs exactly this setup on SQL Server: the writer ingests and is
|
||||
refused the chain UPDATE and the per-channel DELETE; the maintenance login chains, verifies,
|
||||
reads heads, purges per channel, enumerates partitions, archives and switches out. Not moved
|
||||
to the maintenance login: the monthly partition roll-forward (`ALTER PARTITION FUNCTION`,
|
||||
needs `ALTER ANY DATASPACE` on the runtime login, or run it as a DBA job) and the one-time
|
||||
`audit backfill-source-node` (needs `UPDATE` on `SourceNode` — a DBA-run step in a hardened
|
||||
setup). Migrations themselves are applied by a DBA with the production SQL scripts.
|
||||
- **Authorization.** Reading the Audit Log requires the existing **Audit** role
|
||||
extended with a new **OperationalAudit** permission. Per-site row scoping
|
||||
reuses the existing site-permission model; bulk export requires an additional
|
||||
@@ -910,10 +931,29 @@ MS SQL for direct-write events). Unredacted secrets never persist.
|
||||
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).
|
||||
- **External anchor: off-box head export (ScadaBridge#107 item 1) — default OFF.** Inside the
|
||||
database alone, a principal that can rewrite rows AND recompute every later link AND the
|
||||
head produces a chain `verify-chain` accepts. Owner decision: **no signing**; instead
|
||||
`AuditLog:HashChain:HeadExport:Enabled` makes both central nodes, every `IntervalMinutes`
|
||||
(60), read every month's head (`LastSeq`, `LastHash`, the purge anchor, live chained rows,
|
||||
tombstones — one snapshot under the chain lock SHARED) and append the heads that changed
|
||||
since that node's last export (all of them after a restart; `ExportUnchanged` for every
|
||||
tick) to a sink the database login cannot write: `Sink: File` — append-only JSON lines in
|
||||
`{File:Directory}/{yyyy}/audit-chain-heads-{yyyy-MM-dd}-{node}.jsonl` (local or UNC; opened
|
||||
`FileMode.Append` only, one file per node per day; give the service account create +
|
||||
append-data rights and nothing else), or `Sink: Syslog` — RFC 5424 (`<PRI>1 … chainhead -
|
||||
{json}`, facility 13 "log audit", severity Notice) over UDP (RFC 5426) or TCP (RFC 6587
|
||||
octet counting). A failed write is logged and retried with the same heads next tick.
|
||||
`scadabridge audit verify-heads --path <file|dir>` reads those records back (file-sink
|
||||
lines or a syslog collector's log) and the `VerifyAuditChainHeads` command checks each
|
||||
against the database: the link stored at the exported sequence (the head, a live row's
|
||||
`RowHash`, a tombstone, or the purge anchor) must still carry the exported hash —
|
||||
`Matches` / `Extended` / `Purged` (removed by a partition switch; verify the archive), or
|
||||
the failures `Mismatch` (rewritten chain), `Regressed` (head rolled back), `MissingHead`,
|
||||
`MissingLink` (chained row deleted outside the purge paths), `CountMismatch` (same head,
|
||||
rows + tombstones changed). `AuditChainHeadStoreTests` forges a chain with the production
|
||||
hasher: `verify-chain` reports it Intact, `verify-heads` reports Mismatch. Coverage is from
|
||||
the first export on: a forgery of links never exported is not detectable this way.
|
||||
- **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
|
||||
@@ -987,6 +1027,31 @@ the global window; `PerChannelRetentionDays` specifies per-channel windows that
|
||||
are strictly shorter — any channel whose override equals or exceeds the global
|
||||
value is silently ignored (the global partition switch-out already governs it).
|
||||
|
||||
The nested `AuditLog:HashChain` section (ScadaBridge#41) runs the chaining job; its
|
||||
`HeadExport` sub-section (ScadaBridge#107) exports the chain heads off-box, and the root
|
||||
`AuditLog:MaintenanceConnectionString` moves the maintenance paths to a second login (see
|
||||
Security & Tamper-Evidence):
|
||||
|
||||
```jsonc
|
||||
"AuditLog": {
|
||||
"MaintenanceConnectionString": "${secret:SB-AUDIT-MAINT-DB}", // optional; unset = runtime connection
|
||||
"HashChain": {
|
||||
"Enabled": false, // default off
|
||||
"IntervalSeconds": 60,
|
||||
"BatchSize": 1000,
|
||||
"MaxBatchesPerTick": 100,
|
||||
"HeadExport": {
|
||||
"Enabled": false, // default off
|
||||
"IntervalMinutes": 60,
|
||||
"ExportUnchanged": false, // true = every head every tick
|
||||
"Sink": "File", // File | Syslog
|
||||
"File": { "Directory": "\\\\fileserver\\audit-heads" }, // absolute, local or UNC
|
||||
"Syslog": { "Host": "syslog.example", "Port": 514, "Protocol": "Udp", "Facility": 13, "AppName": "scadabridge", "TimeoutSeconds": 10 }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The nested `AuditLog:Archive` section (ScadaBridge#42) turns on the Parquet cold
|
||||
archive (see Retention & Purge):
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ The `scadabridge audit` group targets the centralized Audit Log component (#23)
|
||||
exposes the UI-equivalent operational audit surface. Permissions follow the same
|
||||
read-vs-export split the Central UI uses (see Component-AuditLog.md, Security &
|
||||
Tamper-Evidence, and Security & Auth #10): `audit query`, `audit tree`, and
|
||||
`audit verify-chain` and `audit archive list|verify` require the `OperationalAudit`
|
||||
`audit verify-chain`, `audit verify-heads` and `audit archive list|verify` require the `OperationalAudit`
|
||||
permission; `audit export`
|
||||
additionally requires `AuditExport`; `audit backfill-source-node` requires the
|
||||
`Admin` role (maintenance path only). The server enforces permission checks and
|
||||
@@ -316,6 +316,7 @@ scadabridge audit export --since <t> --until <t> --format csv|jsonl|parquet --ou
|
||||
scadabridge audit tree --execution-id <guid> [--format table|json]
|
||||
scadabridge audit backfill-source-node --before <ISO-8601-UTC> [--sentinel <value>] [--batch <n>]
|
||||
scadabridge audit verify-chain --month <YYYY-MM>
|
||||
scadabridge audit verify-heads --path <file-or-directory>
|
||||
scadabridge audit archive list
|
||||
scadabridge audit archive verify --name <archive>
|
||||
```
|
||||
@@ -350,6 +351,18 @@ scadabridge audit archive verify --name <archive>
|
||||
`0` intact or not chained, `3` broken, `1` error, `2` authorization failure. Requires
|
||||
`OperationalAudit` and a caller not scoped to specific sites. See
|
||||
Component-AuditLog.md, Security & Tamper-Evidence.
|
||||
- `audit verify-heads --path <file-or-directory>` (ScadaBridge#107) — reads the AuditLog
|
||||
chain heads exported off-box by `AuditLog:HashChain:HeadExport` (JSON lines from the file
|
||||
sink, or any log a syslog collector wrote — each line's JSON object is taken from its first
|
||||
`{` to its last `}`; a directory is read recursively for `*.jsonl`/`*.log`/`*.txt`),
|
||||
collapses duplicates and sends them in the `VerifyAuditChainHeads` management command. The
|
||||
server checks each exported head against the database chain — `Matches`, `Extended` (chain
|
||||
grew; the link at the exported sequence still carries the exported hash), `Purged` (removed
|
||||
by a partition purge; verify the archive), or the failures `Mismatch` (rewritten chain),
|
||||
`Regressed`, `MissingHead`, `MissingLink`, `CountMismatch`. Exit `0` verified, `3` a
|
||||
failure, `1` error (no records found, bad input, `CHAIN_BUSY`), `2` authorization failure.
|
||||
Same gate as `verify-chain`. Run it together with `verify-chain`: a forger who rewrites rows
|
||||
and re-hashes the chain inside SQL Server passes `verify-chain` but not `verify-heads`.
|
||||
- `audit archive list` / `audit archive verify --name <archive>` (ScadaBridge#42) —
|
||||
send the `ListAuditArchives` / `VerifyAuditArchive` management commands for the
|
||||
AuditLog Parquet cold archives the retention purge writes (`AuditLog:Archive`,
|
||||
|
||||
@@ -200,6 +200,7 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B
|
||||
|
||||
- **QueryAuditLog**: Legacy configuration-change audit query (filtered by entity type, user, date range, etc.) routed through `/management`. Gated to the `Admin` role; superseded for the centralized Audit Log component (#23) by the dedicated `/api/audit/*` REST endpoints described below.
|
||||
- **VerifyAuditChain** (ScadaBridge#41): verifies one UTC calendar month (`Month`, `YYYY-MM`) of the central AuditLog hash chain and returns an `AuditChainVerificationResult` (status, rows verified, tombstones applied, purge anchor, head sequence, unchained rows, first break with partition / `ChainSeq` / `EventId` / kind). Gated on the OperationalAudit roles (`Administrator`, `Viewer`); a site-scoped non-Administrator caller is refused (`ManagementUnauthorized`) because a month's chain spans every site. Long-running Ask timeout. Errors: `INVALID_ARGUMENT` (bad month), `CHAIN_BUSY` (a chaining/purge pass held the chain lock past 60 s). Backs `scadabridge audit verify-chain`.
|
||||
- **VerifyAuditChainHeads** (ScadaBridge#107): checks chain heads exported off-box (`Records`, at most 50,000 `AuditChainHeadRecord`s; duplicates collapsed) against the database chain and returns an `AuditChainHeadVerificationResult` (overall `Verified`, one `AuditChainHeadCheck` per distinct exported head: `Matches` / `Extended` / `Purged`, or the failures `Mismatch` / `Regressed` / `MissingHead` / `MissingLink` / `CountMismatch`). Same gate as VerifyAuditChain; long-running Ask timeout. Errors: `INVALID_ARGUMENT` (no records / over the cap), `CHAIN_BUSY`. Backs `scadabridge audit verify-heads`.
|
||||
- **ListAuditArchives** / **VerifyAuditArchive** (ScadaBridge#42): list the AuditLog Parquet cold archives under `AuditLog:Archive:Directory` (`AuditArchiveListResult`: enabled flag + every manifest), and verify one by `Name` (`AuditArchiveVerificationResult`: file integrity, per-month chain recomputed from the archived rows, anchor `Matches`/`Superseded`/`NotPurged`/`Mismatch`, overall `Verified`). Same gate as VerifyAuditChain (OperationalAudit roles, site-scoped non-Administrator refused). VerifyAuditArchive uses the long-running Ask timeout. Errors: `ARCHIVE_NOT_CONFIGURED`, `INVALID_ARGUMENT` (not an archive name), `NOT_FOUND`. Back `scadabridge audit archive list|verify`.
|
||||
|
||||
### Transport (Bundle Import / Export)
|
||||
|
||||
@@ -480,7 +480,7 @@ Sections 10.1–10.4 cover **configuration-database audit** (config-mutating use
|
||||
- **AL-9**: The site SQLite Audit Log is purged only when `ForwardState ∈ {Forwarded, Reconciled}` — i.e., a row must be either confirmed-forwarded *or* confirmed-reconciled before it can be removed. A central outage therefore **cannot cause audit loss at sites**.
|
||||
- **AL-10**: The Central UI exposes an **Audit Log page** with a cross-channel filter (by site, target, status, time range, correlation ID), plus **drill-ins from existing operational pages** (Site Calls, Notification Outbox, Inbound API).
|
||||
- **AL-11**: Append-only semantics are **enforced via DB roles** (no UPDATE/DELETE granted on the `AuditLog` table to application accounts); a **tamper-evidence hash chain** (per-month SHA-256 chain, default off) shipped later by ScadaBridge#41.
|
||||
- **AL-12**: The CLI provides a `scadabridge audit` command group for query, export (csv, jsonl, parquet), hash-chain verification (verify-chain reports the first break of a month's hash chain), and listing/verifying the Parquet cold archives (ScadaBridge#42) against the central Audit Log.
|
||||
- **AL-12**: The CLI provides a `scadabridge audit` command group for query, export (csv, jsonl, parquet), hash-chain verification (verify-chain reports the first break of a month's hash chain; verify-heads checks off-box exported chain heads against the database, ScadaBridge#107), and listing/verifying the Parquet cold archives (ScadaBridge#42) against the central Audit Log.
|
||||
|
||||
## 11. Health Monitoring
|
||||
|
||||
|
||||
Reference in New Issue
Block a user