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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user