c7dfc21b4c
`dotnet ef migrations script --idempotent` puts each migration in ONE batch behind IF NOT EXISTS guards, and SQL Server compiles the whole batch before any guard runs. AddAuditLogHashChain adds ChainSeq and then creates two filtered indexes whose WHERE clause references it in the same batch, so the script failed with Msg 207 "Invalid column name 'ChainSeq'" on any DB at migration <= 60 (the 2026-11-11 vd03 deploy hand-inserted GO separators instead). - AddAuditLogHashChain: the two CREATE INDEX statements go through EXEC(N'...') so they compile at execution time. Same indexes, same snapshot; Migrate() / database update unaffected; DBs that already ran the migration never run it again. The column-level GRANT compiles fine (checked at execution). - Audit of 58-64: no other migration hits the pattern (the idempotent script from 57 now runs clean end to end; AddConfigGraphRowVersions' plain CREATE INDEX on a new column is not compile-checked). - docs/plans/sql/AddAuditLogHashChain.sql: same EXEC wrap. It, and PrefixSearchIndexes.sql / AddConfigGraphRowVersions.sql, also carried a stray UTF-8 BOM after the prepended header comment that made sqlcmd fail with Msg 102 on line 1 of the batch; removed. - New IdempotentMigrationScriptTests: migrates the fixture DB to 57, runs the generated idempotent script batch by batch, asserts every migration applied, then re-runs it as a no-op. Fails with Msg 207 without the migration fix. Co-Authored-By: Claude <noreply@anthropic.com>
71 lines
3.1 KiB
Transact-SQL
71 lines
3.1 KiB
Transact-SQL
-- 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).
|
|
-- The two filtered indexes reference ChainSeq, which this same batch adds; SQL Server compiles a batch
|
|
-- before running it, so they are wrapped in EXEC to defer compilation (else Msg 207, ScadaBridge#126).
|
|
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])
|
|
);
|
|
|
|
|
|
EXEC(N'CREATE NONCLUSTERED INDEX IX_AuditLog_Unchained
|
|
ON dbo.AuditLog (OccurredAtUtc, EventId)
|
|
WHERE ChainSeq IS NULL
|
|
ON ps_AuditLog_Month(OccurredAtUtc);');
|
|
|
|
|
|
EXEC(N'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
|
|
|