docs(audit-log): document append-only enforcement honestly (CI guard default; DB roles are optional DBA hardening; purger now has CREATE TABLE + scoped DELETE)

This commit is contained in:
Joseph Doherty
2026-07-09 07:03:10 -04:00
parent 2a080a3e12
commit eb69b93947
3 changed files with 37 additions and 17 deletions
@@ -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.
---