feat(audit): SHA-256 hash chain over the central AuditLog (ScadaBridge#41)

Audit roadmap T1 (alog.md §11.4): one chain per UTC calendar month of
OccurredAtUtc. Each chained row carries ChainSeq (gap-free within its month)
and RowHash = SHA-256(prev.RowHash || canonical(row)); the v1 canonical form
(AuditChainHasher) is pinned by a known-answer vector computed independently.

- Migration AddAuditLogHashChain: nullable ChainSeq/RowHash on dbo.AuditLog
  (metadata-only add), AuditLogChainHead (per-month chain end + partition-purge
  checkpoint anchor), AuditLogChainTombstone, two partition-aligned filtered
  indexes, scadabridge_audit_chainer role with a column-level UPDATE grant.
  Production script docs/plans/sql/AddAuditLogHashChain.sql.
- AuditLogHashChain chains unchained rows oldest month first under an
  exclusive sp_getapplock (zero timeout, so a dual-active central pair still
  yields one chain), with an optimistic head check and a ChainSeq IS NULL
  row-count check that roll a pass back instead of leaving a gap. The ingest
  path is untouched.
- Purges keep the chain verifiable: the partition switch records the month's
  checkpoint anchor and drops its tombstones; the per-channel DELETE writes a
  tombstone per chained row in the same transaction (OUTPUT INTO). Both, and
  the SourceNode backfill, hold the chain lock; the backfill skips chained
  rows. The switch staging table gains the two new columns.
- AuditChainVerifier reports the first break (partition, ChainSeq, EventId,
  kind) for edited, deleted, reordered rows and tail truncation.
- AuditLogHashChainService (central, default OFF via AuditLog:HashChain).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 16:28:43 -05:00
parent 3cf97f6dda
commit fd4be80913
24 changed files with 4596 additions and 6 deletions
+68
View File
@@ -0,0 +1,68 @@
-- AddAuditLogHashChain (2026-11-09, ScadaBridge#41). AuditLog hash-chain tamper evidence:
-- ChainSeq/RowHash on dbo.AuditLog, AuditLogChainHead + AuditLogChainTombstone, two partition-aligned
-- filtered indexes, the scadabridge_audit_chainer role. Apply to the central configuration database
-- with both central nodes stopped (old binaries cannot switch out a partition once the columns exist).
BEGIN TRANSACTION;
ALTER TABLE [AuditLog] ADD [ChainSeq] bigint NULL;
ALTER TABLE [AuditLog] ADD [RowHash] binary(32) NULL;
CREATE TABLE [AuditLogChainHead] (
[ChainMonth] date NOT NULL,
[LastSeq] bigint NOT NULL,
[LastHash] binary(32) NOT NULL,
[PurgedThroughSeq] bigint NULL,
[PurgedThroughHash] binary(32) NULL,
[UpdatedAtUtc] datetime2 NOT NULL,
CONSTRAINT [PK_AuditLogChainHead] PRIMARY KEY ([ChainMonth])
);
CREATE TABLE [AuditLogChainTombstone] (
[ChainMonth] date NOT NULL,
[ChainSeq] bigint NOT NULL,
[RowHash] binary(32) NOT NULL,
[EventId] uniqueidentifier NOT NULL,
[PurgedAtUtc] datetime2 NOT NULL,
CONSTRAINT [PK_AuditLogChainTombstone] PRIMARY KEY ([ChainMonth], [ChainSeq])
);
CREATE NONCLUSTERED INDEX IX_AuditLog_Unchained
ON dbo.AuditLog (OccurredAtUtc, EventId)
WHERE ChainSeq IS NULL
ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_ChainSeq
ON dbo.AuditLog (ChainSeq)
WHERE ChainSeq IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_chainer') IS NULL
EXEC sp_executesql N'CREATE ROLE scadabridge_audit_chainer';
-- The chaining job reads unchained rows and writes ONLY the two link columns.
GRANT SELECT ON dbo.AuditLog TO scadabridge_audit_chainer;
GRANT UPDATE (ChainSeq, RowHash) ON dbo.AuditLog TO scadabridge_audit_chainer;
GRANT SELECT, INSERT, UPDATE ON dbo.AuditLogChainHead TO scadabridge_audit_chainer;
GRANT SELECT ON dbo.AuditLogChainTombstone TO scadabridge_audit_chainer;
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_purger') IS NULL
EXEC sp_executesql N'CREATE ROLE scadabridge_audit_purger';
-- The per-channel purge writes a tombstone per chained row it removes; the partition
-- switch moves the month's head to its checkpoint anchor and drops its tombstones.
GRANT SELECT, UPDATE ON dbo.AuditLogChainHead TO scadabridge_audit_purger;
GRANT SELECT, INSERT, DELETE ON dbo.AuditLogChainTombstone TO scadabridge_audit_purger;
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_writer') IS NULL
EXEC sp_executesql N'CREATE ROLE scadabridge_audit_writer';
-- The ingest principal must never be able to forge chain state.
DENY INSERT, UPDATE, DELETE ON dbo.AuditLogChainHead TO scadabridge_audit_writer;
DENY INSERT, UPDATE, DELETE ON dbo.AuditLogChainTombstone TO scadabridge_audit_writer;
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20261109211317_AddAuditLogHashChain', N'10.0.13');
COMMIT;
GO