diff --git a/CLAUDE.md b/CLAUDE.md index 0f345c03..d397feb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,7 +181,8 @@ spec for each is `docs/requirements/Component-.md`, and `README.md` carrie - Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in. Inbound API: full verbatim capture up to `InboundMaxBytes` (default 1 MiB); request headers stored in `Extra.requestHeaders` (post-redaction); per-method `SkipBodyCapture` flag suppresses bodies while still recording headers + metadata; `AuditInboundCeilingHits` counter surfaced on health snapshot. (M5.3 T7) - Audit-write failure NEVER aborts the user-facing action — audit is best-effort, the action's own success/failure path is authoritative. - 365-day central retention with monthly partition-switch purge; per-channel retention overrides (`AuditLog:PerChannelRetentionDays`) expire rows earlier than the global window via a bounded, batched row DELETE on the purge actor's maintenance path — values must be shorter than the global window (M5.5 T3); 7-day site SQLite retention with a hard `ForwardState` invariant (no row purged until forwarded or reconciled). -- Append-only enforced via DB roles (writer role has INSERT only, no UPDATE/DELETE); hash-chain tamper evidence (T1) and Parquet archival (T2) are deferred to v1.x — not shipped in M5. +- Append-only enforced via DB roles (writer role has INSERT only, no UPDATE/DELETE); Parquet archival (T2) is deferred to v1.x. +- **Hash-chain tamper evidence (T1, ScadaBridge#41) — default OFF (`AuditLog:HashChain:Enabled`).** One SHA-256 chain per UTC calendar month: `ChainSeq` + `RowHash = SHA-256(prev || canonical(row))` on `AuditLog` (migration `AddAuditLogHashChain`), linked by the central `AuditLogHashChainService` (both nodes, serialised by `sp_getapplock`) — never by the ingest path. `AuditLogChainHead` catches tail deletion and carries the partition-switch checkpoint anchor; the per-channel DELETE writes an `AuditLogChainTombstone` per chained row in the same transaction. `audit verify-chain --month` = management command `VerifyAuditChain` (OperationalAudit, unscoped callers only), first break with partition/ChainSeq/EventId, CLI exit 3. A chained row's `SourceNode` is frozen (backfill skips it). Unsigned; signing/external anchoring are open. - Node-of-origin is captured alongside site-of-origin: `SourceNode` (`varchar(64)` NULL) on `AuditLog`, `Notifications`, and `SiteCalls` — `node-a`/`node-b` for site rows (qualified by `SourceSiteId`/`SourceSite`), `central-a`/`central-b` for central direct-write rows. Stamped at the writing node, carried verbatim through telemetry + reconciliation, and indexed via `IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)` on `AuditLog`. - Per-node stuck KPIs (M5.3 T6): Notification Outbox and Site Call Audit expose `PerNodeNotificationKpiRequest`/`PerNodeSiteCallKpiRequest` messages that group stuck/parked/delivered counts by `SourceNode`, surfacing per-node breakdowns on the Health dashboard. - `audit tree --execution-id ` CLI command (M5.3 T8) + `GET /api/audit/tree` endpoint — resolves any node to its chain root and renders the full execution tree; backed by `IAuditLogRepository.GetExecutionTreeAsync`. diff --git a/alog.md b/alog.md index f7818094..7027e0ca 100644 --- a/alog.md +++ b/alog.md @@ -493,12 +493,17 @@ Audit Log page **Export** button streams CSV (current filter) server-side. Defau ### 11.3 Payload redaction at write See §8.2. Contract: unredacted secrets never persist. Safety net over-redacts on misconfiguration. -### 11.4 Tamper-evidence hash chain (deferred, v1.x) -- Each row gains a `RowHash` column. -- `RowHash = SHA-256(prev.RowHash || canonical(row))` per partition. -- Computed by a chaining job that runs after each monthly partition closes. -- Verifiable offline via `scadabridge audit verify-chain --month YYYY-MM`. -- Default **off** in v1 to avoid operational burden. Flag for v1.x. +### 11.4 Tamper-evidence hash chain (shipped, ScadaBridge#41 — default off) +- Each row gains a `RowHash` column, plus a `ChainSeq` (position in its partition's chain). +- `RowHash = SHA-256(prev.RowHash || canonical(row))` per partition (UTC calendar month). +- Computed by a chaining job — implemented incrementally (every minute, both central + nodes, serialised by a SQL application lock) rather than only after the month closes. +- Purges keep the chain verifiable: the partition switch records a checkpoint anchor on + the month's chain head; the per-channel DELETE leaves a tombstone per chained row. +- Verifiable via `scadabridge audit verify-chain --month YYYY-MM` (first break reported + with partition, `ChainSeq`, `EventId`). Unsigned; signing / external anchoring are open. +- Default **off** (`AuditLog:HashChain:Enabled`). Full design: Component-AuditLog.md, + Security & Tamper-Evidence. ### 11.5 Site SQLite security - File permissions: read/write by the ScadaBridge service account only. @@ -607,7 +612,7 @@ A monthly job dumps the closing partition to Parquet on operator-configured obje |---|---|---| | 1 | Component number | **#23 Audit Log** (README matrix + HighLevelReqs). | | 2 | Nav placement | New top-level **Audit** nav group in Central UI. | -| 3 | Hash-chain tamper evidence (§11.4) | Deferred to v1.x. v1 enforces append-only via DB grants only. | +| 3 | Hash-chain tamper evidence (§11.4) | Deferred to v1.x; shipped by ScadaBridge#41 (unsigned SHA-256 chain, default off). | | 4 | Parquet archival to object storage (§15.2) | Deferred to v1.x. | | 5 | Per-channel retention overrides (§12.1) | Deferred to v1.x. v1 uses a single global `RetentionDays`. | | 6 | Default payload cap | **8 KB** for `RequestSummary` / `ResponseSummary`; **64 KB** on error rows (`Status IN ('Failed', 'Parked', 'Discarded')`). | diff --git a/docs/components/CLI.md b/docs/components/CLI.md index 4eaf6a3a..4024540e 100644 --- a/docs/components/CLI.md +++ b/docs/components/CLI.md @@ -22,8 +22,8 @@ Direct-to-node access (`http://localhost:9001` for `central-a`, `http://localhos Management commands use `POST /management`. Audit query, export, and tree use `GET /api/audit/query`, `/api/audit/export`, and `/api/audit/tree`; source-node -backfill uses `POST /api/audit/backfill-source-node`. `audit verify-chain` is a -local placeholder that performs no verification. `debug stream` uses a persistent +backfill uses `POST /api/audit/backfill-source-node`. `audit verify-chain` sends the +`VerifyAuditChain` management command (ScadaBridge#41). `debug stream` uses a persistent SignalR connection. Audit export streams the response without buffering it all. ### Authentication and roles @@ -120,7 +120,7 @@ scadabridge instance deploy --id 7 | `db-connection` | `list`, `get`, `create`, `update`, `delete` | `Designer` | | `api-method` | `list`, `get`, `create`, `update`, `delete`, `validate`, `recompile` | `Designer` | | `bundle` | `export`, `preview`, `import` | Designer export; Administrator preview/import | -| `audit` | `query`, `export`, `tree`, `backfill-source-node`, `verify-chain` | Audit roles; Administrator backfill; verify-chain is a local placeholder | +| `audit` | `query`, `export`, `tree`, `backfill-source-node`, `verify-chain` | Audit roles; Administrator backfill; verify-chain needs an unscoped audit role | | `audit-config` | `query` (config-change audit trail; was `audit-log` pre-M8) | `Admin` | | `health` | `summary`, `pending-updates`, `site`, `event-log`, `parked-messages` | Authenticated reads with server-side scope checks | | `debug` | `snapshot`, `stream` | `Deployer` | diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index b071462c..75f5c94e 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -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 `, `scadabridge audit backfill-source-node --sentinel --before `, 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 ` (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` / diff --git a/docs/requirements/Component-CLI.md b/docs/requirements/Component-CLI.md index 51157e10..ad356494 100644 --- a/docs/requirements/Component-CLI.md +++ b/docs/requirements/Component-CLI.md @@ -337,11 +337,15 @@ scadabridge audit verify-chain --month rows where `SourceNode IS NULL` and `OccurredAtUtc < --before`, in batches (`--batch`, default 5000). Admin-only maintenance command. Idempotent. Backed by `POST /api/audit/backfill-source-node`. -- `audit verify-chain` — hash-chain verification for the named month. - **No-op in v1**: the command is defined so the command tree is stable, but - verification only becomes meaningful once the hash-chain ships (see - Component-AuditLog.md, Security & Tamper-Evidence). Until then, the server - responds with a "verification not yet available" status and the CLI exits 0. +- `audit verify-chain --month ` (ScadaBridge#41) — verifies the AuditLog + hash chain for one UTC calendar month by sending the `VerifyAuditChain` management + command over `POST /management` (the one `audit` leaf that is a management contract + rather than a REST call). Reports `Intact` / `NotChained` / `Broken`, rows verified, + tombstones applied, the partition-purge anchor, the head sequence and unchained rows; + on `Broken`, the first break's partition, `ChainSeq`, `EventId` and kind. Exit code + `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. ### Health Commands ``` @@ -520,7 +524,7 @@ Configuration is resolved in the following priority order (highest wins): - **System.CommandLine**: Command-line argument parsing. - **Microsoft.AspNetCore.SignalR.Client**: SignalR client for the `debug stream` command's WebSocket connection. - **Management Service (#18)**: The CLI hits the central cluster via the existing HTTP Management API (`POST /management`), which dispatches to the ManagementActor. The `scadabridge audit` command group rides a parallel REST surface on the same Host (`GET /api/audit/query` and `GET /api/audit/export`), sharing authentication (bearer token or HTTP Basic) with `/management` but bypassing the actor for read-only, keyset-paged / streaming workloads. -- **Audit Log (#23)**: The `scadabridge audit query`, `audit export`, `audit tree`, and `audit backfill-source-node` subcommands target the centralized Audit Log component's REST endpoints (`GET /api/audit/query`, `GET /api/audit/export`, `GET /api/audit/tree`, `POST /api/audit/backfill-source-node`) on the Host's Management API surface; `audit verify-chain` is a client-side no-op today (hash-chain deferred to v1.x). Permission checks (`OperationalAudit`, `AuditExport`, `Admin`) are enforced server-side by `AuditEndpoints`. +- **Audit Log (#23)**: The `scadabridge audit query`, `audit export`, `audit tree`, and `audit backfill-source-node` subcommands target the centralized Audit Log component's REST endpoints (`GET /api/audit/query`, `GET /api/audit/export`, `GET /api/audit/tree`, `POST /api/audit/backfill-source-node`) on the Host's Management API surface; `audit verify-chain` sends the `VerifyAuditChain` management command (`POST /management`, ScadaBridge#41). Permission checks (`OperationalAudit`, `AuditExport`, `Admin`) are enforced server-side by `AuditEndpoints`. ## Interactions diff --git a/docs/requirements/Component-ManagementService.md b/docs/requirements/Component-ManagementService.md index e7e2e921..e6dba714 100644 --- a/docs/requirements/Component-ManagementService.md +++ b/docs/requirements/Component-ManagementService.md @@ -199,6 +199,7 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B ### Audit Log - **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`. ### Transport (Bundle Import / Export) @@ -266,6 +267,7 @@ Every incoming message carries the authenticated user's identity and roles. The - **Verifier** role required for: approving / rejecting a secured write (`ApproveSecuredWriteCommand` / `RejectSecuredWriteCommand`). The no-self-approval rule (`Operator ≠ Verifier`) is enforced in the handler, independent of the role check. - **Any of Operator / Verifier / Administrator** required for: listing / querying secured writes (`ListSecuredWritesCommand`). Read-only, but the history exposes process-sensitive tag values, so it is gated any-of rather than open to every authenticated user (arch-review UA1). Enforced as an any-of gate (`GetRequiredRoles` returns the permitted set; the caller must hold at least one). - **Admin** role additionally required for: server-certificate trust/remove/list (`TrustServerCertCommand` / `RemoveServerCertCommand` / `ListServerCertsCommand`). +- **Any of Administrator / Viewer (OperationalAudit)** required for: AuditLog hash-chain verification (`VerifyAuditChainCommand`, ScadaBridge#41); the handler additionally refuses a site-scoped non-Administrator caller. - **Read-only access** (any authenticated role): health summary, health site, site event log queries, parked message queries. Unauthorized commands receive an `Unauthorized` response message. Failed authorization attempts are not audit logged (consistent with existing behavior). diff --git a/docs/requirements/HighLevelReqs.md b/docs/requirements/HighLevelReqs.md index f22331fa..7b4c95ee 100644 --- a/docs/requirements/HighLevelReqs.md +++ b/docs/requirements/HighLevelReqs.md @@ -479,8 +479,8 @@ Sections 10.1–10.4 cover **configuration-database audit** (config-mutating use - **AL-8**: Central retention defaults to **365 days**, enforced by a **monthly partition switch-and-drop** purge — no row-by-row delete. - **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 is deferred to v1.x**. -- **AL-12**: The CLI provides a `scadabridge audit` command group for query, export, and hash-chain verification (verify-chain becomes operational once AL-11's hash chain ships) against the central Audit Log. +- **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. ## 11. Health Monitoring diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 6b8f1b3b..cc2decd6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -2050,13 +2050,24 @@ scadabridge --url audit verify-chain --month | Option | Required | Default | Description | |--------|----------|---------|-------------| -| `--month` | yes | — | Month to verify, `YYYY-MM` (e.g. `2026-05`) | +| `--month` | yes | — | UTC calendar month to verify, `YYYY-MM` (e.g. `2026-05`) | -> **v1 no-op.** Hash-chain tamper-evidence is not enabled in this release (T1 -> deferred to v1.x). The subcommand validates the `--month` argument and prints a -> notice pointing at the v1.x roadmap in `Component-AuditLog.md`; it exits `0` -> without contacting the server. The command exists now so scripts and operator -> habits do not need to change when tamper-evidence ships. +Sends the `VerifyAuditChain` management command (ScadaBridge#41). The server +recomputes every link of that month's AuditLog hash chain and reports the first +break, if any: partition, `ChainSeq`, `EventId` and kind (`HashMismatch` = edited or +reordered row, `MissingRow` = deleted row, `HeadMismatch` = rows deleted from the +end, `DuplicateSequence`, `IncompleteLink`). `--format table` prints a summary; +`json` prints the result object. + +| Exit code | Meaning | +|-----------|---------| +| `0` | `Intact`, or `NotChained` (nothing chained for that month yet) | +| `3` | `Broken` — tamper evidence; see `firstBreak` | +| `1` | Request error (bad month, `CHAIN_BUSY`, server error) | +| `2` | Authorization failure (needs `Administrator` or `Viewer`, not site-scoped) | + +The chain only exists once `AuditLog:HashChain:Enabled` is on for the central nodes +(default off); see `Component-AuditLog.md`, Security & Tamper-Evidence. ---