docs(audit): Parquet cold archive shipped, default off (ScadaBridge#42)

Component-AuditLog Retention & Purge describes the archive: directory and
layout, the three files, consistency with the hash chain (SHARED read,
guarded switch, anchor = archived chain end), fail-closed purge, retention
interplay (per-channel overrides are not archived, their tombstones are),
verify/read, and the AuditLog:Archive configuration. alog.md §13.6/§15.2/§16,
CLAUDE.md, HighLevelReqs AL-12, Component-CLI, Component-ManagementService,
docs/components/{CLI,ManagementService}.md and the CLI README drop the
"deferred / 501" wording and document `audit archive list|verify` and the
parquet export.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 17:01:03 -05:00
parent d1fc336e18
commit 3ff463b6fd
9 changed files with 136 additions and 18 deletions
+66 -3
View File
@@ -759,6 +759,51 @@ MS SQL for direct-write events). Unredacted secrets never persist.
partition switch-out loop. Values are validated to be in
`[30, RetentionDays]`; keys that are not a recognized `AuditChannel` enum name
are rejected at startup.
- **Parquet cold archive (T2, ScadaBridge#42) — default OFF.** With
`AuditLog:Archive:Enabled` the purge actor archives every expired partition before
switching it out, so audit rows outlive `RetentionDays` as Parquet files:
- **Where.** `AuditLog:Archive:Directory` — an absolute path (validated at startup)
that both central nodes reach, because the purge runs on the active node and
`audit archive list|verify` on whichever node serves the call. Files land in
`{Directory}/{YYYY}/`. Object storage (S3 / Azure Blob) is not called directly —
mount it as a file system or copy the files on.
- **What.** Per partition, three files sharing the stem
`auditlog-{YYYY-MM}-{archivedAt:yyyyMMddTHHmmssfff}Z`: `.parquet` (every row of the
partition — the ten stored columns, `OccurredAtUtc` both as a UTC microsecond
timestamp and as exact `OccurredAtUtcTicks`, and `ChainSeq`/`RowHash`, null for
rows the chaining job had not reached; computed columns are not stored, they derive
from `DetailsJson`), `.tombstones.parquet` (the month's per-channel purge
tombstones) and `.manifest.json` (row / chained / unchained / tombstone counts,
SHA-256 and size of each file, and each chain month's head — `LastSeq`, `LastHash`,
and the anchor an earlier purge left). Each file is written to a `.tmp` sibling,
flushed and renamed; the manifest goes last and is the commit marker. Archives are
never overwritten. Row groups of `RowGroupSize` (default 50,000) bound memory.
- **Consistent with the hash chain.** The partition's rows, heads and tombstones are
read in one transaction holding the chain lock SHARED, so no chaining pass or purge
moves the chain mid-read. The switch then runs with an archive guard: under the
EXCLUSIVE chain lock it re-counts the partition and re-reads each month's
`LastSeq`, and THROWs (51002 row count changed / 51003 chain advanced — nothing
purged) unless both still equal the archive's. The switch's checkpoint anchor
(`PurgedThroughSeq/Hash`) therefore equals the archive's recorded chain end. A
stale archive keeps manifest status `Archived`; the next tick writes a fresh one.
- **Fail closed.** A partition that cannot be archived (volume full, lock busy past
`ChainLockTimeoutSeconds`, …) is not purged: the failure takes the usual
`AuditLogPurgeFailedEvent` / `PurgeFailures` path and the table keeps the rows.
A successful archive publishes `AuditLogArchivedEvent` before `AuditLogPurgedEvent`.
- **Retention interplay.** Archiving changes nothing about *when* a partition is
purged (`RetentionDays`). Per-channel overrides still delete their rows earlier,
so those rows are never archived — the archive carries their tombstones instead,
which is what keeps the archived chain verifiable. Archive files have no retention
of their own.
- **Verify / read.** `audit archive verify --name` (`VerifyAuditArchive`) checks both
files' SHA-256 and counts against the manifest, recomputes each archived month's
chain from the archived rows and tombstones (`AuditChainVerifier`, starting from
any earlier anchor), and compares the chain end with the database's current anchor:
`Matches`; `Superseded` (late rows for the month were chained, purged and archived
again afterwards — that later archive resumes from this one's end); `NotPurged`;
or `Mismatch` (archive or head altered). The files are plain Parquet — DuckDB,
pyarrow or Spark read them directly. There is no re-import into `dbo.AuditLog`:
a re-inserted expired row would be purged again by the next tick.
- **Sites:** `SiteAuditRetentionService` (site-only `IHostedService`) runs the
site SQLite retention purge on a timer — first tick after
`AuditLog:SiteRetention:InitialDelay` (default 5 min, short so a daily-recycled
@@ -868,9 +913,12 @@ MS SQL for direct-write events). Unredacted secrets never persist.
(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 shipped later — see above; T2 is still deferred.)
- **Parquet archival (T2) — shipped (ScadaBridge#42), default OFF.** See Retention &
Purge. The archive carries the hash-chain columns and the chain head, so tamper
evidence survives the purge: `audit archive verify` ties an archive to the checkpoint
anchor still in `AuditLogChainHead`. Like the chain itself, archives are unsigned;
a principal able to rewrite both the archive files and the head row can forge a
consistent pair.
- **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.
@@ -934,6 +982,21 @@ 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:Archive` section (ScadaBridge#42) turns on the Parquet cold
archive (see Retention & Purge):
```jsonc
"AuditLog": {
"Archive": {
"Enabled": false, // default off
"Directory": "D:\\AuditArchive", // absolute; required when Enabled; reachable by both centrals
"RowGroupSize": 50000, // 1,000 – 1,000,000
"ReadCommandTimeoutMinutes": 60, // reading one partition out of SQL Server
"ChainLockTimeoutSeconds": 300 // wait for a chaining pass before skipping the tick
}
}
```
The nested `AuditLog:Purge` section controls the purge actor cadence and batch
size:
+14 -3
View File
@@ -304,7 +304,8 @@ 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` require the `OperationalAudit` permission; `audit export`
`audit verify-chain` 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
returns HTTP 403 (CLI exit code 2) on denial.
@@ -315,6 +316,8 @@ 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 archive list
scadabridge audit archive verify --name <archive>
```
- `audit query` — filtered query against the central `AuditLog` table, matching the
@@ -323,8 +326,9 @@ scadabridge audit verify-chain --month <YYYY-MM>
JSON (default) or table.
- `audit export` — server-side streaming export of the central `AuditLog` to the
requested format (`csv`, `jsonl`, `parquet`) written to `--output`. The server
streams rows rather than materializing them in memory; the CLI writes bytes
through to disk. Supports the same scoping filters as `audit query`.
streams rows rather than materializing them in memory (Parquet: one row group of
10,000 rows at a time, the same 24 columns as csv/jsonl — ScadaBridge#42); the CLI
writes bytes through to disk. Supports the same scoping filters as `audit query`.
- `audit tree --execution-id <guid>` (M5.3 T8) — renders the full execution-chain
tree for the given `ExecutionId`. The server resolves the root from any node in
the chain (walks `ParentExecutionId` to find the root, then traverses downward)
@@ -346,6 +350,13 @@ scadabridge audit verify-chain --month <YYYY-MM>
`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 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`,
default off). `verify` checks the files against their manifest, recomputes the
archived hash chain and compares its end with the purge anchor; exit `0` verified,
`3` not verified, `1` error, `2` authorization failure. Same gate as `verify-chain`.
See Component-AuditLog.md, Retention & Purge.
### Health Commands
```
@@ -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`.
- **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)
+1 -1
View File
@@ -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, and hash-chain verification (verify-chain reports the first break of a month's hash chain) 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), and listing/verifying the Parquet cold archives (ScadaBridge#42) against the central Audit Log.
## 11. Health Monitoring