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
@@ -0,0 +1,155 @@
using System.Buffers.Binary;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
/// <summary>
/// The stored column values of one <c>dbo.AuditLog</c> row as the hash chain sees them
/// (ScadaBridge#41). Strings are the raw column values exactly as SQL Server returns them
/// (e.g. <see cref="Outcome"/> / <see cref="Category"/> are the stored enum NAMES), so
/// the hash never depends on how a later build happens to map them to CLR enums.
/// </summary>
/// <param name="EventId">The row's EventId.</param>
/// <param name="OccurredAtUtc">The row's OccurredAtUtc (datetime2(7) — full tick precision).</param>
/// <param name="Actor">Actor column.</param>
/// <param name="Action">Action column.</param>
/// <param name="Outcome">Outcome column.</param>
/// <param name="Category">Category (channel) column.</param>
/// <param name="Target">Target column.</param>
/// <param name="SourceNode">SourceNode column.</param>
/// <param name="CorrelationId">CorrelationId column.</param>
/// <param name="DetailsJson">DetailsJson column.</param>
/// <param name="ChainSeq">Stored ChainSeq, or null when unchained.</param>
/// <param name="RowHash">Stored RowHash, or null when unchained.</param>
public sealed record AuditChainRow(
Guid EventId,
DateTime OccurredAtUtc,
string? Actor,
string Action,
string Outcome,
string Category,
string? Target,
string? SourceNode,
Guid? CorrelationId,
string? DetailsJson,
long? ChainSeq = null,
byte[]? RowHash = null);
/// <summary>
/// Canonical serialisation + SHA-256 link computation for the AuditLog hash chain
/// (alog.md §11.4, ScadaBridge#41): <c>RowHash = SHA-256(prev.RowHash || canonical(row))</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Canonical form (v1), all integers big-endian:</b> the ASCII domain tag
/// <c>"ScadaBridge.AuditLog.Chain.v1"</c>; the chain partition (<c>YYYY-MM</c>); the
/// 64-bit <c>ChainSeq</c>; then the ten writable columns in table order — EventId (as its
/// lowercase <c>D</c>-format string), OccurredAtUtc (64-bit ticks), Actor, Action, Outcome,
/// Category, Target, SourceNode, CorrelationId (<c>D</c>-format string), DetailsJson.
/// Every string-valued field is encoded as one presence byte (<c>0</c> = SQL NULL,
/// <c>1</c> = present) followed, when present, by a 32-bit byte length and its UTF-8 bytes —
/// so NULL and the empty string hash differently, and no field boundary is ambiguous.
/// </para>
/// <para>
/// The partition and sequence number are inside the hashed bytes so a row moved to a
/// different position, or into another month's chain, fails verification on its own,
/// independently of the previous-hash link. The computed columns (Kind, Status, …) are
/// derived from DetailsJson, so hashing DetailsJson covers them.
/// </para>
/// <para>
/// Changing anything here invalidates every chain already written. A new form needs a new
/// domain tag and a migration story; never edit v1 in place.
/// </para>
/// </remarks>
public static class AuditChainHasher
{
/// <summary>Length of a link hash in bytes.</summary>
public const int HashLength = 32;
private static readonly byte[] DomainTag = Encoding.ASCII.GetBytes("ScadaBridge.AuditLog.Chain.v1");
/// <summary>The previous-hash value the first row of every chain links to (32 zero bytes).</summary>
public static ReadOnlySpan<byte> GenesisHash => new byte[HashLength];
/// <summary>Returns the first instant (UTC) of the month containing <paramref name="occurredAtUtc"/>.</summary>
/// <param name="occurredAtUtc">A UTC timestamp.</param>
/// <returns>Day 1, 00:00:00 UTC of that month.</returns>
public static DateTime MonthOf(DateTime occurredAtUtc)
=> new(occurredAtUtc.Year, occurredAtUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>Formats a chain partition as <c>YYYY-MM</c>.</summary>
/// <param name="monthStartUtc">The month's first instant.</param>
/// <returns>The partition label.</returns>
public static string PartitionLabel(DateTime monthStartUtc)
=> monthStartUtc.ToString("yyyy-MM", CultureInfo.InvariantCulture);
/// <summary>Computes the link hash for <paramref name="row"/> at <paramref name="chainSeq"/>.</summary>
/// <param name="previousHash">The RowHash of the row at <c>chainSeq - 1</c>, or <see cref="GenesisHash"/>.</param>
/// <param name="monthStartUtc">The chain partition the row belongs to.</param>
/// <param name="chainSeq">The row's position in the chain.</param>
/// <param name="row">The row's stored column values.</param>
/// <returns>The 32-byte SHA-256 link hash.</returns>
public static byte[] ComputeRowHash(ReadOnlySpan<byte> previousHash, DateTime monthStartUtc, long chainSeq, AuditChainRow row)
{
ArgumentNullException.ThrowIfNull(row);
if (previousHash.Length != HashLength)
{
throw new ArgumentException($"Previous hash must be {HashLength} bytes.", nameof(previousHash));
}
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
hash.AppendData(previousHash);
hash.AppendData(Canonicalize(monthStartUtc, chainSeq, row));
return hash.GetHashAndReset();
}
/// <summary>Builds the canonical v1 byte form of <paramref name="row"/> at its chain position.</summary>
/// <param name="monthStartUtc">The chain partition.</param>
/// <param name="chainSeq">The row's position in the chain.</param>
/// <param name="row">The row's stored column values.</param>
/// <returns>The canonical bytes (excluding the previous hash).</returns>
public static byte[] Canonicalize(DateTime monthStartUtc, long chainSeq, AuditChainRow row)
{
ArgumentNullException.ThrowIfNull(row);
using var buffer = new MemoryStream(256 + (row.DetailsJson?.Length ?? 0) * 3);
buffer.Write(DomainTag);
WriteString(buffer, PartitionLabel(monthStartUtc));
WriteInt64(buffer, chainSeq);
WriteString(buffer, row.EventId.ToString("D"));
WriteInt64(buffer, row.OccurredAtUtc.Ticks);
WriteString(buffer, row.Actor);
WriteString(buffer, row.Action);
WriteString(buffer, row.Outcome);
WriteString(buffer, row.Category);
WriteString(buffer, row.Target);
WriteString(buffer, row.SourceNode);
WriteString(buffer, row.CorrelationId?.ToString("D"));
WriteString(buffer, row.DetailsJson);
return buffer.ToArray();
}
private static void WriteInt64(Stream stream, long value)
{
Span<byte> bytes = stackalloc byte[8];
BinaryPrimitives.WriteInt64BigEndian(bytes, value);
stream.Write(bytes);
}
private static void WriteString(Stream stream, string? value)
{
if (value is null)
{
stream.WriteByte(0);
return;
}
stream.WriteByte(1);
var bytes = Encoding.UTF8.GetBytes(value);
Span<byte> length = stackalloc byte[4];
BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length);
stream.Write(length);
stream.Write(bytes);
}
}
@@ -0,0 +1,214 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
/// <summary>A month's chain head as verification sees it (ScadaBridge#41).</summary>
/// <param name="LastSeq">Last sequence number linked.</param>
/// <param name="LastHash">RowHash at <paramref name="LastSeq"/>.</param>
/// <param name="PurgedThroughSeq">Checkpoint anchor left by a partition-switch purge, or null.</param>
/// <param name="PurgedThroughHash">RowHash at <paramref name="PurgedThroughSeq"/>, or null.</param>
public sealed record AuditChainHeadState(
long LastSeq,
byte[] LastHash,
long? PurgedThroughSeq,
byte[]? PurgedThroughHash);
/// <summary>
/// Streaming verifier for one month's AuditLog hash chain (ScadaBridge#41). Feed it the
/// month's chained rows in <c>ChainSeq</c> order via <see cref="Accept"/>, then call
/// <see cref="Complete"/>. Storage-agnostic so the detection rules are unit-testable
/// without SQL Server.
/// </summary>
/// <remarks>
/// <para>
/// Verification starts at the checkpoint anchor (<c>PurgedThroughSeq + 1</c>, previous
/// link <c>PurgedThroughHash</c>) when a partition purge left one, otherwise at sequence 1
/// with the genesis hash. Each row's hash is recomputed from the previous link and its own
/// stored columns; a sequence number with no row is bridged only by a purge tombstone. At
/// the end the chain must land exactly on the head's <c>(LastSeq, LastHash)</c>, which is
/// what catches deletion of the newest rows.
/// </para>
/// <para>
/// Detection, by tampering act: an <b>edited</b> row → <see cref="AuditChainBreakKind.HashMismatch"/>
/// at that row; a <b>deleted</b> row → <see cref="AuditChainBreakKind.MissingRow"/> at its
/// sequence number (or <see cref="AuditChainBreakKind.HeadMismatch"/> if it was the tail);
/// <b>reordered</b> rows (ChainSeq values swapped) → <see cref="AuditChainBreakKind.HashMismatch"/>
/// at the first moved row, because the sequence number is inside the hashed bytes.
/// </para>
/// </remarks>
public sealed class AuditChainVerifier
{
private readonly DateTime _monthStartUtc;
private readonly string _partition;
private readonly AuditChainHeadState? _head;
private readonly IReadOnlyDictionary<long, byte[]> _tombstones;
private readonly long _startAfter;
private byte[] _previous;
private long _expected;
private long _verified;
private long _tombstonesApplied;
private AuditChainBreak? _break;
/// <summary>Initializes a verifier for one month.</summary>
/// <param name="monthStartUtc">The chain partition (first instant of the month, UTC).</param>
/// <param name="head">The month's chain head, or null when none exists.</param>
/// <param name="tombstones">Purge tombstones for the month, keyed by ChainSeq.</param>
public AuditChainVerifier(
DateTime monthStartUtc,
AuditChainHeadState? head,
IReadOnlyDictionary<long, byte[]> tombstones)
{
_monthStartUtc = monthStartUtc;
_partition = AuditChainHasher.PartitionLabel(monthStartUtc);
_head = head;
_tombstones = tombstones ?? throw new ArgumentNullException(nameof(tombstones));
if (head?.PurgedThroughSeq is { } purgedThrough && head.PurgedThroughHash is { } anchor)
{
_startAfter = purgedThrough;
_previous = anchor;
}
else
{
_startAfter = 0;
_previous = AuditChainHasher.GenesisHash.ToArray();
}
_expected = _startAfter + 1;
}
/// <summary>The chain partition label (<c>YYYY-MM</c>).</summary>
public string Partition => _partition;
/// <summary>The checkpoint anchor this verification resumed after, or null.</summary>
public long? PurgedThroughSeq => _head?.PurgedThroughSeq;
/// <summary>True once a break has been found; further rows are ignored.</summary>
public bool IsBroken => _break is not null;
/// <summary>Feeds the next chained row (ascending ChainSeq).</summary>
/// <param name="row">The row; its <see cref="AuditChainRow.ChainSeq"/> must be non-null.</param>
/// <returns>False once a break has been found, so the caller can stop reading.</returns>
public bool Accept(AuditChainRow row)
{
ArgumentNullException.ThrowIfNull(row);
if (_break is not null)
{
return false;
}
var seq = row.ChainSeq ?? throw new ArgumentException("Only chained rows can be verified.", nameof(row));
// Rows at or below a partition-purge anchor cannot exist (the switch removed the
// whole month's partition); ignore rather than mis-report them.
if (seq <= _startAfter)
{
return true;
}
if (seq < _expected)
{
return Fail(seq, row.EventId, AuditChainBreakKind.DuplicateSequence,
$"ChainSeq {seq} appears more than once.");
}
while (_expected < seq)
{
if (!_tombstones.TryGetValue(_expected, out var tombstone))
{
return Fail(_expected, row.EventId, AuditChainBreakKind.MissingRow,
$"ChainSeq {_expected} is missing and no purge tombstone covers it; "
+ $"the next chained row is ChainSeq {seq} (EventId {row.EventId}).");
}
_previous = tombstone;
_tombstonesApplied++;
_expected++;
}
if (row.RowHash is not { Length: AuditChainHasher.HashLength } stored)
{
return Fail(seq, row.EventId, AuditChainBreakKind.IncompleteLink,
$"Row at ChainSeq {seq} has a sequence number but no 32-byte RowHash.");
}
var computed = AuditChainHasher.ComputeRowHash(_previous, _monthStartUtc, seq, row);
if (!computed.AsSpan().SequenceEqual(stored))
{
return Fail(seq, row.EventId, AuditChainBreakKind.HashMismatch,
$"Recomputed hash of the row at ChainSeq {seq} does not match its stored RowHash: "
+ "the row was edited, moved to a different position, or an earlier link was altered.");
}
_previous = stored;
_expected++;
_verified++;
return true;
}
/// <summary>Finishes verification against the chain head.</summary>
/// <param name="unchainedRows">Rows in the month not yet chained (reported, never a break).</param>
/// <returns>The verification result.</returns>
public AuditChainVerificationResult Complete(long unchainedRows)
{
if (_break is null)
{
// A purge tombstone may cover the newest links.
while (_tombstones.TryGetValue(_expected, out var tombstone))
{
_previous = tombstone;
_tombstonesApplied++;
_expected++;
}
var lastSeq = _expected - 1;
if (_head is null)
{
if (lastSeq > _startAfter)
{
Fail(lastSeq, null, AuditChainBreakKind.HeadMismatch,
$"Chained rows exist through ChainSeq {lastSeq} but the month has no chain head.");
}
else
{
return Result(AuditChainStatus.NotChained, unchainedRows);
}
}
else if (lastSeq < _head.LastSeq)
{
Fail(lastSeq + 1, null, AuditChainBreakKind.HeadMismatch,
$"The chain ends at ChainSeq {lastSeq} but the head records {_head.LastSeq}: "
+ $"ChainSeq {lastSeq + 1}..{_head.LastSeq} are missing (rows deleted from the end).");
}
else if (lastSeq > _head.LastSeq)
{
Fail(_head.LastSeq + 1, null, AuditChainBreakKind.HeadMismatch,
$"The chain continues to ChainSeq {lastSeq} past the head's {_head.LastSeq}.");
}
else if (!_previous.AsSpan().SequenceEqual(_head.LastHash))
{
Fail(lastSeq, null, AuditChainBreakKind.HeadMismatch,
$"The hash at ChainSeq {lastSeq} does not match the chain head's recorded hash.");
}
}
return Result(_break is null ? AuditChainStatus.Intact : AuditChainStatus.Broken, unchainedRows);
}
private AuditChainVerificationResult Result(AuditChainStatus status, long unchainedRows)
=> new(
_partition,
status,
_verified,
_tombstonesApplied,
_head?.PurgedThroughSeq,
_head?.LastSeq,
unchainedRows,
_break);
private bool Fail(long seq, Guid? eventId, AuditChainBreakKind kind, string detail)
{
_break ??= new AuditChainBreak(_partition, seq, eventId, kind, detail);
return false;
}
}
@@ -0,0 +1,403 @@
using System.Data;
using System.Data.Common;
using System.Text;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
/// <summary>
/// The SQL Server application lock that serialises every writer of AuditLog hash-chain
/// state (ScadaBridge#41): the chaining job and both purge paths take it EXCLUSIVE;
/// verification takes it SHARED so it reads a chain no writer is half-way through.
/// </summary>
public static class AuditChainLock
{
/// <summary>The <c>sp_getapplock</c> resource name.</summary>
public const string Resource = "ScadaBridge.AuditLogHashChain";
}
/// <summary>
/// SQL Server implementation of <see cref="IAuditLogHashChain"/> (ScadaBridge#41,
/// alog.md §11.4). See the interface for the chain model.
/// </summary>
/// <remarks>
/// <para>
/// <b>Concurrency.</b> Two central nodes and the site-ingest path INSERT into
/// <c>dbo.AuditLog</c> concurrently; none of them touches the chain. The chaining pass runs
/// in one transaction that first takes the exclusive <see cref="AuditChainLock"/> with a
/// zero timeout (the peer node's pass simply skips its tick), then reads the head row with
/// <c>UPDLOCK, HOLDLOCK</c> and advances it with an optimistic <c>LastSeq</c> check. Every
/// link UPDATE is guarded by <c>ChainSeq IS NULL</c> and its row count is checked, so a row
/// purged or chained underneath the pass rolls the whole pass back instead of leaving a gap.
/// A row INSERTed while the pass runs is invisible to it (READ COMMITTED) and is chained on
/// a later pass with a later sequence number — chain order is chaining order, not
/// <c>OccurredAtUtc</c> order, which is why late-arriving site rows need no special case.
/// </para>
/// <para>
/// <b>Append-only.</b> The only AuditLog columns this class writes are <c>ChainSeq</c> and
/// <c>RowHash</c>, and only while they are NULL. In production that UPDATE runs under the
/// <c>scadabridge_audit_chainer</c> role, which holds a COLUMN-level UPDATE grant on just
/// those two columns; the ingest writer role keeps its table-wide DENY UPDATE.
/// </para>
/// </remarks>
public sealed class AuditLogHashChain : IAuditLogHashChain
{
// 4 parameters per row; 200 rows = 800 parameters, far under SQL Server's 2,100 cap.
private const int UpdateChunkRows = 200;
private const string RowColumns =
"EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson";
private readonly ScadaBridgeDbContext _context;
private readonly ILogger<AuditLogHashChain> _logger;
/// <summary>Initializes a new instance of the <see cref="AuditLogHashChain"/> class.</summary>
/// <param name="context">The database context whose connection is used.</param>
/// <param name="logger">Optional logger.</param>
public AuditLogHashChain(ScadaBridgeDbContext context, ILogger<AuditLogHashChain>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger<AuditLogHashChain>.Instance;
}
/// <summary>
/// Command timeout for verification statements, in seconds. A month can hold millions
/// of rows; the management Ask (long-running, 5 min) is the real deadline.
/// </summary>
public int VerifyCommandTimeoutSeconds { get; init; } = 300;
/// <summary>How long verification waits for an in-flight chaining/purge pass to release the lock.</summary>
public TimeSpan VerifyLockTimeout { get; init; } = TimeSpan.FromSeconds(60);
/// <inheritdoc />
public async Task<AuditChainBatchResult> ChainPendingAsync(int batchSize, CancellationToken ct = default)
{
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be > 0.");
}
var conn = _context.Database.GetDbConnection();
var openedHere = await OpenAsync(conn, ct).ConfigureAwait(false);
try
{
await using var tx = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct).ConfigureAwait(false);
if (!await TryAcquireLockAsync(conn, tx, "Exclusive", TimeSpan.Zero, ct).ConfigureAwait(false))
{
await tx.RollbackAsync(ct).ConfigureAwait(false);
return new AuditChainBatchResult(0, LockBusy: true);
}
// Oldest month that still has unchained rows. One month per pass keeps the
// candidate read partition-eliminated and index-ordered (no cross-partition sort).
DateTime? oldest;
await using (var cmd = Command(conn, tx,
"SELECT TOP (1) OccurredAtUtc FROM dbo.AuditLog WHERE ChainSeq IS NULL ORDER BY OccurredAtUtc;"))
{
var scalar = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
oldest = scalar is DateTime dt ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) : null;
}
if (oldest is null)
{
await tx.CommitAsync(ct).ConfigureAwait(false);
return new AuditChainBatchResult(0, LockBusy: false);
}
var month = AuditChainHasher.MonthOf(oldest.Value);
var candidates = new List<AuditChainRow>(Math.Min(batchSize, 4096));
await using (var cmd = Command(conn, tx,
$"SELECT TOP (@batch) {RowColumns} FROM dbo.AuditLog " +
"WHERE ChainSeq IS NULL AND OccurredAtUtc >= @monthStart AND OccurredAtUtc < @monthEnd " +
"ORDER BY OccurredAtUtc, EventId;"))
{
AddInt(cmd, "@batch", batchSize);
AddMonthRange(cmd, month);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
candidates.Add(ReadRow(reader, withLink: false));
}
}
// Current chain end for the month, locked until commit.
long? headSeq = null;
byte[] previous = AuditChainHasher.GenesisHash.ToArray();
await using (var cmd = Command(conn, tx,
"SELECT LastSeq, LastHash FROM dbo.AuditLogChainHead WITH (UPDLOCK, HOLDLOCK) WHERE ChainMonth = @month;"))
{
AddDate(cmd, "@month", month);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (await reader.ReadAsync(ct).ConfigureAwait(false))
{
headSeq = reader.GetInt64(0);
previous = (byte[])reader.GetValue(1);
}
}
var seq = headSeq ?? 0;
var links = new List<(AuditChainRow Row, long Seq, byte[] Hash)>(candidates.Count);
foreach (var row in candidates)
{
seq++;
var hash = AuditChainHasher.ComputeRowHash(previous, month, seq, row);
links.Add((row, seq, hash));
previous = hash;
}
for (var offset = 0; offset < links.Count; offset += UpdateChunkRows)
{
var chunk = links.Skip(offset).Take(UpdateChunkRows).ToList();
var updated = await WriteLinksAsync(conn, tx, chunk, ct).ConfigureAwait(false);
if (updated != chunk.Count)
{
// A candidate was purged (or chained by a pass that bypassed the lock)
// between the read and the write. Linking the rest would leave a gap.
_logger.LogWarning(
"AuditLog hash chain: {Updated} of {Expected} links written for {Partition}; rolling the pass back to retry.",
updated, chunk.Count, AuditChainHasher.PartitionLabel(month));
await tx.RollbackAsync(ct).ConfigureAwait(false);
return new AuditChainBatchResult(0, LockBusy: false);
}
}
if (links.Count > 0 && !await AdvanceHeadAsync(conn, tx, month, headSeq, seq, previous, ct).ConfigureAwait(false))
{
_logger.LogWarning(
"AuditLog hash chain: head for {Partition} moved underneath the pass; rolling back to retry.",
AuditChainHasher.PartitionLabel(month));
await tx.RollbackAsync(ct).ConfigureAwait(false);
return new AuditChainBatchResult(0, LockBusy: false);
}
await tx.CommitAsync(ct).ConfigureAwait(false);
return new AuditChainBatchResult(links.Count, LockBusy: false);
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
}
/// <inheritdoc />
public async Task<AuditChainVerificationResult> VerifyMonthAsync(DateTime monthStartUtc, CancellationToken ct = default)
{
var month = AuditChainHasher.MonthOf(DateTime.SpecifyKind(monthStartUtc, DateTimeKind.Utc));
var conn = _context.Database.GetDbConnection();
var openedHere = await OpenAsync(conn, ct).ConfigureAwait(false);
try
{
await using var tx = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct).ConfigureAwait(false);
if (!await TryAcquireLockAsync(conn, tx, "Shared", VerifyLockTimeout, ct).ConfigureAwait(false))
{
await tx.RollbackAsync(ct).ConfigureAwait(false);
throw new TimeoutException(
$"The AuditLog hash chain was busy (chaining or purge in progress) for {VerifyLockTimeout.TotalSeconds:0}s; retry.");
}
AuditChainHeadState? head = null;
await using (var cmd = Command(conn, tx,
"SELECT LastSeq, LastHash, PurgedThroughSeq, PurgedThroughHash FROM dbo.AuditLogChainHead WHERE ChainMonth = @month;"))
{
AddDate(cmd, "@month", month);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (await reader.ReadAsync(ct).ConfigureAwait(false))
{
head = new AuditChainHeadState(
reader.GetInt64(0),
(byte[])reader.GetValue(1),
reader.IsDBNull(2) ? null : reader.GetInt64(2),
reader.IsDBNull(3) ? null : (byte[])reader.GetValue(3));
}
}
var tombstones = new Dictionary<long, byte[]>();
await using (var cmd = Command(conn, tx,
"SELECT ChainSeq, RowHash FROM dbo.AuditLogChainTombstone WHERE ChainMonth = @month;"))
{
AddDate(cmd, "@month", month);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
tombstones[reader.GetInt64(0)] = (byte[])reader.GetValue(1);
}
}
long unchained;
await using (var cmd = Command(conn, tx,
"SELECT COUNT_BIG(*) FROM dbo.AuditLog WHERE ChainSeq IS NULL AND OccurredAtUtc >= @monthStart AND OccurredAtUtc < @monthEnd;"))
{
cmd.CommandTimeout = VerifyCommandTimeoutSeconds;
AddMonthRange(cmd, month);
unchained = Convert.ToInt64(await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false));
}
var verifier = new AuditChainVerifier(month, head, tombstones);
await using (var cmd = Command(conn, tx,
$"SELECT {RowColumns}, ChainSeq, RowHash FROM dbo.AuditLog " +
"WHERE ChainSeq IS NOT NULL AND OccurredAtUtc >= @monthStart AND OccurredAtUtc < @monthEnd " +
"ORDER BY ChainSeq;"))
{
cmd.CommandTimeout = VerifyCommandTimeoutSeconds;
AddMonthRange(cmd, month);
await using var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess, ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
if (!verifier.Accept(ReadRow(reader, withLink: true)))
{
break;
}
}
}
await tx.CommitAsync(ct).ConfigureAwait(false);
return verifier.Complete(unchained);
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
}
/// <summary>
/// Takes the chain application lock inside <paramref name="tx"/> (released at commit /
/// rollback). Returns false when the lock could not be granted within <paramref name="timeout"/>.
/// </summary>
internal static async Task<bool> TryAcquireLockAsync(
DbConnection conn, DbTransaction tx, string mode, TimeSpan timeout, CancellationToken ct)
{
await using var cmd = Command(conn, tx,
"DECLARE @result int; " +
"EXEC @result = sp_getapplock @Resource = @resource, @LockMode = @mode, @LockOwner = 'Transaction', @LockTimeout = @timeoutMs; " +
"SELECT @result;");
cmd.Parameters.Add(new SqlParameter("@resource", SqlDbType.NVarChar, 255) { Value = AuditChainLock.Resource });
cmd.Parameters.Add(new SqlParameter("@mode", SqlDbType.VarChar, 32) { Value = mode });
AddInt(cmd, "@timeoutMs", (int)Math.Min(int.MaxValue, timeout.TotalMilliseconds));
if (timeout > TimeSpan.Zero)
{
cmd.CommandTimeout = (int)Math.Ceiling(timeout.TotalSeconds) + 30;
}
var result = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false));
return result >= 0;
}
private static async Task<int> WriteLinksAsync(
DbConnection conn, DbTransaction tx, IReadOnlyList<(AuditChainRow Row, long Seq, byte[] Hash)> chunk, CancellationToken ct)
{
var sql = new StringBuilder();
// The ONE sanctioned UPDATE against AuditLog outside the maintenance backfill: it
// writes only the two chain columns, only while they are still NULL.
sql.Append("UPDATE dbo.AuditLog SET ChainSeq = v.ChainSeq, RowHash = v.RowHash\n"); // AUDIT-PURGE-ALLOWED: hash-chain link (ChainSeq/RowHash only, NULL→value), chaining job
sql.Append("FROM dbo.AuditLog INNER JOIN (VALUES ");
await using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
for (var i = 0; i < chunk.Count; i++)
{
if (i > 0)
{
sql.Append(", ");
}
sql.Append($"(@e{i}, @o{i}, @s{i}, @h{i})");
cmd.Parameters.Add(new SqlParameter($"@e{i}", SqlDbType.UniqueIdentifier) { Value = chunk[i].Row.EventId });
cmd.Parameters.Add(new SqlParameter($"@o{i}", SqlDbType.DateTime2) { Scale = 7, Value = chunk[i].Row.OccurredAtUtc });
cmd.Parameters.Add(new SqlParameter($"@s{i}", SqlDbType.BigInt) { Value = chunk[i].Seq });
cmd.Parameters.Add(new SqlParameter($"@h{i}", SqlDbType.Binary, AuditChainHasher.HashLength) { Value = chunk[i].Hash });
}
sql.Append(") AS v (EventId, OccurredAtUtc, ChainSeq, RowHash)\n");
sql.Append("ON dbo.AuditLog.EventId = v.EventId AND dbo.AuditLog.OccurredAtUtc = v.OccurredAtUtc\n");
sql.Append("WHERE dbo.AuditLog.ChainSeq IS NULL;");
cmd.CommandText = sql.ToString();
return await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
private static async Task<bool> AdvanceHeadAsync(
DbConnection conn, DbTransaction tx, DateTime month, long? oldSeq, long newSeq, byte[] newHash, CancellationToken ct)
{
await using var cmd = Command(conn, tx, oldSeq is null
? "INSERT INTO dbo.AuditLogChainHead (ChainMonth, LastSeq, LastHash, UpdatedAtUtc) VALUES (@month, @seq, @hash, SYSUTCDATETIME());"
: "UPDATE dbo.AuditLogChainHead SET LastSeq = @seq, LastHash = @hash, UpdatedAtUtc = SYSUTCDATETIME() " +
"WHERE ChainMonth = @month AND LastSeq = @oldSeq;");
AddDate(cmd, "@month", month);
cmd.Parameters.Add(new SqlParameter("@seq", SqlDbType.BigInt) { Value = newSeq });
cmd.Parameters.Add(new SqlParameter("@hash", SqlDbType.Binary, AuditChainHasher.HashLength) { Value = newHash });
if (oldSeq is not null)
{
cmd.Parameters.Add(new SqlParameter("@oldSeq", SqlDbType.BigInt) { Value = oldSeq.Value });
}
return await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false) == 1;
}
private static AuditChainRow ReadRow(DbDataReader reader, bool withLink)
{
// Ordinals follow RowColumns (+ ChainSeq, RowHash). Read strictly in order — the
// verification reader is opened with SequentialAccess.
var eventId = reader.GetGuid(0);
var occurred = DateTime.SpecifyKind(reader.GetDateTime(1), DateTimeKind.Utc);
var actor = reader.IsDBNull(2) ? null : reader.GetString(2);
var action = reader.GetString(3);
var outcome = reader.GetString(4);
var category = reader.GetString(5);
var target = reader.IsDBNull(6) ? null : reader.GetString(6);
var sourceNode = reader.IsDBNull(7) ? null : reader.GetString(7);
Guid? correlationId = reader.IsDBNull(8) ? null : reader.GetGuid(8);
var details = reader.IsDBNull(9) ? null : reader.GetString(9);
long? chainSeq = null;
byte[]? rowHash = null;
if (withLink)
{
chainSeq = reader.IsDBNull(10) ? null : reader.GetInt64(10);
rowHash = reader.IsDBNull(11) ? null : (byte[])reader.GetValue(11);
}
return new AuditChainRow(eventId, occurred, actor, action, outcome, category, target, sourceNode,
correlationId, details, chainSeq, rowHash);
}
private static DbCommand Command(DbConnection conn, DbTransaction tx, string sql)
{
var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
return cmd;
}
private static void AddInt(DbCommand cmd, string name, int value)
=> cmd.Parameters.Add(new SqlParameter(name, SqlDbType.Int) { Value = value });
private static void AddDate(DbCommand cmd, string name, DateTime month)
=> cmd.Parameters.Add(new SqlParameter(name, SqlDbType.Date) { Value = month.Date });
private static void AddMonthRange(DbCommand cmd, DateTime month)
{
cmd.Parameters.Add(new SqlParameter("@monthStart", SqlDbType.DateTime2) { Scale = 7, Value = month });
cmd.Parameters.Add(new SqlParameter("@monthEnd", SqlDbType.DateTime2) { Scale = 7, Value = month.AddMonths(1) });
}
private static async Task<bool> OpenAsync(DbConnection conn, CancellationToken ct)
{
if (conn.State == ConnectionState.Open)
{
return false;
}
await conn.OpenAsync(ct).ConfigureAwait(false);
return true;
}
}