feat(audit): SHA-256 hash chain over the central AuditLog (ScadaBridge#41)
Audit roadmap T1 (alog.md §11.4): one chain per UTC calendar month of OccurredAtUtc. Each chained row carries ChainSeq (gap-free within its month) and RowHash = SHA-256(prev.RowHash || canonical(row)); the v1 canonical form (AuditChainHasher) is pinned by a known-answer vector computed independently. - Migration AddAuditLogHashChain: nullable ChainSeq/RowHash on dbo.AuditLog (metadata-only add), AuditLogChainHead (per-month chain end + partition-purge checkpoint anchor), AuditLogChainTombstone, two partition-aligned filtered indexes, scadabridge_audit_chainer role with a column-level UPDATE grant. Production script docs/plans/sql/AddAuditLogHashChain.sql. - AuditLogHashChain chains unchained rows oldest month first under an exclusive sp_getapplock (zero timeout, so a dual-active central pair still yields one chain), with an optimistic head check and a ChainSeq IS NULL row-count check that roll a pass back instead of leaving a gap. The ingest path is untouched. - Purges keep the chain verifiable: the partition switch records the month's checkpoint anchor and drops its tombstones; the per-channel DELETE writes a tombstone per chained row in the same transaction (OUTPUT INTO). Both, and the SourceNode backfill, hold the chain lock; the backfill skips chained rows. The switch staging table gains the two new columns. - AuditChainVerifier reports the first break (partition, ChainSeq, EventId, kind) for edited, deleted, reordered rows and tail truncation. - AuditLogHashChainService (central, default OFF via AuditLog:HashChain). Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
-- AddAuditLogHashChain (2026-11-09, ScadaBridge#41). AuditLog hash-chain tamper evidence:
|
||||||
|
-- ChainSeq/RowHash on dbo.AuditLog, AuditLogChainHead + AuditLogChainTombstone, two partition-aligned
|
||||||
|
-- filtered indexes, the scadabridge_audit_chainer role. Apply to the central configuration database
|
||||||
|
-- with both central nodes stopped (old binaries cannot switch out a partition once the columns exist).
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
ALTER TABLE [AuditLog] ADD [ChainSeq] bigint NULL;
|
||||||
|
|
||||||
|
ALTER TABLE [AuditLog] ADD [RowHash] binary(32) NULL;
|
||||||
|
|
||||||
|
CREATE TABLE [AuditLogChainHead] (
|
||||||
|
[ChainMonth] date NOT NULL,
|
||||||
|
[LastSeq] bigint NOT NULL,
|
||||||
|
[LastHash] binary(32) NOT NULL,
|
||||||
|
[PurgedThroughSeq] bigint NULL,
|
||||||
|
[PurgedThroughHash] binary(32) NULL,
|
||||||
|
[UpdatedAtUtc] datetime2 NOT NULL,
|
||||||
|
CONSTRAINT [PK_AuditLogChainHead] PRIMARY KEY ([ChainMonth])
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE [AuditLogChainTombstone] (
|
||||||
|
[ChainMonth] date NOT NULL,
|
||||||
|
[ChainSeq] bigint NOT NULL,
|
||||||
|
[RowHash] binary(32) NOT NULL,
|
||||||
|
[EventId] uniqueidentifier NOT NULL,
|
||||||
|
[PurgedAtUtc] datetime2 NOT NULL,
|
||||||
|
CONSTRAINT [PK_AuditLogChainTombstone] PRIMARY KEY ([ChainMonth], [ChainSeq])
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_Unchained
|
||||||
|
ON dbo.AuditLog (OccurredAtUtc, EventId)
|
||||||
|
WHERE ChainSeq IS NULL
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_ChainSeq
|
||||||
|
ON dbo.AuditLog (ChainSeq)
|
||||||
|
WHERE ChainSeq IS NOT NULL
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);
|
||||||
|
|
||||||
|
|
||||||
|
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_chainer') IS NULL
|
||||||
|
EXEC sp_executesql N'CREATE ROLE scadabridge_audit_chainer';
|
||||||
|
-- The chaining job reads unchained rows and writes ONLY the two link columns.
|
||||||
|
GRANT SELECT ON dbo.AuditLog TO scadabridge_audit_chainer;
|
||||||
|
GRANT UPDATE (ChainSeq, RowHash) ON dbo.AuditLog TO scadabridge_audit_chainer;
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON dbo.AuditLogChainHead TO scadabridge_audit_chainer;
|
||||||
|
GRANT SELECT ON dbo.AuditLogChainTombstone TO scadabridge_audit_chainer;
|
||||||
|
|
||||||
|
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_purger') IS NULL
|
||||||
|
EXEC sp_executesql N'CREATE ROLE scadabridge_audit_purger';
|
||||||
|
-- The per-channel purge writes a tombstone per chained row it removes; the partition
|
||||||
|
-- switch moves the month's head to its checkpoint anchor and drops its tombstones.
|
||||||
|
GRANT SELECT, UPDATE ON dbo.AuditLogChainHead TO scadabridge_audit_purger;
|
||||||
|
GRANT SELECT, INSERT, DELETE ON dbo.AuditLogChainTombstone TO scadabridge_audit_purger;
|
||||||
|
|
||||||
|
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_writer') IS NULL
|
||||||
|
EXEC sp_executesql N'CREATE ROLE scadabridge_audit_writer';
|
||||||
|
-- The ingest principal must never be able to forge chain state.
|
||||||
|
DENY INSERT, UPDATE, DELETE ON dbo.AuditLogChainHead TO scadabridge_audit_writer;
|
||||||
|
DENY INSERT, UPDATE, DELETE ON dbo.AuditLogChainTombstone TO scadabridge_audit_writer;
|
||||||
|
|
||||||
|
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
|
||||||
|
VALUES (N'20261109211317_AddAuditLogHashChain', N'10.0.13');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
GO
|
||||||
|
|
||||||
@@ -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>
|
/// <summary>Configuration section bound to <see cref="AuditLogPartitionMaintenanceOptions"/>.</summary>
|
||||||
public const string PartitionMaintenanceSectionName = "AuditLog:PartitionMaintenance";
|
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>
|
/// <summary>Configuration section bound to <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogPurgeOptions"/>.</summary>
|
||||||
public const string PurgeSectionName = "AuditLog:Purge";
|
public const string PurgeSectionName = "AuditLog:Purge";
|
||||||
|
|
||||||
@@ -395,6 +398,13 @@ public static class ServiceCollectionExtensions
|
|||||||
AuditLogPartitionMaintenanceOptionsValidator>(config, PartitionMaintenanceSectionName);
|
AuditLogPartitionMaintenanceOptionsValidator>(config, PartitionMaintenanceSectionName);
|
||||||
services.AddHostedService<AuditLogPartitionMaintenanceService>();
|
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
|
// I1 (review): bind the two central-singleton options HERE rather than in
|
||||||
// AddAuditLogCentralReconciliationClient. AkkaHostedService.RegisterCentralActors
|
// AddAuditLogCentralReconciliationClient. AkkaHostedService.RegisterCentralActors
|
||||||
// resolves IOptions<AuditLogPurgeOptions> / <SiteAuditReconciliationOptions>
|
// 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
|
/// <c>OccurredAtUtc</c> is strictly older than <paramref name="threshold"/>, in
|
||||||
/// bounded batches of <paramref name="batchSize"/> rows, looping until no further
|
/// bounded batches of <paramref name="batchSize"/> rows, looping until no further
|
||||||
/// rows match. Returns the total number of rows deleted across all batches.
|
/// 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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <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);
|
||||||
+52
@@ -169,6 +169,17 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLog
|
|||||||
stored: false)
|
stored: false)
|
||||||
.ValueGeneratedOnAddOrUpdate();
|
.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 ───────────────────────────────────────────────────
|
// ── Keys + indexes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
// Composite PK includes OccurredAtUtc — required by the monthly partition scheme
|
// 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 })
|
builder.HasIndex(e => new { e.Actor, e.OccurredAtUtc })
|
||||||
.IsDescending(false, true)
|
.IsDescending(false, true)
|
||||||
.HasDatabaseName("IX_AuditLog_Actor_Occurred");
|
.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;
|
: null;
|
||||||
}
|
}
|
||||||
private readonly DateTime? _ingestedAtUtc;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2255
File diff suppressed because it is too large
Load Diff
+144
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -1796,6 +1796,61 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
|||||||
b.ToTable("BundleUnlockAttempt", (string)null);
|
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 =>
|
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("EventId")
|
b.Property<Guid>("EventId")
|
||||||
@@ -1814,6 +1869,9 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
|||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("nvarchar(256)");
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<long?>("ChainSeq")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<string>("Channel")
|
b.Property<string>("Channel")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
@@ -1856,6 +1914,11 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
|||||||
.HasColumnType("uniqueidentifier")
|
.HasColumnType("uniqueidentifier")
|
||||||
.HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true);
|
.HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true);
|
||||||
|
|
||||||
|
b.Property<byte[]>("RowHash")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("binary(32)")
|
||||||
|
.IsFixedLength();
|
||||||
|
|
||||||
b.Property<string>("SourceNode")
|
b.Property<string>("SourceNode")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.IsUnicode(false)
|
.IsUnicode(false)
|
||||||
@@ -1882,6 +1945,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
|||||||
|
|
||||||
b.HasKey("EventId", "OccurredAtUtc");
|
b.HasKey("EventId", "OccurredAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("ChainSeq")
|
||||||
|
.HasDatabaseName("IX_AuditLog_ChainSeq")
|
||||||
|
.HasFilter("[ChainSeq] IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("CorrelationId")
|
b.HasIndex("CorrelationId")
|
||||||
.HasDatabaseName("IX_AuditLog_CorrelationId")
|
.HasDatabaseName("IX_AuditLog_CorrelationId")
|
||||||
.HasFilter("[CorrelationId] IS NOT NULL");
|
.HasFilter("[CorrelationId] IS NOT NULL");
|
||||||
@@ -1906,6 +1973,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
|||||||
.IsDescending(false, true)
|
.IsDescending(false, true)
|
||||||
.HasDatabaseName("IX_AuditLog_Kind_Occurred");
|
.HasDatabaseName("IX_AuditLog_Kind_Occurred");
|
||||||
|
|
||||||
|
b.HasIndex("OccurredAtUtc", "EventId")
|
||||||
|
.HasDatabaseName("IX_AuditLog_Unchained")
|
||||||
|
.HasFilter("[ChainSeq] IS NULL");
|
||||||
|
|
||||||
b.HasIndex("SourceNode", "OccurredAtUtc")
|
b.HasIndex("SourceNode", "OccurredAtUtc")
|
||||||
.HasDatabaseName("IX_AuditLog_Node_Occurred");
|
.HasDatabaseName("IX_AuditLog_Node_Occurred");
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,22 @@ public class AuditLogRepository : IAuditLogRepository
|
|||||||
private const string CanonicalColumnList =
|
private const string CanonicalColumnList =
|
||||||
"EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson";
|
"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 ScadaBridgeDbContext _context;
|
||||||
private readonly ILogger<AuditLogRepository> _logger;
|
private readonly ILogger<AuditLogRepository> _logger;
|
||||||
|
|
||||||
@@ -514,6 +530,13 @@ VALUES
|
|||||||
BEGIN TRY
|
BEGIN TRY
|
||||||
BEGIN TRANSACTION;
|
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
|
-- 1. Defensive cleanup for databases created before
|
||||||
-- AlignAuditLogEventIdUniqueness: the migration drops the
|
-- AlignAuditLogEventIdUniqueness: the migration drops the
|
||||||
-- non-aligned UX_AuditLog_EventId, but a database restored from an
|
-- non-aligned UX_AuditLog_EventId, but a database restored from an
|
||||||
@@ -530,7 +553,8 @@ VALUES
|
|||||||
-- expression text + PERSISTED) or ALTER TABLE ... SWITCH PARTITION
|
-- expression text + PERSISTED) or ALTER TABLE ... SWITCH PARTITION
|
||||||
-- rejects the operation with msg 4904/4948. The ordinal order also
|
-- rejects the operation with msg 4904/4948. The ordinal order also
|
||||||
-- matches dbo.AuditLog_v2 (the CollapseAuditLogToCanonical migration):
|
-- 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}] (
|
CREATE TABLE dbo.[{stagingTableName}] (
|
||||||
EventId uniqueidentifier NOT NULL,
|
EventId uniqueidentifier NOT NULL,
|
||||||
OccurredAtUtc datetime2(7) NOT NULL,
|
OccurredAtUtc datetime2(7) NOT NULL,
|
||||||
@@ -548,6 +572,8 @@ VALUES
|
|||||||
ExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier) PERSISTED,
|
ExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier) PERSISTED,
|
||||||
ParentExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') 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)),
|
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)
|
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
|
||||||
) ON [PRIMARY];
|
) 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}];';
|
DECLARE @sql nvarchar(max) = 'ALTER TABLE dbo.AuditLog SWITCH PARTITION ' + CAST(@partitionNumber AS nvarchar(10)) + ' TO dbo.[{stagingTableName}];';
|
||||||
EXEC sp_executesql @sql;
|
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.
|
-- 4. Drop staging — the rows are discarded here. This is the purge.
|
||||||
DROP TABLE dbo.[{stagingTableName}];
|
DROP TABLE dbo.[{stagingTableName}];
|
||||||
|
|
||||||
@@ -686,8 +722,22 @@ VALUES
|
|||||||
// The trailing AUDIT-PURGE-ALLOWED marker on the DELETE line below is the
|
// The trailing AUDIT-PURGE-ALLOWED marker on the DELETE line below is the
|
||||||
// single narrow exemption the append-only CI guard (AuditLogAppendOnlyGuardTests)
|
// single narrow exemption the append-only CI guard (AuditLogAppendOnlyGuardTests)
|
||||||
// recognizes; any other UPDATE/DELETE targeting AuditLog still trips the guard.
|
// 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 =
|
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;
|
long totalDeleted = 0;
|
||||||
|
|
||||||
@@ -1363,8 +1413,16 @@ VALUES
|
|||||||
// The trailing AUDIT-PURGE-ALLOWED marker on the UPDATE line below is the
|
// The trailing AUDIT-PURGE-ALLOWED marker on the UPDATE line below is the
|
||||||
// single narrow exemption the append-only CI guard (AuditLogAppendOnlyGuardTests)
|
// single narrow exemption the append-only CI guard (AuditLogAppendOnlyGuardTests)
|
||||||
// recognises for an UPDATE; any other UPDATE targeting AuditLog still trips the guard.
|
// 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 =
|
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;
|
long totalUpdated = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
|
|||||||
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
|
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>
|
/// <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>();
|
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>
|
/// <summary>Gets the set of site calls.</summary>
|
||||||
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
|
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ public static class ServiceCollectionExtensions
|
|||||||
// fresh scope on each tick (mirrors AuditLogPurgeActor / AuditLogIngestActor).
|
// fresh scope on each tick (mirrors AuditLogPurgeActor / AuditLogIngestActor).
|
||||||
services.AddScoped<IPartitionMaintenance, AuditLogPartitionMaintenance>();
|
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).
|
// 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
|
// Options binding to configuration is done by the host; without it the defaults apply
|
||||||
// and the purge service is a no-op.
|
// and the purge service is a no-op.
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Policy tests for <see cref="AuditLogHashChainService"/> (ScadaBridge#41) over a scripted
|
||||||
|
/// <see cref="IAuditLogHashChain"/>: default-off, draining a backlog within the per-tick
|
||||||
|
/// cap, yielding to the peer node's lock, and containing failures. The SQL chaining itself
|
||||||
|
/// is covered by <c>AuditLogHashChainTests</c> in ConfigurationDatabase.Tests.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditLogHashChainServiceTests
|
||||||
|
{
|
||||||
|
private sealed class ScriptedChain(params AuditChainBatchResult[] script) : IAuditLogHashChain
|
||||||
|
{
|
||||||
|
private int _next;
|
||||||
|
public int Calls => _next;
|
||||||
|
public Exception? Throw { get; init; }
|
||||||
|
|
||||||
|
public Task<AuditChainBatchResult> ChainPendingAsync(int batchSize, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (Throw is not null) throw Throw;
|
||||||
|
var i = _next++;
|
||||||
|
return Task.FromResult(i < script.Length ? script[i] : new AuditChainBatchResult(0, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AuditChainVerificationResult> VerifyMonthAsync(DateTime monthStartUtc, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditLogHashChainService Create(IAuditLogHashChain chain, AuditLogHashChainOptions options)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped(_ => chain);
|
||||||
|
return new AuditLogHashChainService(
|
||||||
|
services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>(),
|
||||||
|
Options.Create(options),
|
||||||
|
NullLogger<AuditLogHashChainService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The job is off by default and starting it then does nothing.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Disabled_ByDefault_StartsNoLoop()
|
||||||
|
{
|
||||||
|
var chain = new ScriptedChain(new AuditChainBatchResult(5, false));
|
||||||
|
Assert.False(new AuditLogHashChainOptions().Enabled);
|
||||||
|
using var service = Create(chain, new AuditLogHashChainOptions());
|
||||||
|
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
await Task.Delay(50);
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, chain.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A tick drains batches until one comes back empty.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Tick_DrainsUntilEmpty()
|
||||||
|
{
|
||||||
|
var chain = new ScriptedChain(
|
||||||
|
new AuditChainBatchResult(10, false), new AuditChainBatchResult(10, false), new AuditChainBatchResult(3, false));
|
||||||
|
using var service = Create(chain, new AuditLogHashChainOptions { Enabled = true, BatchSize = 10 });
|
||||||
|
|
||||||
|
Assert.Equal(23, await service.TickAsync(CancellationToken.None));
|
||||||
|
Assert.Equal(4, chain.Calls); // the 4th returns 0 and ends the tick
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The peer node holding the lock ends the tick at once.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Tick_StopsWhenPeerHoldsTheLock()
|
||||||
|
{
|
||||||
|
var chain = new ScriptedChain(new AuditChainBatchResult(0, LockBusy: true), new AuditChainBatchResult(10, false));
|
||||||
|
using var service = Create(chain, new AuditLogHashChainOptions { Enabled = true });
|
||||||
|
|
||||||
|
Assert.Equal(0, await service.TickAsync(CancellationToken.None));
|
||||||
|
Assert.Equal(1, chain.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A backlog yields after MaxBatchesPerTick batches.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Tick_RespectsMaxBatchesPerTick()
|
||||||
|
{
|
||||||
|
var chain = new ScriptedChain(Enumerable.Repeat(new AuditChainBatchResult(10, false), 50).ToArray());
|
||||||
|
using var service = Create(chain, new AuditLogHashChainOptions { Enabled = true, MaxBatchesPerTick = 3 });
|
||||||
|
|
||||||
|
Assert.Equal(30, await service.TickAsync(CancellationToken.None));
|
||||||
|
Assert.Equal(3, chain.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A failing tick is contained (logged), never thrown out of the loop.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Tick_ContainsFailures()
|
||||||
|
{
|
||||||
|
var chain = new ScriptedChain { Throw = new InvalidOperationException("sql down") };
|
||||||
|
using var service = Create(chain, new AuditLogHashChainOptions { Enabled = true });
|
||||||
|
|
||||||
|
Assert.Equal(0, await service.TickAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Invalid tuning fails startup validation.</summary>
|
||||||
|
/// <param name="interval">IntervalSeconds.</param>
|
||||||
|
/// <param name="batch">BatchSize.</param>
|
||||||
|
/// <param name="maxBatches">MaxBatchesPerTick.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 1000, 100)]
|
||||||
|
[InlineData(60, 0, 100)]
|
||||||
|
[InlineData(60, 10_001, 100)]
|
||||||
|
[InlineData(60, 1000, 0)]
|
||||||
|
public void Validator_RejectsInvalidTuning(int interval, int batch, int maxBatches)
|
||||||
|
{
|
||||||
|
var result = new AuditLogHashChainOptionsValidator().Validate(null,
|
||||||
|
new AuditLogHashChainOptions { IntervalSeconds = interval, BatchSize = batch, MaxBatchesPerTick = maxBatches });
|
||||||
|
Assert.False(result.Succeeded);
|
||||||
|
Assert.True(new AuditLogHashChainOptionsValidator().Validate(null, new AuditLogHashChainOptions()).Succeeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
|||||||
/// Additionally, the scan is line-oriented: DML where the keyword and table name appear
|
/// Additionally, the scan is line-oriented: DML where the keyword and table name appear
|
||||||
/// on separate lines is an accepted, undetected edge case.
|
/// on separate lines is an accepted, undetected edge case.
|
||||||
///
|
///
|
||||||
/// <b>Allow-list.</b> Two narrow maintenance-path exemptions carry the exact
|
/// <b>Allow-list.</b> Three narrow exemptions carry the exact
|
||||||
/// <see cref="AuditPurgeAllowedMarker"/> trailing comment:
|
/// <see cref="AuditPurgeAllowedMarker"/> trailing comment:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><description>
|
/// <item><description>
|
||||||
@@ -47,6 +47,11 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
|||||||
/// one-time ops procedure; the append-only invariant still applies to all
|
/// one-time ops procedure; the append-only invariant still applies to all
|
||||||
/// other columns and all other UPDATE forms.
|
/// other columns and all other UPDATE forms.
|
||||||
/// </description></item>
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <c>AuditLogHashChain.WriteLinksAsync</c> (ScadaBridge#41): the chaining job's
|
||||||
|
/// <c>UPDATE dbo.AuditLog SET ChainSeq, RowHash</c>, guarded by
|
||||||
|
/// <c>ChainSeq IS NULL</c> — it only ever fills the two hash-chain link columns once.
|
||||||
|
/// </description></item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// The allow-list is applied in the file-scan test only
|
/// The allow-list is applied in the file-scan test only
|
||||||
/// (<see cref="ConfigurationDatabase_ShouldNotContainAuditLogMutations"/>) — the
|
/// (<see cref="ConfigurationDatabase_ShouldNotContainAuditLogMutations"/>) — the
|
||||||
|
|||||||
+5
-2
@@ -97,8 +97,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
|||||||
// C5: 10 canonical columns (EventId, OccurredAtUtc, Actor, Action, Outcome,
|
// C5: 10 canonical columns (EventId, OccurredAtUtc, Actor, Action, Outcome,
|
||||||
// Channel[=Category], Target, SourceNode, CorrelationId, DetailsJson) + 6
|
// Channel[=Category], Target, SourceNode, CorrelationId, DetailsJson) + 6
|
||||||
// persisted computed columns (Kind, Status, SourceSiteId, ExecutionId,
|
// persisted computed columns (Kind, Status, SourceSiteId, ExecutionId,
|
||||||
// ParentExecutionId, IngestedAtUtc) = 16.
|
// ParentExecutionId, IngestedAtUtc) + 2 hash-chain link columns (ChainSeq,
|
||||||
Assert.Equal(16, properties.Count);
|
// RowHash — ScadaBridge#41) = 18.
|
||||||
|
Assert.Equal(18, properties.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that the channel property maps to the canonical category column.</summary>
|
/// <summary>Verifies that the channel property maps to the canonical category column.</summary>
|
||||||
@@ -177,6 +178,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
|||||||
var expected = new[]
|
var expected = new[]
|
||||||
{
|
{
|
||||||
"IX_AuditLog_Actor_Occurred",
|
"IX_AuditLog_Actor_Occurred",
|
||||||
|
"IX_AuditLog_ChainSeq", // hash chain (ScadaBridge#41)
|
||||||
"IX_AuditLog_Channel_Status_Occurred",
|
"IX_AuditLog_Channel_Status_Occurred",
|
||||||
"IX_AuditLog_CorrelationId",
|
"IX_AuditLog_CorrelationId",
|
||||||
"IX_AuditLog_Execution",
|
"IX_AuditLog_Execution",
|
||||||
@@ -186,6 +188,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
|||||||
"IX_AuditLog_ParentExecution",
|
"IX_AuditLog_ParentExecution",
|
||||||
"IX_AuditLog_Site_Occurred",
|
"IX_AuditLog_Site_Occurred",
|
||||||
"IX_AuditLog_Target_Occurred",
|
"IX_AuditLog_Target_Occurred",
|
||||||
|
"IX_AuditLog_Unchained", // hash chain (ScadaBridge#41)
|
||||||
// UX_AuditLog_EventId is intentionally absent — dropped by
|
// UX_AuditLog_EventId is intentionally absent — dropped by
|
||||||
// AlignAuditLogEventIdUniqueness; the aligned clustered PK carries it.
|
// AlignAuditLogEventIdUniqueness; the aligned clustered PK carries it.
|
||||||
};
|
};
|
||||||
|
|||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage-free tests for the AuditLog hash-chain hasher + verifier (ScadaBridge#41):
|
||||||
|
/// tamper detection for an edited, a deleted and a reordered row, tombstone and
|
||||||
|
/// partition-anchor bridging, and a pinned known-answer vector for the v1 canonical form.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditChainVerifierTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Month = new(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
/// <summary>An untouched chain verifies intact and covers every row.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void IntactChain_Verifies()
|
||||||
|
{
|
||||||
|
var (rows, head) = BuildChain(5);
|
||||||
|
var result = Verify(rows, head);
|
||||||
|
|
||||||
|
Assert.Equal(AuditChainStatus.Intact, result.Status);
|
||||||
|
Assert.Equal(5, result.RowsVerified);
|
||||||
|
Assert.Null(result.FirstBreak);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Editing any stored column of a chained row is a HashMismatch at that row.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void EditedRow_IsHashMismatch_AtThatRow()
|
||||||
|
{
|
||||||
|
var (rows, head) = BuildChain(5);
|
||||||
|
rows[2] = rows[2] with { Target = "tampered" };
|
||||||
|
|
||||||
|
var result = Verify(rows, head);
|
||||||
|
|
||||||
|
Assert.Equal(AuditChainStatus.Broken, result.Status);
|
||||||
|
Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind);
|
||||||
|
Assert.Equal(3, result.FirstBreak.ChainSeq);
|
||||||
|
Assert.Equal(rows[2].EventId, result.FirstBreak.EventId);
|
||||||
|
Assert.Equal("2026-05", result.FirstBreak.Partition);
|
||||||
|
Assert.Equal(2, result.RowsVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A row deleted from the middle (no tombstone) is a MissingRow at its sequence number.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DeletedMiddleRow_IsMissingRow()
|
||||||
|
{
|
||||||
|
var (rows, head) = BuildChain(5);
|
||||||
|
var deleted = rows[1];
|
||||||
|
rows.RemoveAt(1);
|
||||||
|
|
||||||
|
var result = Verify(rows, head);
|
||||||
|
|
||||||
|
Assert.Equal(AuditChainStatus.Broken, result.Status);
|
||||||
|
Assert.Equal(AuditChainBreakKind.MissingRow, result.FirstBreak!.Kind);
|
||||||
|
Assert.Equal(deleted.ChainSeq, result.FirstBreak.ChainSeq);
|
||||||
|
Assert.Equal(rows[1].EventId, result.FirstBreak.EventId); // the next surviving row
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deleting the newest rows leaves a self-consistent prefix; the head catches it.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DeletedTailRows_IsHeadMismatch()
|
||||||
|
{
|
||||||
|
var (rows, head) = BuildChain(5);
|
||||||
|
rows.RemoveRange(3, 2);
|
||||||
|
|
||||||
|
var result = Verify(rows, head);
|
||||||
|
|
||||||
|
Assert.Equal(AuditChainStatus.Broken, result.Status);
|
||||||
|
Assert.Equal(AuditChainBreakKind.HeadMismatch, result.FirstBreak!.Kind);
|
||||||
|
Assert.Equal(4, result.FirstBreak.ChainSeq);
|
||||||
|
Assert.Null(result.FirstBreak.EventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Swapping two rows' sequence numbers is a HashMismatch at the first moved row.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void ReorderedRows_IsHashMismatch()
|
||||||
|
{
|
||||||
|
var (rows, head) = BuildChain(5);
|
||||||
|
var a = rows[1];
|
||||||
|
var b = rows[3];
|
||||||
|
rows[1] = b with { ChainSeq = a.ChainSeq };
|
||||||
|
rows[3] = a with { ChainSeq = b.ChainSeq };
|
||||||
|
|
||||||
|
var result = Verify(rows, head);
|
||||||
|
|
||||||
|
Assert.Equal(AuditChainStatus.Broken, result.Status);
|
||||||
|
Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind);
|
||||||
|
Assert.Equal(2, result.FirstBreak.ChainSeq);
|
||||||
|
Assert.Equal(b.EventId, result.FirstBreak.EventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A duplicated sequence number is reported, not silently skipped.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateSequence_IsReported()
|
||||||
|
{
|
||||||
|
var (rows, head) = BuildChain(3);
|
||||||
|
rows.Insert(2, rows[1] with { EventId = Guid.NewGuid() });
|
||||||
|
|
||||||
|
var result = Verify(rows, head);
|
||||||
|
|
||||||
|
Assert.Equal(AuditChainBreakKind.DuplicateSequence, result.FirstBreak!.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A sanctioned per-channel purge leaves a tombstone that bridges the gap.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void TombstonedRow_BridgesTheGap()
|
||||||
|
{
|
||||||
|
var (rows, head) = BuildChain(5);
|
||||||
|
var purged = rows[2];
|
||||||
|
rows.RemoveAt(2);
|
||||||
|
var tombstones = new Dictionary<long, byte[]> { [purged.ChainSeq!.Value] = purged.RowHash! };
|
||||||
|
|
||||||
|
var result = Verify(rows, head, tombstones);
|
||||||
|
|
||||||
|
Assert.Equal(AuditChainStatus.Intact, result.Status);
|
||||||
|
Assert.Equal(4, result.RowsVerified);
|
||||||
|
Assert.Equal(1, result.TombstonesApplied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A tombstone with the wrong hash does not hide a deletion.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void ForgedTombstone_IsDetectedAtTheNextRow()
|
||||||
|
{
|
||||||
|
var (rows, head) = BuildChain(5);
|
||||||
|
var purged = rows[2];
|
||||||
|
rows.RemoveAt(2);
|
||||||
|
var tombstones = new Dictionary<long, byte[]> { [purged.ChainSeq!.Value] = new byte[32] };
|
||||||
|
|
||||||
|
var result = Verify(rows, head, tombstones);
|
||||||
|
|
||||||
|
Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind);
|
||||||
|
Assert.Equal(4, result.FirstBreak.ChainSeq);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>After a partition purge, verification resumes at the checkpoint anchor.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void PartitionPurgeAnchor_VerifiesTheRest()
|
||||||
|
{
|
||||||
|
var (rows, head) = BuildChain(6);
|
||||||
|
var anchor = rows[3];
|
||||||
|
var survivors = rows.Skip(4).ToList();
|
||||||
|
var anchored = head with { PurgedThroughSeq = anchor.ChainSeq, PurgedThroughHash = anchor.RowHash };
|
||||||
|
|
||||||
|
var result = Verify(survivors, anchored);
|
||||||
|
|
||||||
|
Assert.Equal(AuditChainStatus.Intact, result.Status);
|
||||||
|
Assert.Equal(2, result.RowsVerified);
|
||||||
|
Assert.Equal(4, result.PurgedThroughSeq);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A month with neither rows nor head reports NotChained.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void EmptyMonth_IsNotChained()
|
||||||
|
{
|
||||||
|
var result = Verify([], null, unchained: 7);
|
||||||
|
|
||||||
|
Assert.Equal(AuditChainStatus.NotChained, result.Status);
|
||||||
|
Assert.Equal(7, result.UnchainedRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the v1 canonical form: a change to the serialisation silently invalidates every
|
||||||
|
/// chain already in a database, so it must fail a test first. The expected value was
|
||||||
|
/// computed by an independent implementation of the documented v1 encoding (Python
|
||||||
|
/// hashlib/struct), not by this code.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void KnownAnswerVector_V1()
|
||||||
|
{
|
||||||
|
var row = new AuditChainRow(
|
||||||
|
Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e"),
|
||||||
|
new DateTime(2026, 5, 20, 10, 30, 0, DateTimeKind.Utc).AddTicks(1234567),
|
||||||
|
"alice", "ApiOutbound.ApiCall", "Success", "ApiOutbound", "Erp", "central-a",
|
||||||
|
Guid.Parse("7c9e6679-7425-40de-944b-e07fc1f90ae7"), "{\"kind\":\"ApiCall\",\"status\":\"Delivered\"}");
|
||||||
|
|
||||||
|
var hash = AuditChainHasher.ComputeRowHash(AuditChainHasher.GenesisHash, Month, 1, row);
|
||||||
|
|
||||||
|
Assert.Equal("a3137c9961f35f931c7d84de6bced66ce1fe8bbb1dc500f9740b97efd9983ad4", Convert.ToHexStringLower(hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>NULL and the empty string hash differently (presence byte), as do adjacent-field shifts.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Canonical_DistinguishesNullEmptyAndFieldBoundaries()
|
||||||
|
{
|
||||||
|
var baseRow = NewRow(1);
|
||||||
|
var nullTarget = baseRow with { Target = null };
|
||||||
|
var emptyTarget = baseRow with { Target = "" };
|
||||||
|
var shifted = baseRow with { Actor = "ab", Target = "c" };
|
||||||
|
var shifted2 = baseRow with { Actor = "a", Target = "bc" };
|
||||||
|
|
||||||
|
Assert.NotEqual(Hash(nullTarget), Hash(emptyTarget));
|
||||||
|
Assert.NotEqual(Hash(shifted), Hash(shifted2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Hash(AuditChainRow row)
|
||||||
|
=> Convert.ToHexString(AuditChainHasher.ComputeRowHash(AuditChainHasher.GenesisHash, Month, 1, row));
|
||||||
|
|
||||||
|
private static AuditChainVerificationResult Verify(
|
||||||
|
IEnumerable<AuditChainRow> rows,
|
||||||
|
AuditChainHeadState? head,
|
||||||
|
IReadOnlyDictionary<long, byte[]>? tombstones = null,
|
||||||
|
long unchained = 0)
|
||||||
|
{
|
||||||
|
var verifier = new AuditChainVerifier(Month, head, tombstones ?? new Dictionary<long, byte[]>());
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (!verifier.Accept(row))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifier.Complete(unchained);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (List<AuditChainRow> Rows, AuditChainHeadState Head) BuildChain(int count)
|
||||||
|
{
|
||||||
|
var rows = new List<AuditChainRow>();
|
||||||
|
var previous = AuditChainHasher.GenesisHash.ToArray();
|
||||||
|
for (var seq = 1; seq <= count; seq++)
|
||||||
|
{
|
||||||
|
var row = NewRow(seq);
|
||||||
|
var hash = AuditChainHasher.ComputeRowHash(previous, Month, seq, row);
|
||||||
|
rows.Add(row with { ChainSeq = seq, RowHash = hash });
|
||||||
|
previous = hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (rows, new AuditChainHeadState(count, previous, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditChainRow NewRow(int i)
|
||||||
|
=> new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
Month.AddDays(i).AddTicks(i),
|
||||||
|
$"actor-{i}",
|
||||||
|
"ApiOutbound.ApiCall",
|
||||||
|
"Success",
|
||||||
|
"ApiOutbound",
|
||||||
|
$"target-{i}",
|
||||||
|
"central-a",
|
||||||
|
null,
|
||||||
|
$"{{\"kind\":\"ApiCall\",\"n\":{i}}}");
|
||||||
|
}
|
||||||
+346
@@ -0,0 +1,346 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL Server integration tests for the AuditLog hash chain (ScadaBridge#41) against the
|
||||||
|
/// real migrated, partitioned schema: continuity under concurrent writers and chainers,
|
||||||
|
/// detection of an edited / deleted / reordered row, and survival of both purge paths.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The fixture database is shared by the class and the chain is per month across the whole
|
||||||
|
/// table, so every test owns a distinct month (xunit runs one class's tests sequentially).
|
||||||
|
/// Each test drains the chain before tampering so no other month is left unchained.
|
||||||
|
/// </remarks>
|
||||||
|
public class AuditLogHashChainTests : IClassFixture<MsSqlMigrationFixture>
|
||||||
|
{
|
||||||
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Creates the test class with the shared migrated SQL Server fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared migration fixture.</param>
|
||||||
|
public AuditLogHashChainTests(MsSqlMigrationFixture fixture) => _fixture = fixture;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Many concurrent INSERTs (the two central nodes + site ingest) racing two concurrent
|
||||||
|
/// chainers (a dual-active central pair) still produce one gap-free, intact chain.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task ConcurrentWritersAndChainers_ProduceOneIntactGapFreeChain()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
var month = Month(2026, 2);
|
||||||
|
const int writers = 8;
|
||||||
|
const int rowsPerWriter = 40;
|
||||||
|
|
||||||
|
using var stop = new CancellationTokenSource();
|
||||||
|
var lockBusySeen = 0;
|
||||||
|
async Task ChainLoop()
|
||||||
|
{
|
||||||
|
while (!stop.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
var r = await new AuditLogHashChain(ctx).ChainPendingAsync(17);
|
||||||
|
if (r.LockBusy) Interlocked.Increment(ref lockBusySeen);
|
||||||
|
if (r.RowsChained == 0) await Task.Delay(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var chainers = new[] { Task.Run(ChainLoop), Task.Run(ChainLoop) };
|
||||||
|
var writerTasks = Enumerable.Range(0, writers).Select(w => Task.Run(async () =>
|
||||||
|
{
|
||||||
|
for (var i = 0; i < rowsPerWriter; i++)
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
// Out-of-order OccurredAtUtc on purpose: late-arriving site rows.
|
||||||
|
var at = month.AddDays((w * 7 + i * 13) % 27).AddSeconds(i);
|
||||||
|
await new AuditLogRepository(ctx).InsertIfNotExistsAsync(NewEvent(at, sourceNode: $"node-{w}"));
|
||||||
|
}
|
||||||
|
})).ToArray();
|
||||||
|
|
||||||
|
await Task.WhenAll(writerTasks);
|
||||||
|
await Task.Delay(200);
|
||||||
|
await stop.CancelAsync();
|
||||||
|
await Task.WhenAll(chainers);
|
||||||
|
await DrainAsync();
|
||||||
|
|
||||||
|
var result = await VerifyAsync(month);
|
||||||
|
Assert.Equal(AuditChainStatus.Intact, result.Status);
|
||||||
|
Assert.Equal(writers * rowsPerWriter, result.RowsVerified);
|
||||||
|
Assert.Equal(writers * rowsPerWriter, result.HeadSeq);
|
||||||
|
Assert.Equal(0, result.UnchainedRows);
|
||||||
|
|
||||||
|
var seqs = await ChainSeqsAsync(month);
|
||||||
|
Assert.Equal(Enumerable.Range(1, writers * rowsPerWriter).Select(i => (long)i), seqs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Editing a chained row's content is reported at that row.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task EditedRow_IsDetected_WithRowIdAndPartition()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
var month = Month(2026, 3);
|
||||||
|
var events = await SeedAndChainAsync(month, 6);
|
||||||
|
Assert.Equal(AuditChainStatus.Intact, (await VerifyAsync(month)).Status);
|
||||||
|
|
||||||
|
var victim = events[3];
|
||||||
|
await ExecAsync($"UPDATE dbo.AuditLog SET Target = N'forged' WHERE EventId = '{victim}';");
|
||||||
|
|
||||||
|
var result = await VerifyAsync(month);
|
||||||
|
Assert.Equal(AuditChainStatus.Broken, result.Status);
|
||||||
|
Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind);
|
||||||
|
Assert.Equal("2026-03", result.FirstBreak.Partition);
|
||||||
|
Assert.Equal(victim, result.FirstBreak.EventId);
|
||||||
|
Assert.Equal(await SeqOfAsync(victim), result.FirstBreak.ChainSeq);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deleting a chained row outside the purge path is reported as a missing sequence number.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task DeletedRow_IsDetected()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
var month = Month(2026, 4);
|
||||||
|
var events = await SeedAndChainAsync(month, 6);
|
||||||
|
var victim = events[2];
|
||||||
|
var victimSeq = await SeqOfAsync(victim);
|
||||||
|
|
||||||
|
await ExecAsync($"DELETE FROM dbo.AuditLog WHERE EventId = '{victim}';");
|
||||||
|
|
||||||
|
var result = await VerifyAsync(month);
|
||||||
|
Assert.Equal(AuditChainStatus.Broken, result.Status);
|
||||||
|
Assert.Equal(AuditChainBreakKind.MissingRow, result.FirstBreak!.Kind);
|
||||||
|
Assert.Equal(victimSeq, result.FirstBreak.ChainSeq);
|
||||||
|
Assert.Equal("2026-04", result.FirstBreak.Partition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Swapping two rows' chain positions is reported.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task ReorderedRows_AreDetected()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
var month = Month(2026, 5);
|
||||||
|
var events = await SeedAndChainAsync(month, 6);
|
||||||
|
var a = events[1];
|
||||||
|
var b = events[4];
|
||||||
|
var seqA = await SeqOfAsync(a);
|
||||||
|
var seqB = await SeqOfAsync(b);
|
||||||
|
|
||||||
|
await ExecAsync(
|
||||||
|
$"UPDATE dbo.AuditLog SET ChainSeq = CASE WHEN EventId = '{a}' THEN {seqB} ELSE {seqA} END " +
|
||||||
|
$"WHERE EventId IN ('{a}', '{b}');");
|
||||||
|
|
||||||
|
var result = await VerifyAsync(month);
|
||||||
|
Assert.Equal(AuditChainStatus.Broken, result.Status);
|
||||||
|
Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind);
|
||||||
|
Assert.Equal(Math.Min(seqA, seqB), result.FirstBreak.ChainSeq);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The per-channel retention DELETE removes chained rows from the middle of a month and
|
||||||
|
/// the chain still verifies (tombstones); a later chaining pass continues the chain.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task PerChannelPurge_LeavesAVerifiableChain()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
var month = Month(2026, 6);
|
||||||
|
var rows = new List<AuditEvent>();
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
rows.Add(NewEvent(month.AddDays(i + 1),
|
||||||
|
channel: i % 2 == 0 ? AuditChannel.DbOutbound : AuditChannel.ApiOutbound,
|
||||||
|
kind: i % 2 == 0 ? AuditKind.DbWrite : AuditKind.ApiCall));
|
||||||
|
}
|
||||||
|
|
||||||
|
await InsertAsync(rows);
|
||||||
|
await DrainAsync();
|
||||||
|
|
||||||
|
await using (var ctx = CreateContext())
|
||||||
|
{
|
||||||
|
var purged = await new AuditLogRepository(ctx).PurgeChannelOlderThanAsync(
|
||||||
|
nameof(AuditChannel.DbOutbound), month.AddDays(8), batchSize: 2);
|
||||||
|
Assert.Equal(4, purged); // days 1,3,5,7
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await VerifyAsync(month);
|
||||||
|
Assert.Equal(AuditChainStatus.Intact, result.Status);
|
||||||
|
Assert.Equal(4, result.TombstonesApplied);
|
||||||
|
Assert.Equal(6, result.RowsVerified);
|
||||||
|
|
||||||
|
// A row arriving after the purge chains on from the existing head.
|
||||||
|
await InsertAsync([NewEvent(month.AddDays(20))]);
|
||||||
|
await DrainAsync();
|
||||||
|
result = await VerifyAsync(month);
|
||||||
|
Assert.Equal(AuditChainStatus.Intact, result.Status);
|
||||||
|
Assert.Equal(11, result.HeadSeq);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The monthly partition-switch purge removes the whole month; the head's checkpoint
|
||||||
|
/// anchor keeps the month verifiable, including for a late row chained afterwards.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task PartitionSwitchPurge_LeavesAnAnchoredVerifiableChain()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
var month = Month(2026, 7);
|
||||||
|
await SeedAndChainAsync(month, 5);
|
||||||
|
|
||||||
|
await using (var ctx = CreateContext())
|
||||||
|
{
|
||||||
|
await new AuditLogRepository(ctx).SwitchOutPartitionAsync(month);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await VerifyAsync(month);
|
||||||
|
Assert.Equal(AuditChainStatus.Intact, result.Status);
|
||||||
|
Assert.Equal(5, result.PurgedThroughSeq);
|
||||||
|
Assert.Equal(0, result.RowsVerified);
|
||||||
|
|
||||||
|
await InsertAsync([NewEvent(month.AddDays(3))]);
|
||||||
|
await DrainAsync();
|
||||||
|
result = await VerifyAsync(month);
|
||||||
|
Assert.Equal(AuditChainStatus.Intact, result.Status);
|
||||||
|
Assert.Equal(1, result.RowsVerified);
|
||||||
|
Assert.Equal(6, result.HeadSeq);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The SourceNode backfill (the other sanctioned AuditLog UPDATE) must not touch a chained
|
||||||
|
/// row — SourceNode is inside its hash.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task SourceNodeBackfill_SkipsChainedRows()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
var month = Month(2026, 8);
|
||||||
|
await InsertAsync([NewEvent(month.AddDays(2), sourceNode: null)]);
|
||||||
|
await DrainAsync();
|
||||||
|
var unchained = NewEvent(month.AddDays(3), sourceNode: null);
|
||||||
|
|
||||||
|
// A row written after the drain stays unchained until the next pass.
|
||||||
|
await InsertAsync([unchained]);
|
||||||
|
await using (var ctx = CreateContext())
|
||||||
|
{
|
||||||
|
await new AuditLogRepository(ctx).BackfillSourceNodeAsync("unknown", month.AddMonths(1), batchSize: 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
await DrainAsync();
|
||||||
|
var result = await VerifyAsync(month);
|
||||||
|
Assert.Equal(AuditChainStatus.Intact, result.Status);
|
||||||
|
Assert.Equal(2, result.RowsVerified);
|
||||||
|
Assert.Equal("unknown", await ScalarAsync<string>($"SELECT SourceNode FROM dbo.AuditLog WHERE EventId = '{unchained.EventId}';"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static DateTime Month(int year, int month) => new(year, month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private async Task<List<Guid>> SeedAndChainAsync(DateTime month, int count)
|
||||||
|
{
|
||||||
|
var events = Enumerable.Range(1, count).Select(i => NewEvent(month.AddDays(i).AddMinutes(i))).ToList();
|
||||||
|
await InsertAsync(events);
|
||||||
|
await DrainAsync();
|
||||||
|
return events.Select(e => e.EventId).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InsertAsync(IEnumerable<AuditEvent> events)
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(ctx);
|
||||||
|
foreach (var e in events)
|
||||||
|
{
|
||||||
|
await repo.InsertIfNotExistsAsync(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DrainAsync()
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
var chain = new AuditLogHashChain(ctx);
|
||||||
|
for (var i = 0; i < 1000; i++)
|
||||||
|
{
|
||||||
|
var r = await chain.ChainPendingAsync(500);
|
||||||
|
if (r.RowsChained == 0 && !r.LockBusy)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Chain did not drain.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AuditChainVerificationResult> VerifyAsync(DateTime month)
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
return await new AuditLogHashChain(ctx).VerifyMonthAsync(month);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<long> SeqOfAsync(Guid eventId)
|
||||||
|
=> await ScalarAsync<long>($"SELECT ChainSeq FROM dbo.AuditLog WHERE EventId = '{eventId}';");
|
||||||
|
|
||||||
|
private async Task<List<long>> ChainSeqsAsync(DateTime month)
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(_fixture.ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT ChainSeq FROM dbo.AuditLog WHERE OccurredAtUtc >= @s AND OccurredAtUtc < @e ORDER BY ChainSeq;";
|
||||||
|
cmd.Parameters.Add(new SqlParameter("@s", System.Data.SqlDbType.DateTime2) { Value = month });
|
||||||
|
cmd.Parameters.Add(new SqlParameter("@e", System.Data.SqlDbType.DateTime2) { Value = month.AddMonths(1) });
|
||||||
|
var list = new List<long>();
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
list.Add(reader.GetInt64(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecAsync(string sql)
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(_fixture.ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = sql;
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> ScalarAsync<T>(string sql)
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(_fixture.ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = sql;
|
||||||
|
return (T)(await cmd.ExecuteScalarAsync())!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ScadaBridgeDbContext CreateContext()
|
||||||
|
=> new(new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlServer(_fixture.ConnectionString).Options);
|
||||||
|
|
||||||
|
private static AuditEvent NewEvent(
|
||||||
|
DateTime occurredAtUtc,
|
||||||
|
AuditChannel channel = AuditChannel.ApiOutbound,
|
||||||
|
AuditKind kind = AuditKind.ApiCall,
|
||||||
|
string? sourceNode = "central-a") =>
|
||||||
|
ScadaBridgeAuditEventFactory.Create(
|
||||||
|
channel: channel,
|
||||||
|
kind: kind,
|
||||||
|
status: AuditStatus.Delivered,
|
||||||
|
occurredAtUtc: occurredAtUtc,
|
||||||
|
target: "erp",
|
||||||
|
sourceNode: sourceNode,
|
||||||
|
sourceSiteId: "hash-chain-test");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user