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
@@ -48,6 +48,22 @@ public class AuditLogRepository : IAuditLogRepository
private const string CanonicalColumnList =
"EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson";
// Hash chain (ScadaBridge#41). Wraps a maintenance-path batch in a transaction holding
// the EXCLUSIVE chain application lock (Maintenance.AuditChainLock), so it never
// interleaves with a chaining pass or a verification. NOCOUNT is ON for everything but
// the one mutating statement the caller places between prefix and suffix, so
// ExecuteNonQuery still returns exactly that statement's row count. XACT_ABORT rolls
// the transaction back on any error or client timeout (attention).
private const string ChainLockedBatchPrefix =
"SET XACT_ABORT ON;\nSET NOCOUNT ON;\nBEGIN TRANSACTION;\n" +
"DECLARE @chainLock int;\n" +
"EXEC @chainLock = sp_getapplock @Resource = N'" + Maintenance.AuditChainLock.Resource + "', @LockMode = 'Exclusive', @LockOwner = 'Transaction', @LockTimeout = -1;\n" +
"IF @chainLock < 0 THROW 51000, 'AuditLog hash-chain lock was not granted.', 1;\n" +
"SET NOCOUNT OFF;\n";
private const string ChainLockedBatchSuffix =
"\nSET NOCOUNT ON;\nCOMMIT TRANSACTION;";
private readonly ScadaBridgeDbContext _context;
private readonly ILogger<AuditLogRepository> _logger;
@@ -514,6 +530,13 @@ VALUES
BEGIN TRY
BEGIN TRANSACTION;
-- 0. Hash chain (ScadaBridge#41): hold the exclusive chain lock for the whole
-- switch so no chaining pass / verification interleaves with the anchor
-- update in step 3b.
DECLARE @chainLock int;
EXEC @chainLock = sp_getapplock @Resource = N'{Maintenance.AuditChainLock.Resource}', @LockMode = 'Exclusive', @LockOwner = 'Transaction', @LockTimeout = -1;
IF @chainLock < 0 THROW 51000, 'AuditLog hash-chain lock was not granted.', 1;
-- 1. Defensive cleanup for databases created before
-- AlignAuditLogEventIdUniqueness: the migration drops the
-- non-aligned UX_AuditLog_EventId, but a database restored from an
@@ -530,7 +553,8 @@ VALUES
-- expression text + PERSISTED) or ALTER TABLE ... SWITCH PARTITION
-- rejects the operation with msg 4904/4948. The ordinal order also
-- matches dbo.AuditLog_v2 (the CollapseAuditLogToCanonical migration):
-- 10 canonical columns first, then the 6 computed columns.
-- 10 canonical columns first, then the 6 computed columns, then the two
-- hash-chain columns AddAuditLogHashChain appended (ScadaBridge#41).
CREATE TABLE dbo.[{stagingTableName}] (
EventId uniqueidentifier NOT NULL,
OccurredAtUtc datetime2(7) NOT NULL,
@@ -548,6 +572,8 @@ VALUES
ExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier) PERSISTED,
ParentExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier) PERSISTED,
IngestedAtUtc AS CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7)),
ChainSeq bigint NULL,
RowHash binary(32) NULL,
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
) ON [PRIMARY];
@@ -559,6 +585,16 @@ VALUES
DECLARE @sql nvarchar(max) = 'ALTER TABLE dbo.AuditLog SWITCH PARTITION ' + CAST(@partitionNumber AS nvarchar(10)) + ' TO dbo.[{stagingTableName}];';
EXEC sp_executesql @sql;
-- 3b. Hash chain (ScadaBridge#41): the months this partition held are gone as a
-- whole, so record each month's current chain end as its checkpoint anchor
-- (verification resumes after it; a late-arriving row chains on from it)
-- and drop the per-channel tombstones the anchor now subsumes.
UPDATE dbo.AuditLogChainHead
SET PurgedThroughSeq = LastSeq, PurgedThroughHash = LastHash, UpdatedAtUtc = SYSUTCDATETIME()
WHERE $partition.pf_AuditLog_Month(CAST(ChainMonth AS datetime2(7))) = @partitionNumber;
DELETE FROM dbo.AuditLogChainTombstone
WHERE $partition.pf_AuditLog_Month(CAST(ChainMonth AS datetime2(7))) = @partitionNumber;
-- 4. Drop staging — the rows are discarded here. This is the purge.
DROP TABLE dbo.[{stagingTableName}];
@@ -686,8 +722,22 @@ VALUES
// The trailing AUDIT-PURGE-ALLOWED marker on the DELETE line below is the
// single narrow exemption the append-only CI guard (AuditLogAppendOnlyGuardTests)
// recognizes; any other UPDATE/DELETE targeting AuditLog still trips the guard.
//
// Hash chain (ScadaBridge#41): a row deleted from the middle of a month's chain must
// leave its link behind or verification reports the gap as tampering. The DELETE
// OUTPUTs every removed row into a table variable and the same transaction writes a
// tombstone (month, ChainSeq, RowHash) for each CHAINED one; the whole batch runs
// under the exclusive chain lock so a chaining pass or a verification never sees the
// row gone without its tombstone. Rows not yet chained need no tombstone.
const string deleteBatchSql =
"DELETE TOP (@batch) FROM dbo.AuditLog WHERE Category = @channel AND OccurredAtUtc < @threshold;"; // AUDIT-PURGE-ALLOWED: per-channel retention override, maintenance path
ChainLockedBatchPrefix +
"DECLARE @purged TABLE (EventId uniqueidentifier NOT NULL, OccurredAtUtc datetime2(7) NOT NULL, ChainSeq bigint NULL, RowHash binary(32) NULL);\n" +
"DELETE TOP (@batch) FROM dbo.AuditLog OUTPUT deleted.EventId, deleted.OccurredAtUtc, deleted.ChainSeq, deleted.RowHash INTO @purged WHERE Category = @channel AND OccurredAtUtc < @threshold;\n" + // AUDIT-PURGE-ALLOWED: per-channel retention override, maintenance path
"SET NOCOUNT ON;\n" +
"INSERT INTO dbo.AuditLogChainTombstone (ChainMonth, ChainSeq, RowHash, EventId, PurgedAtUtc)\n" +
"SELECT DATEFROMPARTS(YEAR(OccurredAtUtc), MONTH(OccurredAtUtc), 1), ChainSeq, RowHash, EventId, SYSUTCDATETIME()\n" +
"FROM @purged WHERE ChainSeq IS NOT NULL AND RowHash IS NOT NULL;\n" +
ChainLockedBatchSuffix;
long totalDeleted = 0;
@@ -1363,8 +1413,16 @@ VALUES
// The trailing AUDIT-PURGE-ALLOWED marker on the UPDATE line below is the
// single narrow exemption the append-only CI guard (AuditLogAppendOnlyGuardTests)
// recognises for an UPDATE; any other UPDATE targeting AuditLog still trips the guard.
//
// Hash chain (ScadaBridge#41): SourceNode is inside every chained row's hash, so the
// backfill touches ONLY unchained rows (ChainSeq IS NULL) and runs under the
// exclusive chain lock — a chaining pass can never hash a row whose SourceNode this
// batch is about to change. Run the backfill BEFORE enabling AuditLog:HashChain;
// once a row is chained its SourceNode is frozen.
const string updateBatchSql =
"UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel WHERE SourceNode IS NULL AND OccurredAtUtc < @before;"; // AUDIT-PURGE-ALLOWED: SourceNode sentinel backfill, maintenance path
ChainLockedBatchPrefix +
"UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel WHERE SourceNode IS NULL AND OccurredAtUtc < @before AND ChainSeq IS NULL;" + // AUDIT-PURGE-ALLOWED: SourceNode sentinel backfill, maintenance path
ChainLockedBatchSuffix;
long totalUpdated = 0;