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