From eb69b93947984de1669ff3f287457b09ff186698 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 9 Jul 2026 07:03:10 -0400 Subject: [PATCH] docs(audit-log): document append-only enforcement honestly (CI guard default; DB roles are optional DBA hardening; purger now has CREATE TABLE + scoped DELETE) --- docs/components/ConfigurationDatabase.md | 8 ++--- docs/requirements/Component-AuditLog.md | 34 +++++++++++++++---- .../Component-ConfigurationDatabase.md | 12 +++---- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/docs/components/ConfigurationDatabase.md b/docs/components/ConfigurationDatabase.md index 547be2b3..f98a9661 100644 --- a/docs/components/ConfigurationDatabase.md +++ b/docs/components/ConfigurationDatabase.md @@ -42,14 +42,14 @@ A `SecretAwareModelCacheKeyFactory` folds `HasSecretEncryptionProvider` into the ### Append-only AuditLog and DB-role enforcement -The central `dbo.AuditLog` table has two dedicated SQL Server roles: +The central `dbo.AuditLog` table has two dedicated SQL Server roles — **optional DBA hardening** on top of the default application-level enforcement (CI grep guard + code review; the default deployment runs one connection principal for both paths): | Role | Grants | |------|--------| -| `scadabridge_audit_writer` | `INSERT`, `SELECT` on `AuditLog` only — no `UPDATE`, no `DELETE` | -| `scadabridge_audit_purger` | `ALTER ON SCHEMA::dbo` (required for `SPLIT RANGE` and partition switch-out) | +| `scadabridge_audit_writer` | `INSERT`, `SELECT` on `AuditLog` only; `DENY UPDATE` + `DENY DELETE` | +| `scadabridge_audit_purger` | `SELECT` + `ALTER ON SCHEMA::dbo` (partition switch-out), **plus** `CREATE TABLE` (switch-out CREATEs a staging table) and scoped `DELETE ON dbo.AuditLog` (per-channel retention override) — the latter two added by the `FixAuditPurgerRoleGrants` migration | -Row-level `DELETE` on `AuditLog` is not granted even to the purge role; retention is always a partition switch, never a row delete. +The primary retention purge is a monthly partition switch. The one sanctioned row-level `DELETE` against `AuditLog` is the per-channel retention override on the maintenance path (guarded by the `// AUDIT-PURGE-ALLOWED` marker); every other `UPDATE`/`DELETE` trips the append-only CI guard. ## Architecture diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index c2d5c6aa..d3db0453 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -441,13 +441,33 @@ MS SQL for direct-write events). Unredacted secrets never persist. ## Security & Tamper-Evidence -- **Append-only enforcement.** The application accesses `AuditLog` via a - dedicated DB role `scadabridge_audit_writer` granted `INSERT` + `SELECT` only — - no `UPDATE`, no `DELETE`. Purge runs under a separate role - `scadabridge_audit_purger` whose permissions are limited to the partition-switch - operation; row-level `DELETE` is not granted even to purge. -- **CI grep guard.** The build greps the data layer for any - `UPDATE … AuditLog` or `DELETE … AuditLog` text and fails on a hit. +- **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 + 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. + 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. - **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 diff --git a/docs/requirements/Component-ConfigurationDatabase.md b/docs/requirements/Component-ConfigurationDatabase.md index 38e0b834..fbc2478f 100644 --- a/docs/requirements/Component-ConfigurationDatabase.md +++ b/docs/requirements/Component-ConfigurationDatabase.md @@ -100,7 +100,7 @@ The configuration database stores all central system data, organized by domain a - `IX_AuditLog_ParentExecution (ParentExecutionId)` — unfiltered - `IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)` (per-node queries) - **Monthly partitioning** on `OccurredAtUtc` via partition function `pf_AuditLog_Month` and partition scheme `ps_AuditLog_Month`; retention purge is a partition switch, never a row-level delete. The table is populated only by Audit Log writers (site telemetry, central direct-write, reconciliation pulls); central ingest is **insert-if-not-exists** keyed on `EventId`. See Component-AuditLog.md for the full lifecycle, payload-capture policy, and ingestion paths. + **Monthly partitioning** on `OccurredAtUtc` via partition function `pf_AuditLog_Month` and partition scheme `ps_AuditLog_Month`; the primary retention purge is a partition switch (per-channel retention overrides add one sanctioned bounded row DELETE on the maintenance path — see AuditLog Table Purge). The table is populated only by Audit Log writers (site telemetry, central direct-write, reconciliation pulls); central ingest is **insert-if-not-exists** keyed on `EventId`. See Component-AuditLog.md for the full lifecycle, payload-capture policy, and ingestion paths. ### Inbound API - **API Keys**: The `ApiKeys` SQL Server table was **retired** by migration `RetireInboundApiKeyStore`. Inbound API keys now live in the shared `ZB.MOM.WW.Auth.ApiKeys` SQLite store, not the configuration MS SQL database. The `IInboundApiRepository` no longer exposes any `ApiKey` operations; key management goes through `IInboundApiKeyAdmin` (Commons, `Interfaces/Security/`). The `ApprovedApiKeyIds` column on `ApiMethods` was removed at the same time — key-to-method scoping is now managed by the auth library. @@ -327,12 +327,12 @@ Results are returned in reverse chronological order (most recent first) with pag ## Database Roles -The configuration database defines dedicated SQL Server roles for the append-only `AuditLog` table so that the application can never accidentally mutate audit history: +The configuration database defines dedicated SQL Server roles for the append-only `AuditLog` table. These roles are **optional DBA hardening**: the default deployment runs with a single connection principal for both writer and maintenance paths, so append-only is enforced application-side (CI grep guard + code review — see Component-AuditLog.md). A deployment that wants database-level enforcement provisions two logins mapped to these roles: -- **`scadabridge_audit_writer`** — the role used by application code that ingests audit events (the `AuditLogIngestActor`, central direct-write paths, and the Notification Outbox dispatcher). Granted `INSERT` and `SELECT` on `AuditLog` only — explicitly **no** `UPDATE` and **no** `DELETE`. Audit ingest is `INSERT … WHERE NOT EXISTS` keyed on `EventId`, which this grant set fully supports. -- **`scadabridge_audit_purger`** — the role used by the `AuditLogPurgeActor`. Granted only the permissions required to execute the monthly partition-switch operation (switch out a partition to a staging table and drop the staging table). Row-level `DELETE` on `AuditLog` is **not** granted even to the purge role; retention is a partition switch, never a row-by-row delete. +- **`scadabridge_audit_writer`** — the role for application code that ingests audit events (the `AuditLogIngestActor`, central direct-write paths, and the Notification Outbox dispatcher). Granted `INSERT` and `SELECT` on `AuditLog` only, with `DENY UPDATE` + `DENY DELETE` so a later `db_datawriter` membership cannot silently re-enable mutation (`DENY` > `GRANT`). Audit ingest is `INSERT … WHERE NOT EXISTS` keyed on `EventId`, which this grant set fully supports. +- **`scadabridge_audit_purger`** — the maintenance role for the `AuditLogPurgeActor`. Granted `SELECT` + `ALTER ON SCHEMA::dbo` for the monthly partition-switch dance, **plus** `CREATE TABLE` (the switch-out CREATEs a staging table — `ALTER ON SCHEMA::dbo` alone does not confer the database-level `CREATE TABLE` permission) and a scoped `DELETE ON dbo.AuditLog` (the per-channel retention override is a bounded row DELETE on the maintenance path). The `CREATE TABLE` + `DELETE` grants were added by the `FixAuditPurgerRoleGrants` migration (arch-review 04, S3); the earlier claim that row-level `DELETE` is "not granted even to the purge role" was stale once per-channel retention overrides shipped. -A CI grep guard fails the build on any occurrence of `UPDATE … AuditLog` or `DELETE … AuditLog` in the data-access layer source, backstopping the DB-grant enforcement at code-review time. See Component-AuditLog.md (Security & Tamper-Evidence) for the full enforcement contract. +A CI grep guard (`AuditLogAppendOnlyGuardTests`) fails the build on any `UPDATE`/`DELETE` DML targeting `dbo.AuditLog` in the data-access layer source, save the two `// AUDIT-PURGE-ALLOWED`-marked maintenance-path mutations. In the default single-principal deployment this guard (with code review) IS the enforcement; the DB roles above harden it further when a DBA opts in. See Component-AuditLog.md (Security & Tamper-Evidence) for the full contract. --- @@ -396,7 +396,7 @@ The `SiteCalls` table grows one row per cached site call and is never trimmed by ### AuditLog Table Purge -The `AuditLog` table is append-only and grows by every script-trust-boundary event across all channels. Unlike `Notifications` and `SiteCalls`, purge is **never a row-level `DELETE`** — it is a **monthly partition switch** against the `ps_AuditLog_Month` scheme. A daily job switches out any partition whose latest `OccurredAtUtc` is older than the global retention window (default 365 days, configurable via the `AuditLog:RetentionDays` Audit Log option — single global value in v1, no per-channel overrides) and drops the resulting staging table. The job is owned and scheduled by the Audit Log component (`AuditLogPurgeActor` — see Component-AuditLog.md), which is also the consumer of the `AuditLog:RetentionDays` option. The Configuration Database component contributes only the table, the partition function/scheme, the indexes, and the DB roles that constrain the purge to a partition switch. +The `AuditLog` table is append-only and grows by every script-trust-boundary event across all channels. Unlike `Notifications` and `SiteCalls`, the primary purge is a **monthly partition switch** against the `ps_AuditLog_Month` scheme, not a row-level `DELETE`. A daily job switches out any partition whose latest `OccurredAtUtc` is older than the global retention window (default 365 days, configurable via `AuditLog:RetentionDays`) and drops the resulting staging table. On top of the global window, **per-channel retention overrides** (`AuditLog:PerChannelRetentionDays`, a dictionary keyed by canonical channel name with windows strictly shorter than the global one) expire a single channel's rows earlier via a bounded, batched `DELETE TOP` on the maintenance path — the one sanctioned row-level DELETE against `AuditLog` (see Component-AuditLog.md). The job is owned and scheduled by the Audit Log component (`AuditLogPurgeActor` — see Component-AuditLog.md), which is also the consumer of both options. The Configuration Database component contributes only the table, the partition function/scheme, the indexes, and the optional DB roles. ---