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
@@ -97,8 +97,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
// C5: 10 canonical columns (EventId, OccurredAtUtc, Actor, Action, Outcome,
// Channel[=Category], Target, SourceNode, CorrelationId, DetailsJson) + 6
// persisted computed columns (Kind, Status, SourceSiteId, ExecutionId,
// ParentExecutionId, IngestedAtUtc) = 16.
Assert.Equal(16, properties.Count);
// ParentExecutionId, IngestedAtUtc) + 2 hash-chain link columns (ChainSeq,
// RowHash — ScadaBridge#41) = 18.
Assert.Equal(18, properties.Count);
}
/// <summary>Verifies that the channel property maps to the canonical category column.</summary>
@@ -177,6 +178,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
var expected = new[]
{
"IX_AuditLog_Actor_Occurred",
"IX_AuditLog_ChainSeq", // hash chain (ScadaBridge#41)
"IX_AuditLog_Channel_Status_Occurred",
"IX_AuditLog_CorrelationId",
"IX_AuditLog_Execution",
@@ -186,6 +188,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
"IX_AuditLog_ParentExecution",
"IX_AuditLog_Site_Occurred",
"IX_AuditLog_Target_Occurred",
"IX_AuditLog_Unchained", // hash chain (ScadaBridge#41)
// UX_AuditLog_EventId is intentionally absent — dropped by
// AlignAuditLogEventIdUniqueness; the aligned clustered PK carries it.
};