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,51 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Options for the central <see cref="AuditLogHashChainService"/> (section
/// <c>AuditLog:HashChain</c>) — hash-chain tamper evidence for the AuditLog
/// (audit roadmap T1, alog.md §11.4, ScadaBridge#41).
/// </summary>
/// <remarks>
/// <para>
/// <b>Default OFF</b> (alog.md §11.4: "Default off … to avoid operational burden"). When
/// enabled, both central nodes run the job; the SQL application lock means only one of
/// them chains at a time, so a dual-active pair still produces one gap-free chain per month.
/// </para>
/// <para>
/// Enabling it on an existing database chains the historical backlog oldest month first,
/// <see cref="BatchSize"/> × <see cref="MaxBatchesPerTick"/> rows per tick, so the first
/// ticks run long. Rows chained late are only protected from the moment they are chained.
/// Run <c>audit backfill-source-node</c> BEFORE enabling: a chained row's SourceNode is frozen.
/// </para>
/// </remarks>
public sealed class AuditLogHashChainOptions
{
/// <summary>Whether the chaining job runs (default false).</summary>
public bool Enabled { get; set; }
/// <summary>Seconds between chaining ticks (default 60).</summary>
public int IntervalSeconds { get; set; } = 60;
/// <summary>Rows linked per transaction (default 1000).</summary>
public int BatchSize { get; set; } = 1000;
/// <summary>Upper bound on batches per tick so a backlog yields between ticks (default 100).</summary>
public int MaxBatchesPerTick { get; set; } = 100;
}
/// <summary>Validates <see cref="AuditLogHashChainOptions"/> at host startup.</summary>
public sealed class AuditLogHashChainOptionsValidator : OptionsValidatorBase<AuditLogHashChainOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, AuditLogHashChainOptions options)
{
builder.RequireThat(options.IntervalSeconds > 0,
$"AuditLog:HashChain:{nameof(AuditLogHashChainOptions.IntervalSeconds)} ({options.IntervalSeconds}) must be > 0.");
builder.RequireThat(options.BatchSize is > 0 and <= 10_000,
$"AuditLog:HashChain:{nameof(AuditLogHashChainOptions.BatchSize)} ({options.BatchSize}) must be between 1 and 10000.");
builder.RequireThat(options.MaxBatchesPerTick > 0,
$"AuditLog:HashChain:{nameof(AuditLogHashChainOptions.MaxBatchesPerTick)} ({options.MaxBatchesPerTick}) must be > 0.");
}
}
@@ -0,0 +1,151 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Central hosted service that links newly written AuditLog rows into their month's
/// SHA-256 hash chain (audit roadmap T1, alog.md §11.4, ScadaBridge#41). The chaining
/// job of alog.md §11.4, run incrementally — every <see cref="AuditLogHashChainOptions.IntervalSeconds"/>
/// rather than only once a month has closed, so a row is tamper-evident within about a
/// minute of reaching central instead of up to a month later.
/// </summary>
/// <remarks>
/// <para>
/// Same shape as <see cref="AuditLogPartitionMaintenanceService"/>: a background loop, a
/// fresh DI scope per tick, every failure logged and contained. Runs on BOTH central nodes;
/// <see cref="IAuditLogHashChain.ChainPendingAsync"/> takes an exclusive SQL application
/// lock with a zero timeout, so the peer's pass skips instead of racing (and correctness
/// does not depend on Akka's view of which node is active).
/// </para>
/// </remarks>
public sealed class AuditLogHashChainService : IHostedService, IDisposable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptions<AuditLogHashChainOptions> _options;
private readonly ILogger<AuditLogHashChainService> _logger;
private CancellationTokenSource? _cts;
private Task? _loop;
/// <summary>Initializes the service.</summary>
/// <param name="scopeFactory">Scope factory used to resolve the scoped chain implementation per tick.</param>
/// <param name="options">Hash-chain options.</param>
/// <param name="logger">Logger.</param>
public AuditLogHashChainService(
IServiceScopeFactory scopeFactory,
IOptions<AuditLogHashChainOptions> options,
ILogger<AuditLogHashChainService> logger)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
if (!_options.Value.Enabled)
{
_logger.LogInformation("AuditLog hash chain disabled (AuditLog:HashChain:Enabled=false); no chaining job started.");
return Task.CompletedTask;
}
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_cts = cts;
var token = cts.Token;
_loop = Task.Run(() => RunLoopAsync(token), CancellationToken.None);
return Task.CompletedTask;
}
private async Task RunLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await TickAsync(ct).ConfigureAwait(false);
try
{
await Task.Delay(TimeSpan.FromSeconds(_options.Value.IntervalSeconds), ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
/// <summary>
/// Runs one tick: chains batches until the backlog is empty, the peer holds the lock,
/// or <see cref="AuditLogHashChainOptions.MaxBatchesPerTick"/> is reached.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>The number of rows chained in this tick.</returns>
internal async Task<long> TickAsync(CancellationToken ct)
{
long total = 0;
try
{
var options = _options.Value;
await using var scope = _scopeFactory.CreateAsyncScope();
var chain = scope.ServiceProvider.GetRequiredService<IAuditLogHashChain>();
for (var i = 0; i < options.MaxBatchesPerTick && !ct.IsCancellationRequested; i++)
{
var result = await chain.ChainPendingAsync(options.BatchSize, ct).ConfigureAwait(false);
total += result.RowsChained;
if (result.LockBusy || result.RowsChained == 0)
{
break;
}
}
if (total > 0)
{
_logger.LogDebug("AuditLog hash chain linked {Rows} rows this tick.", total);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Shutdown.
}
catch (Exception ex)
{
// Contained: a transient SQL failure must not end the loop; the next tick
// resumes from wherever the last committed pass left the chain.
_logger.LogError(ex, "AuditLog hash-chain tick failed after linking {Rows} rows.", total);
}
return total;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Dispose already cancelled the loop.
}
return _loop ?? Task.CompletedTask;
}
/// <inheritdoc />
public void Dispose()
{
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Idempotent.
}
_cts?.Dispose();
}
}
@@ -52,6 +52,9 @@ public static class ServiceCollectionExtensions
/// <summary>Configuration section bound to <see cref="AuditLogPartitionMaintenanceOptions"/>.</summary>
public const string PartitionMaintenanceSectionName = "AuditLog:PartitionMaintenance";
/// <summary>Configuration section bound to <see cref="AuditLogHashChainOptions"/> (ScadaBridge#41).</summary>
public const string HashChainSectionName = "AuditLog:HashChain";
/// <summary>Configuration section bound to <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogPurgeOptions"/>.</summary>
public const string PurgeSectionName = "AuditLog:Purge";
@@ -395,6 +398,13 @@ public static class ServiceCollectionExtensions
AuditLogPartitionMaintenanceOptionsValidator>(config, PartitionMaintenanceSectionName);
services.AddHostedService<AuditLogPartitionMaintenanceService>();
// Hash-chain tamper evidence (ScadaBridge#41). Default OFF; the hosted service is
// always registered and returns from StartAsync without a loop when disabled.
// IAuditLogHashChain is registered by AddConfigurationDatabase (central only).
services.AddValidatedOptions<AuditLogHashChainOptions, AuditLogHashChainOptionsValidator>(
config, HashChainSectionName);
services.AddHostedService<AuditLogHashChainService>();
// I1 (review): bind the two central-singleton options HERE rather than in
// AddAuditLogCentralReconciliationClient. AkkaHostedService.RegisterCentralActors
// resolves IOptions<AuditLogPurgeOptions> / <SiteAuditReconciliationOptions>
@@ -0,0 +1,46 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
/// <summary>
/// Hash-chain tamper evidence for the central <c>AuditLog</c> table (audit roadmap T1,
/// alog.md §11.4, ScadaBridge#41).
/// </summary>
/// <remarks>
/// <para>
/// One chain per <b>partition</b> = UTC calendar month of <c>OccurredAtUtc</c> (the same
/// month the <c>ps_AuditLog_Month</c> scheme partitions on). Each chained row carries a
/// <c>ChainSeq</c> (1, 2, 3 … within its month) and
/// <c>RowHash = SHA-256(prev.RowHash || canonical(row))</c>, where <c>prev</c> is the row
/// at <c>ChainSeq - 1</c> (32 zero bytes for the first). The canonical serialisation
/// covers every writable column plus the month and sequence number.
/// </para>
/// <para>
/// Rows are linked by a <b>chaining job</b>, not by the hot-path writers: two central
/// nodes and the site-ingest path all INSERT concurrently, so a write-time chain would
/// need a global serialisation point on every audit write. The job serialises itself
/// with an exclusive SQL Server application lock, so even a dual-active central pair
/// produces one gap-free chain per month.
/// </para>
/// </remarks>
public interface IAuditLogHashChain
{
/// <summary>
/// Links up to <paramref name="batchSize"/> unchained rows (oldest
/// <c>OccurredAtUtc</c> first) onto the end of their month's chain, in one transaction.
/// Returns immediately with <see cref="AuditChainBatchResult.LockBusy"/> set when another
/// chainer holds the chain lock.
/// </summary>
/// <param name="batchSize">Maximum rows to chain in this pass.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>How many rows were chained, and whether the lock was busy.</returns>
Task<AuditChainBatchResult> ChainPendingAsync(int batchSize, CancellationToken ct = default);
/// <summary>
/// Verifies one month's chain end to end and reports the first break.
/// </summary>
/// <param name="monthStartUtc">The first instant of the month (UTC, day 1, midnight).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The verification result.</returns>
Task<AuditChainVerificationResult> VerifyMonthAsync(DateTime monthStartUtc, CancellationToken ct = default);
}
@@ -155,6 +155,11 @@ public interface IAuditLogRepository
/// <c>OccurredAtUtc</c> is strictly older than <paramref name="threshold"/>, in
/// bounded batches of <paramref name="batchSize"/> rows, looping until no further
/// rows match. Returns the total number of rows deleted across all batches.
/// <para>
/// Hash chain (ScadaBridge#41): every CHAINED row the DELETE removes leaves a
/// <c>dbo.AuditLogChainTombstone</c> (month, ChainSeq, RowHash) written in the same
/// transaction, under the exclusive chain lock, so the month's chain still verifies.
/// </para>
/// </summary>
/// <remarks>
/// <para>
@@ -0,0 +1,100 @@
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Overall outcome of verifying one month's AuditLog hash chain (audit roadmap T1,
/// ScadaBridge#41).
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AuditChainStatus>))]
public enum AuditChainStatus
{
/// <summary>Every chained row, tombstone and the chain head agree.</summary>
Intact,
/// <summary>The chain is broken; <see cref="AuditChainVerificationResult.FirstBreak"/> names where.</summary>
Broken,
/// <summary>The month has no chained rows and no chain head — nothing to verify yet.</summary>
NotChained,
}
/// <summary>How the first chain break manifested.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<AuditChainBreakKind>))]
public enum AuditChainBreakKind
{
/// <summary>
/// A row's recomputed hash differs from its stored <c>RowHash</c>: the row's content
/// was edited, or rows were reordered (their <c>ChainSeq</c> values swapped).
/// </summary>
HashMismatch,
/// <summary>
/// A sequence number is absent and no purge tombstone covers it: a chained row was
/// deleted outside the sanctioned purge path, or its <c>ChainSeq</c> was cleared.
/// </summary>
MissingRow,
/// <summary>Two rows carry the same <c>ChainSeq</c>.</summary>
DuplicateSequence,
/// <summary>A chained row carries a <c>ChainSeq</c> but no <c>RowHash</c> (or vice versa).</summary>
IncompleteLink,
/// <summary>
/// The chain ends before (or after) the sequence/hash the chain head records: rows were
/// deleted from the tail, or the head itself was altered.
/// </summary>
HeadMismatch,
}
/// <summary>
/// The first point at which a month's chain fails verification.
/// </summary>
/// <param name="Partition">The chain partition — the UTC calendar month, formatted <c>YYYY-MM</c>.</param>
/// <param name="ChainSeq">The sequence number at which the break was detected.</param>
/// <param name="EventId">
/// The <c>EventId</c> of the row at (or, for <see cref="AuditChainBreakKind.MissingRow"/>,
/// immediately after) the break; null when no row is present there (tail truncation).
/// </param>
/// <param name="Kind">How the break manifested.</param>
/// <param name="Detail">Human-readable explanation.</param>
public sealed record AuditChainBreak(
string Partition,
long ChainSeq,
Guid? EventId,
AuditChainBreakKind Kind,
string Detail);
/// <summary>
/// Result of <c>audit verify-chain --month YYYY-MM</c>: whether the month's AuditLog hash
/// chain is intact, and if not, the first break.
/// </summary>
/// <param name="Partition">The chain partition verified (<c>YYYY-MM</c>).</param>
/// <param name="Status">Overall outcome.</param>
/// <param name="RowsVerified">Chained rows whose hash was recomputed and matched before the first break (or in total when intact).</param>
/// <param name="TombstonesApplied">Sequence numbers bridged by a sanctioned purge tombstone (per-channel retention).</param>
/// <param name="PurgedThroughSeq">
/// The checkpoint anchor left by a monthly partition purge: the last sequence number the
/// purge removed. Verification starts immediately after it. Null when the month was never purged.
/// </param>
/// <param name="HeadSeq">The last sequence number the chain head records, or null when the month has no head.</param>
/// <param name="UnchainedRows">
/// Rows in the month the chaining job has not reached yet (written since the last tick, or
/// written while chaining was disabled). They are not covered by the chain.
/// </param>
/// <param name="FirstBreak">The first break, or null when <see cref="Status"/> is not <see cref="AuditChainStatus.Broken"/>.</param>
public sealed record AuditChainVerificationResult(
string Partition,
AuditChainStatus Status,
long RowsVerified,
long TombstonesApplied,
long? PurgedThroughSeq,
long? HeadSeq,
long UnchainedRows,
AuditChainBreak? FirstBreak);
/// <summary>Outcome of one chaining pass over unchained AuditLog rows.</summary>
/// <param name="RowsChained">Rows linked into a chain by this pass.</param>
/// <param name="LockBusy">True when another chainer (the peer central node) held the chain lock, so this pass did nothing.</param>
public sealed record AuditChainBatchResult(int RowsChained, bool LockBusy);
@@ -169,6 +169,17 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLog
stored: false)
.ValueGeneratedOnAddOrUpdate();
// ── Hash-chain link (ScadaBridge#41) ─────────────────────────────────
//
// Written only by the chaining job (column-level UPDATE grant to
// scadabridge_audit_chainer; the writer role keeps its table-wide DENY UPDATE).
// binary(32) on SQL Server; BLOB on the SQLite test provider.
builder.Property(e => e.ChainSeq);
builder.Property(e => e.RowHash)
.HasMaxLength(32)
.IsFixedLength();
// ── Keys + indexes ───────────────────────────────────────────────────
// Composite PK includes OccurredAtUtc — required by the monthly partition scheme
@@ -241,5 +252,46 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLog
builder.HasIndex(e => new { e.Actor, e.OccurredAtUtc })
.IsDescending(false, true)
.HasDatabaseName("IX_AuditLog_Actor_Occurred");
// Hash-chain indexes (AddAuditLogHashChain migration, raw SQL ON ps_AuditLog_Month
// so they stay partition-aligned). Unchained: the chaining job's work queue —
// filtered to ChainSeq IS NULL, so it shrinks to the rows written since the last
// tick. ChainSeq: verification streams one month's chain in sequence order.
builder.HasIndex(e => new { e.OccurredAtUtc, e.EventId })
.HasFilter("[ChainSeq] IS NULL")
.HasDatabaseName("IX_AuditLog_Unchained");
builder.HasIndex(e => e.ChainSeq)
.HasFilter("[ChainSeq] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_ChainSeq");
}
}
/// <summary>Maps <see cref="AuditLogChainHead"/> to <c>dbo.AuditLogChainHead</c> (ScadaBridge#41).</summary>
public class AuditLogChainHeadEntityTypeConfiguration : IEntityTypeConfiguration<AuditLogChainHead>
{
/// <summary>Applies the EF Core type configuration for <see cref="AuditLogChainHead"/>.</summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<AuditLogChainHead> builder)
{
builder.ToTable("AuditLogChainHead");
builder.HasKey(e => e.ChainMonth);
builder.Property(e => e.ChainMonth).HasColumnType("date");
builder.Property(e => e.LastHash).HasMaxLength(32).IsFixedLength().IsRequired();
builder.Property(e => e.PurgedThroughHash).HasMaxLength(32).IsFixedLength();
}
}
/// <summary>Maps <see cref="AuditLogChainTombstone"/> to <c>dbo.AuditLogChainTombstone</c> (ScadaBridge#41).</summary>
public class AuditLogChainTombstoneEntityTypeConfiguration : IEntityTypeConfiguration<AuditLogChainTombstone>
{
/// <summary>Applies the EF Core type configuration for <see cref="AuditLogChainTombstone"/>.</summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<AuditLogChainTombstone> builder)
{
builder.ToTable("AuditLogChainTombstone");
builder.HasKey(e => new { e.ChainMonth, e.ChainSeq });
builder.Property(e => e.ChainMonth).HasColumnType("date");
builder.Property(e => e.RowHash).HasMaxLength(32).IsFixedLength().IsRequired();
}
}
@@ -0,0 +1,61 @@
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
/// <summary>
/// One row per chain partition (UTC calendar month) of the AuditLog hash chain
/// (ScadaBridge#41): where the chain currently ends, plus the checkpoint anchor a
/// monthly partition purge leaves behind.
/// </summary>
/// <remarks>
/// The head is what lets verification detect <b>tail truncation</b> — deleting the last
/// rows of a month leaves a chain that is internally consistent but shorter than the
/// head says. It is written by the chaining job (<see cref="LastSeq"/>/<see cref="LastHash"/>)
/// and by the partition-switch purge (<see cref="PurgedThroughSeq"/>/<see cref="PurgedThroughHash"/>).
/// </remarks>
public sealed class AuditLogChainHead
{
/// <summary>First day of the chain's month (UTC). Primary key.</summary>
public DateTime ChainMonth { get; set; }
/// <summary>Sequence number of the last row linked into the chain.</summary>
public long LastSeq { get; set; }
/// <summary>RowHash of the last row linked into the chain (32 bytes).</summary>
public byte[] LastHash { get; set; } = [];
/// <summary>
/// Checkpoint anchor: the last sequence number removed by a monthly partition-switch
/// purge. Verification resumes at <c>PurgedThroughSeq + 1</c> with
/// <see cref="PurgedThroughHash"/> as the previous link. Null when never purged.
/// </summary>
public long? PurgedThroughSeq { get; set; }
/// <summary>RowHash at <see cref="PurgedThroughSeq"/>; null when never purged.</summary>
public byte[]? PurgedThroughHash { get; set; }
/// <summary>UTC time the head last moved.</summary>
public DateTime UpdatedAtUtc { get; set; }
}
/// <summary>
/// The link a sanctioned per-channel retention DELETE removed from the middle of a chain
/// (ScadaBridge#41). Written in the SAME statement batch as the DELETE (via
/// <c>OUTPUT … INTO</c>) so the purge can never remove a chained row without leaving its
/// hash behind; verification bridges the gap with it.
/// </summary>
public sealed class AuditLogChainTombstone
{
/// <summary>First day of the chain's month (UTC). Part of the primary key.</summary>
public DateTime ChainMonth { get; set; }
/// <summary>Sequence number of the purged row. Part of the primary key.</summary>
public long ChainSeq { get; set; }
/// <summary>RowHash of the purged row (32 bytes).</summary>
public byte[] RowHash { get; set; } = [];
/// <summary>The purged row's EventId, kept for the operator's benefit.</summary>
public Guid EventId { get; set; }
/// <summary>UTC time of the purge.</summary>
public DateTime PurgedAtUtc { get; set; }
}
@@ -138,4 +138,19 @@ public sealed record AuditLogRow
: null;
}
private readonly DateTime? _ingestedAtUtc;
// ── Hash-chain link (audit roadmap T1, ScadaBridge#41) ────────────────────
/// <summary>
/// Position of this row in its month's hash chain (1-based, gap-free per UTC calendar
/// month of <see cref="OccurredAtUtc"/>); null until the chaining job reaches the row.
/// Written ONLY by the chaining job — never by the ingest/writer path.
/// </summary>
public long? ChainSeq { get; init; }
/// <summary>
/// <c>SHA-256(prev.RowHash || canonical(row))</c> — 32 bytes; null until chained. See
/// <c>AuditChainHasher</c> for the canonical serialisation.
/// </summary>
public byte[]? RowHash { get; init; }
}
@@ -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;
}
}
@@ -0,0 +1,144 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{
/// <summary>
/// AuditLog hash-chain tamper evidence (audit roadmap T1, alog.md §11.4, ScadaBridge#41):
/// <list type="bullet">
/// <item><c>dbo.AuditLog.ChainSeq</c> (<c>bigint NULL</c>) + <c>RowHash</c>
/// (<c>binary(32) NULL</c>) — nullable columns with no default, so the ADD is a
/// metadata-only change even on a large table. Every existing row starts unchained.</item>
/// <item><c>dbo.AuditLogChainHead</c> (per-month chain end + partition-purge checkpoint
/// anchor) and <c>dbo.AuditLogChainTombstone</c> (links removed by the per-channel
/// retention DELETE).</item>
/// <item>Two partition-aligned filtered indexes (raw SQL, because EF's CreateIndex
/// cannot express <c>ON ps_AuditLog_Month(OccurredAtUtc)</c>): the chaining job's work
/// queue and the verifier's sequence-ordered read. The unchained index initially
/// covers every existing row — on a large production AuditLog consider
/// <c>WITH (ONLINE = ON)</c> (Enterprise edition) in the generated script.</item>
/// <item>A new <c>scadabridge_audit_chainer</c> role with a COLUMN-level UPDATE grant on
/// <c>(ChainSeq, RowHash)</c> only, plus the purger's rights on the two chain tables.
/// The writer role's table-wide <c>DENY UPDATE</c> is untouched, so the ingest
/// principal still cannot write a link.</item>
/// </list>
/// </summary>
public partial class AddAuditLogHashChain : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "ChainSeq",
table: "AuditLog",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<byte[]>(
name: "RowHash",
table: "AuditLog",
type: "binary(32)",
fixedLength: true,
maxLength: 32,
nullable: true);
migrationBuilder.CreateTable(
name: "AuditLogChainHead",
columns: table => new
{
ChainMonth = table.Column<DateTime>(type: "date", nullable: false),
LastSeq = table.Column<long>(type: "bigint", nullable: false),
LastHash = table.Column<byte[]>(type: "binary(32)", fixedLength: true, maxLength: 32, nullable: false),
PurgedThroughSeq = table.Column<long>(type: "bigint", nullable: true),
PurgedThroughHash = table.Column<byte[]>(type: "binary(32)", fixedLength: true, maxLength: 32, nullable: true),
UpdatedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AuditLogChainHead", x => x.ChainMonth);
});
migrationBuilder.CreateTable(
name: "AuditLogChainTombstone",
columns: table => new
{
ChainMonth = table.Column<DateTime>(type: "date", nullable: false),
ChainSeq = table.Column<long>(type: "bigint", nullable: false),
RowHash = table.Column<byte[]>(type: "binary(32)", fixedLength: true, maxLength: 32, nullable: false),
EventId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurgedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AuditLogChainTombstone", x => new { x.ChainMonth, x.ChainSeq });
});
// AuditLog is monthly-partitioned with a partition-switch purge, so both indexes
// MUST be partition-aligned (same pattern as ReviewRemediationIndexes).
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_Unchained
ON dbo.AuditLog (OccurredAtUtc, EventId)
WHERE ChainSeq IS NULL
ON ps_AuditLog_Month(OccurredAtUtc);");
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_ChainSeq
ON dbo.AuditLog (ChainSeq)
WHERE ChainSeq IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);");
migrationBuilder.Sql(@"
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;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_ChainSeq' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_ChainSeq ON dbo.AuditLog;
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Unchained' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_Unchained ON dbo.AuditLog;
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_chainer') IS NOT NULL
BEGIN
REVOKE SELECT ON dbo.AuditLog FROM scadabridge_audit_chainer;
REVOKE UPDATE (ChainSeq, RowHash) ON dbo.AuditLog FROM scadabridge_audit_chainer;
END");
migrationBuilder.DropTable(
name: "AuditLogChainHead");
migrationBuilder.DropTable(
name: "AuditLogChainTombstone");
migrationBuilder.DropColumn(
name: "ChainSeq",
table: "AuditLog");
migrationBuilder.DropColumn(
name: "RowHash",
table: "AuditLog");
}
}
}
@@ -1796,6 +1796,61 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.ToTable("BundleUnlockAttempt", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogChainHead", b =>
{
b.Property<DateTime>("ChainMonth")
.HasColumnType("date");
b.Property<byte[]>("LastHash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("binary(32)")
.IsFixedLength();
b.Property<long>("LastSeq")
.HasColumnType("bigint");
b.Property<byte[]>("PurgedThroughHash")
.HasMaxLength(32)
.HasColumnType("binary(32)")
.IsFixedLength();
b.Property<long?>("PurgedThroughSeq")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("datetime2");
b.HasKey("ChainMonth");
b.ToTable("AuditLogChainHead", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogChainTombstone", b =>
{
b.Property<DateTime>("ChainMonth")
.HasColumnType("date");
b.Property<long>("ChainSeq")
.HasColumnType("bigint");
b.Property<Guid>("EventId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("PurgedAtUtc")
.HasColumnType("datetime2");
b.Property<byte[]>("RowHash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("binary(32)")
.IsFixedLength();
b.HasKey("ChainMonth", "ChainSeq");
b.ToTable("AuditLogChainTombstone", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b =>
{
b.Property<Guid>("EventId")
@@ -1814,6 +1869,9 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<long?>("ChainSeq")
.HasColumnType("bigint");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(32)
@@ -1856,6 +1914,11 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
.HasColumnType("uniqueidentifier")
.HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true);
b.Property<byte[]>("RowHash")
.HasMaxLength(32)
.HasColumnType("binary(32)")
.IsFixedLength();
b.Property<string>("SourceNode")
.HasMaxLength(64)
.IsUnicode(false)
@@ -1882,6 +1945,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.HasKey("EventId", "OccurredAtUtc");
b.HasIndex("ChainSeq")
.HasDatabaseName("IX_AuditLog_ChainSeq")
.HasFilter("[ChainSeq] IS NOT NULL");
b.HasIndex("CorrelationId")
.HasDatabaseName("IX_AuditLog_CorrelationId")
.HasFilter("[CorrelationId] IS NOT NULL");
@@ -1906,6 +1973,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
.IsDescending(false, true)
.HasDatabaseName("IX_AuditLog_Kind_Occurred");
b.HasIndex("OccurredAtUtc", "EventId")
.HasDatabaseName("IX_AuditLog_Unchained")
.HasFilter("[ChainSeq] IS NULL");
b.HasIndex("SourceNode", "OccurredAtUtc")
.HasDatabaseName("IX_AuditLog_Node_Occurred");
@@ -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;
@@ -139,6 +139,10 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
/// <summary>Gets the set of audit log rows (central <c>dbo.AuditLog</c> persistence shape; mapped to/from the canonical record at the repository boundary).</summary>
public DbSet<AuditLogRow> AuditLogs => Set<AuditLogRow>();
/// <summary>Gets the per-month AuditLog hash-chain heads (ScadaBridge#41).</summary>
public DbSet<AuditLogChainHead> AuditLogChainHeads => Set<AuditLogChainHead>();
/// <summary>Gets the AuditLog hash-chain purge tombstones (ScadaBridge#41).</summary>
public DbSet<AuditLogChainTombstone> AuditLogChainTombstones => Set<AuditLogChainTombstone>();
/// <summary>Gets the set of site calls.</summary>
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
@@ -110,6 +110,10 @@ public static class ServiceCollectionExtensions
// fresh scope on each tick (mirrors AuditLogPurgeActor / AuditLogIngestActor).
services.AddScoped<IPartitionMaintenance, AuditLogPartitionMaintenance>();
// AuditLog hash-chain tamper evidence (ScadaBridge#41): chained by the central
// AuditLogHashChainService, verified by the VerifyAuditChain management command.
services.AddScoped<IAuditLogHashChain, AuditLogHashChain>();
// Opt-in config-audit retention (ScadaBridge:ConfigAudit:RetentionDays, default 0 = off).
// Options binding to configuration is done by the host; without it the defaults apply
// and the purge service is a no-op.