From fd4be80913a9bd4b2a75ff79ca0992e3523da52c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 16:28:43 -0500 Subject: [PATCH] feat(audit): SHA-256 hash chain over the central AuditLog (ScadaBridge#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/plans/sql/AddAuditLogHashChain.sql | 68 + .../Central/AuditLogHashChainOptions.cs | 51 + .../Central/AuditLogHashChainService.cs | 151 ++ .../ServiceCollectionExtensions.cs | 10 + .../Repositories/IAuditLogHashChain.cs | 46 + .../Repositories/IAuditLogRepository.cs | 5 + .../Types/Audit/AuditChainVerification.cs | 100 + .../AuditLogEntityTypeConfiguration.cs | 52 + .../Entities/AuditLogChainEntities.cs | 61 + .../Entities/AuditLogRow.cs | 15 + .../Maintenance/AuditChainHasher.cs | 155 ++ .../Maintenance/AuditChainVerifier.cs | 214 ++ .../Maintenance/AuditLogHashChain.cs | 403 +++ ...109211317_AddAuditLogHashChain.Designer.cs | 2255 +++++++++++++++++ .../20261109211317_AddAuditLogHashChain.cs | 144 ++ .../ScadaBridgeDbContextModelSnapshot.cs | 71 + .../Repositories/AuditLogRepository.cs | 64 +- .../ScadaBridgeDbContext.cs | 4 + .../ServiceCollectionExtensions.cs | 4 + .../Central/AuditLogHashChainServiceTests.cs | 125 + .../AuditLogAppendOnlyGuardTests.cs | 7 +- .../AuditLogEntityTypeConfigurationTests.cs | 7 +- .../Maintenance/AuditChainVerifierTests.cs | 244 ++ .../Maintenance/AuditLogHashChainTests.cs | 346 +++ 24 files changed, 4596 insertions(+), 6 deletions(-) create mode 100644 docs/plans/sql/AddAuditLogHashChain.sql create mode 100644 src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainOptions.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainService.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogHashChain.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditChainVerification.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogChainEntities.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainHasher.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainVerifier.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditLogHashChain.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.Designer.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogHashChainServiceTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditChainVerifierTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditLogHashChainTests.cs diff --git a/docs/plans/sql/AddAuditLogHashChain.sql b/docs/plans/sql/AddAuditLogHashChain.sql new file mode 100644 index 00000000..7818ca7c --- /dev/null +++ b/docs/plans/sql/AddAuditLogHashChain.sql @@ -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 + diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainOptions.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainOptions.cs new file mode 100644 index 00000000..db2ff85e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainOptions.cs @@ -0,0 +1,51 @@ +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; + +/// +/// Options for the central (section +/// AuditLog:HashChain) — hash-chain tamper evidence for the AuditLog +/// (audit roadmap T1, alog.md §11.4, ScadaBridge#41). +/// +/// +/// +/// Default OFF (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. +/// +/// +/// Enabling it on an existing database chains the historical backlog oldest month first, +/// × rows per tick, so the first +/// ticks run long. Rows chained late are only protected from the moment they are chained. +/// Run audit backfill-source-node BEFORE enabling: a chained row's SourceNode is frozen. +/// +/// +public sealed class AuditLogHashChainOptions +{ + /// Whether the chaining job runs (default false). + public bool Enabled { get; set; } + + /// Seconds between chaining ticks (default 60). + public int IntervalSeconds { get; set; } = 60; + + /// Rows linked per transaction (default 1000). + public int BatchSize { get; set; } = 1000; + + /// Upper bound on batches per tick so a backlog yields between ticks (default 100). + public int MaxBatchesPerTick { get; set; } = 100; +} + +/// Validates at host startup. +public sealed class AuditLogHashChainOptionsValidator : OptionsValidatorBase +{ + /// + 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."); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainService.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainService.cs new file mode 100644 index 00000000..4a003743 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogHashChainService.cs @@ -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; + +/// +/// 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 +/// 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. +/// +/// +/// +/// Same shape as : a background loop, a +/// fresh DI scope per tick, every failure logged and contained. Runs on BOTH central nodes; +/// 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). +/// +/// +public sealed class AuditLogHashChainService : IHostedService, IDisposable +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IOptions _options; + private readonly ILogger _logger; + private CancellationTokenSource? _cts; + private Task? _loop; + + /// Initializes the service. + /// Scope factory used to resolve the scoped chain implementation per tick. + /// Hash-chain options. + /// Logger. + public AuditLogHashChainService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + 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; + } + } + } + + /// + /// Runs one tick: chains batches until the backlog is empty, the peer holds the lock, + /// or is reached. + /// + /// Cancellation token. + /// The number of rows chained in this tick. + internal async Task TickAsync(CancellationToken ct) + { + long total = 0; + try + { + var options = _options.Value; + await using var scope = _scopeFactory.CreateAsyncScope(); + var chain = scope.ServiceProvider.GetRequiredService(); + 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; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + try + { + _cts?.Cancel(); + } + catch (ObjectDisposedException) + { + // Dispose already cancelled the loop. + } + + return _loop ?? Task.CompletedTask; + } + + /// + public void Dispose() + { + try + { + _cts?.Cancel(); + } + catch (ObjectDisposedException) + { + // Idempotent. + } + + _cts?.Dispose(); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs index 39f7b107..2219173c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs @@ -52,6 +52,9 @@ public static class ServiceCollectionExtensions /// Configuration section bound to . public const string PartitionMaintenanceSectionName = "AuditLog:PartitionMaintenance"; + /// Configuration section bound to (ScadaBridge#41). + public const string HashChainSectionName = "AuditLog:HashChain"; + /// Configuration section bound to . public const string PurgeSectionName = "AuditLog:Purge"; @@ -395,6 +398,13 @@ public static class ServiceCollectionExtensions AuditLogPartitionMaintenanceOptionsValidator>(config, PartitionMaintenanceSectionName); services.AddHostedService(); + // 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( + config, HashChainSectionName); + services.AddHostedService(); + // I1 (review): bind the two central-singleton options HERE rather than in // AddAuditLogCentralReconciliationClient. AkkaHostedService.RegisterCentralActors // resolves IOptions / diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogHashChain.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogHashChain.cs new file mode 100644 index 00000000..077c738f --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogHashChain.cs @@ -0,0 +1,46 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; + +/// +/// Hash-chain tamper evidence for the central AuditLog table (audit roadmap T1, +/// alog.md §11.4, ScadaBridge#41). +/// +/// +/// +/// One chain per partition = UTC calendar month of OccurredAtUtc (the same +/// month the ps_AuditLog_Month scheme partitions on). Each chained row carries a +/// ChainSeq (1, 2, 3 … within its month) and +/// RowHash = SHA-256(prev.RowHash || canonical(row)), where prev is the row +/// at ChainSeq - 1 (32 zero bytes for the first). The canonical serialisation +/// covers every writable column plus the month and sequence number. +/// +/// +/// Rows are linked by a chaining job, 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. +/// +/// +public interface IAuditLogHashChain +{ + /// + /// Links up to unchained rows (oldest + /// OccurredAtUtc first) onto the end of their month's chain, in one transaction. + /// Returns immediately with set when another + /// chainer holds the chain lock. + /// + /// Maximum rows to chain in this pass. + /// Cancellation token. + /// How many rows were chained, and whether the lock was busy. + Task ChainPendingAsync(int batchSize, CancellationToken ct = default); + + /// + /// Verifies one month's chain end to end and reports the first break. + /// + /// The first instant of the month (UTC, day 1, midnight). + /// Cancellation token. + /// The verification result. + Task VerifyMonthAsync(DateTime monthStartUtc, CancellationToken ct = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs index 7812ffe6..8cfe9ea6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs @@ -155,6 +155,11 @@ public interface IAuditLogRepository /// OccurredAtUtc is strictly older than , in /// bounded batches of rows, looping until no further /// rows match. Returns the total number of rows deleted across all batches. + /// + /// Hash chain (ScadaBridge#41): every CHAINED row the DELETE removes leaves a + /// dbo.AuditLogChainTombstone (month, ChainSeq, RowHash) written in the same + /// transaction, under the exclusive chain lock, so the month's chain still verifies. + /// /// /// /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditChainVerification.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditChainVerification.cs new file mode 100644 index 00000000..1f309a28 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditChainVerification.cs @@ -0,0 +1,100 @@ +using System.Text.Json.Serialization; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; + +/// +/// Overall outcome of verifying one month's AuditLog hash chain (audit roadmap T1, +/// ScadaBridge#41). +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AuditChainStatus +{ + /// Every chained row, tombstone and the chain head agree. + Intact, + + /// The chain is broken; names where. + Broken, + + /// The month has no chained rows and no chain head — nothing to verify yet. + NotChained, +} + +/// How the first chain break manifested. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AuditChainBreakKind +{ + /// + /// A row's recomputed hash differs from its stored RowHash: the row's content + /// was edited, or rows were reordered (their ChainSeq values swapped). + /// + HashMismatch, + + /// + /// A sequence number is absent and no purge tombstone covers it: a chained row was + /// deleted outside the sanctioned purge path, or its ChainSeq was cleared. + /// + MissingRow, + + /// Two rows carry the same ChainSeq. + DuplicateSequence, + + /// A chained row carries a ChainSeq but no RowHash (or vice versa). + IncompleteLink, + + /// + /// 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. + /// + HeadMismatch, +} + +/// +/// The first point at which a month's chain fails verification. +/// +/// The chain partition — the UTC calendar month, formatted YYYY-MM. +/// The sequence number at which the break was detected. +/// +/// The EventId of the row at (or, for , +/// immediately after) the break; null when no row is present there (tail truncation). +/// +/// How the break manifested. +/// Human-readable explanation. +public sealed record AuditChainBreak( + string Partition, + long ChainSeq, + Guid? EventId, + AuditChainBreakKind Kind, + string Detail); + +/// +/// Result of audit verify-chain --month YYYY-MM: whether the month's AuditLog hash +/// chain is intact, and if not, the first break. +/// +/// The chain partition verified (YYYY-MM). +/// Overall outcome. +/// Chained rows whose hash was recomputed and matched before the first break (or in total when intact). +/// Sequence numbers bridged by a sanctioned purge tombstone (per-channel retention). +/// +/// 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. +/// +/// The last sequence number the chain head records, or null when the month has no head. +/// +/// 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. +/// +/// The first break, or null when is not . +public sealed record AuditChainVerificationResult( + string Partition, + AuditChainStatus Status, + long RowsVerified, + long TombstonesApplied, + long? PurgedThroughSeq, + long? HeadSeq, + long UnchainedRows, + AuditChainBreak? FirstBreak); + +/// Outcome of one chaining pass over unchained AuditLog rows. +/// Rows linked into a chain by this pass. +/// True when another chainer (the peer central node) held the chain lock, so this pass did nothing. +public sealed record AuditChainBatchResult(int RowsChained, bool LockBusy); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs index dc1bc4fa..f58fee97 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs @@ -169,6 +169,17 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration e.ChainSeq); + + builder.Property(e => e.RowHash) + .HasMaxLength(32) + .IsFixedLength(); + // ── Keys + indexes ─────────────────────────────────────────────────── // Composite PK includes OccurredAtUtc — required by the monthly partition scheme @@ -241,5 +252,46 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration new { e.Actor, e.OccurredAtUtc }) .IsDescending(false, true) .HasDatabaseName("IX_AuditLog_Actor_Occurred"); + + // Hash-chain indexes (AddAuditLogHashChain migration, raw SQL ON ps_AuditLog_Month + // so they stay partition-aligned). Unchained: the chaining job's work queue — + // filtered to ChainSeq IS NULL, so it shrinks to the rows written since the last + // tick. ChainSeq: verification streams one month's chain in sequence order. + builder.HasIndex(e => new { e.OccurredAtUtc, e.EventId }) + .HasFilter("[ChainSeq] IS NULL") + .HasDatabaseName("IX_AuditLog_Unchained"); + + builder.HasIndex(e => e.ChainSeq) + .HasFilter("[ChainSeq] IS NOT NULL") + .HasDatabaseName("IX_AuditLog_ChainSeq"); + } +} + +/// Maps to dbo.AuditLogChainHead (ScadaBridge#41). +public class AuditLogChainHeadEntityTypeConfiguration : IEntityTypeConfiguration +{ + /// Applies the EF Core type configuration for . + /// The entity type builder to configure. + public void Configure(EntityTypeBuilder 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(); + } +} + +/// Maps to dbo.AuditLogChainTombstone (ScadaBridge#41). +public class AuditLogChainTombstoneEntityTypeConfiguration : IEntityTypeConfiguration +{ + /// Applies the EF Core type configuration for . + /// The entity type builder to configure. + public void Configure(EntityTypeBuilder 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(); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogChainEntities.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogChainEntities.cs new file mode 100644 index 00000000..a2584408 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogChainEntities.cs @@ -0,0 +1,61 @@ +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities; + +/// +/// 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. +/// +/// +/// The head is what lets verification detect tail truncation — 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 (/) +/// and by the partition-switch purge (/). +/// +public sealed class AuditLogChainHead +{ + /// First day of the chain's month (UTC). Primary key. + public DateTime ChainMonth { get; set; } + + /// Sequence number of the last row linked into the chain. + public long LastSeq { get; set; } + + /// RowHash of the last row linked into the chain (32 bytes). + public byte[] LastHash { get; set; } = []; + + /// + /// Checkpoint anchor: the last sequence number removed by a monthly partition-switch + /// purge. Verification resumes at PurgedThroughSeq + 1 with + /// as the previous link. Null when never purged. + /// + public long? PurgedThroughSeq { get; set; } + + /// RowHash at ; null when never purged. + public byte[]? PurgedThroughHash { get; set; } + + /// UTC time the head last moved. + public DateTime UpdatedAtUtc { get; set; } +} + +/// +/// 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 +/// OUTPUT … INTO) so the purge can never remove a chained row without leaving its +/// hash behind; verification bridges the gap with it. +/// +public sealed class AuditLogChainTombstone +{ + /// First day of the chain's month (UTC). Part of the primary key. + public DateTime ChainMonth { get; set; } + + /// Sequence number of the purged row. Part of the primary key. + public long ChainSeq { get; set; } + + /// RowHash of the purged row (32 bytes). + public byte[] RowHash { get; set; } = []; + + /// The purged row's EventId, kept for the operator's benefit. + public Guid EventId { get; set; } + + /// UTC time of the purge. + public DateTime PurgedAtUtc { get; set; } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs index 200d6404..83a9ef2b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs @@ -138,4 +138,19 @@ public sealed record AuditLogRow : null; } private readonly DateTime? _ingestedAtUtc; + + // ── Hash-chain link (audit roadmap T1, ScadaBridge#41) ──────────────────── + + /// + /// Position of this row in its month's hash chain (1-based, gap-free per UTC calendar + /// month of ); null until the chaining job reaches the row. + /// Written ONLY by the chaining job — never by the ingest/writer path. + /// + public long? ChainSeq { get; init; } + + /// + /// SHA-256(prev.RowHash || canonical(row)) — 32 bytes; null until chained. See + /// AuditChainHasher for the canonical serialisation. + /// + public byte[]? RowHash { get; init; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainHasher.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainHasher.cs new file mode 100644 index 00000000..65b18efc --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainHasher.cs @@ -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; + +/// +/// The stored column values of one dbo.AuditLog row as the hash chain sees them +/// (ScadaBridge#41). Strings are the raw column values exactly as SQL Server returns them +/// (e.g. / are the stored enum NAMES), so +/// the hash never depends on how a later build happens to map them to CLR enums. +/// +/// The row's EventId. +/// The row's OccurredAtUtc (datetime2(7) — full tick precision). +/// Actor column. +/// Action column. +/// Outcome column. +/// Category (channel) column. +/// Target column. +/// SourceNode column. +/// CorrelationId column. +/// DetailsJson column. +/// Stored ChainSeq, or null when unchained. +/// Stored RowHash, or null when unchained. +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); + +/// +/// Canonical serialisation + SHA-256 link computation for the AuditLog hash chain +/// (alog.md §11.4, ScadaBridge#41): RowHash = SHA-256(prev.RowHash || canonical(row)). +/// +/// +/// +/// Canonical form (v1), all integers big-endian: the ASCII domain tag +/// "ScadaBridge.AuditLog.Chain.v1"; the chain partition (YYYY-MM); the +/// 64-bit ChainSeq; then the ten writable columns in table order — EventId (as its +/// lowercase D-format string), OccurredAtUtc (64-bit ticks), Actor, Action, Outcome, +/// Category, Target, SourceNode, CorrelationId (D-format string), DetailsJson. +/// Every string-valued field is encoded as one presence byte (0 = SQL NULL, +/// 1 = 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. +/// +/// +/// 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. +/// +/// +/// 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. +/// +/// +public static class AuditChainHasher +{ + /// Length of a link hash in bytes. + public const int HashLength = 32; + + private static readonly byte[] DomainTag = Encoding.ASCII.GetBytes("ScadaBridge.AuditLog.Chain.v1"); + + /// The previous-hash value the first row of every chain links to (32 zero bytes). + public static ReadOnlySpan GenesisHash => new byte[HashLength]; + + /// Returns the first instant (UTC) of the month containing . + /// A UTC timestamp. + /// Day 1, 00:00:00 UTC of that month. + public static DateTime MonthOf(DateTime occurredAtUtc) + => new(occurredAtUtc.Year, occurredAtUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc); + + /// Formats a chain partition as YYYY-MM. + /// The month's first instant. + /// The partition label. + public static string PartitionLabel(DateTime monthStartUtc) + => monthStartUtc.ToString("yyyy-MM", CultureInfo.InvariantCulture); + + /// Computes the link hash for at . + /// The RowHash of the row at chainSeq - 1, or . + /// The chain partition the row belongs to. + /// The row's position in the chain. + /// The row's stored column values. + /// The 32-byte SHA-256 link hash. + public static byte[] ComputeRowHash(ReadOnlySpan 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(); + } + + /// Builds the canonical v1 byte form of at its chain position. + /// The chain partition. + /// The row's position in the chain. + /// The row's stored column values. + /// The canonical bytes (excluding the previous hash). + 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 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 length = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length); + stream.Write(length); + stream.Write(bytes); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainVerifier.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainVerifier.cs new file mode 100644 index 00000000..2551cecb --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainVerifier.cs @@ -0,0 +1,214 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance; + +/// A month's chain head as verification sees it (ScadaBridge#41). +/// Last sequence number linked. +/// RowHash at . +/// Checkpoint anchor left by a partition-switch purge, or null. +/// RowHash at , or null. +public sealed record AuditChainHeadState( + long LastSeq, + byte[] LastHash, + long? PurgedThroughSeq, + byte[]? PurgedThroughHash); + +/// +/// Streaming verifier for one month's AuditLog hash chain (ScadaBridge#41). Feed it the +/// month's chained rows in ChainSeq order via , then call +/// . Storage-agnostic so the detection rules are unit-testable +/// without SQL Server. +/// +/// +/// +/// Verification starts at the checkpoint anchor (PurgedThroughSeq + 1, previous +/// link PurgedThroughHash) 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 (LastSeq, LastHash), which is +/// what catches deletion of the newest rows. +/// +/// +/// Detection, by tampering act: an edited row → +/// at that row; a deleted row → at its +/// sequence number (or if it was the tail); +/// reordered rows (ChainSeq values swapped) → +/// at the first moved row, because the sequence number is inside the hashed bytes. +/// +/// +public sealed class AuditChainVerifier +{ + private readonly DateTime _monthStartUtc; + private readonly string _partition; + private readonly AuditChainHeadState? _head; + private readonly IReadOnlyDictionary _tombstones; + private readonly long _startAfter; + private byte[] _previous; + private long _expected; + private long _verified; + private long _tombstonesApplied; + private AuditChainBreak? _break; + + /// Initializes a verifier for one month. + /// The chain partition (first instant of the month, UTC). + /// The month's chain head, or null when none exists. + /// Purge tombstones for the month, keyed by ChainSeq. + public AuditChainVerifier( + DateTime monthStartUtc, + AuditChainHeadState? head, + IReadOnlyDictionary 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; + } + + /// The chain partition label (YYYY-MM). + public string Partition => _partition; + + /// The checkpoint anchor this verification resumed after, or null. + public long? PurgedThroughSeq => _head?.PurgedThroughSeq; + + /// True once a break has been found; further rows are ignored. + public bool IsBroken => _break is not null; + + /// Feeds the next chained row (ascending ChainSeq). + /// The row; its must be non-null. + /// False once a break has been found, so the caller can stop reading. + 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; + } + + /// Finishes verification against the chain head. + /// Rows in the month not yet chained (reported, never a break). + /// The verification result. + 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; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditLogHashChain.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditLogHashChain.cs new file mode 100644 index 00000000..701e3425 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditLogHashChain.cs @@ -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; + +/// +/// 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. +/// +public static class AuditChainLock +{ + /// The sp_getapplock resource name. + public const string Resource = "ScadaBridge.AuditLogHashChain"; +} + +/// +/// SQL Server implementation of (ScadaBridge#41, +/// alog.md §11.4). See the interface for the chain model. +/// +/// +/// +/// Concurrency. Two central nodes and the site-ingest path INSERT into +/// dbo.AuditLog concurrently; none of them touches the chain. The chaining pass runs +/// in one transaction that first takes the exclusive with a +/// zero timeout (the peer node's pass simply skips its tick), then reads the head row with +/// UPDLOCK, HOLDLOCK and advances it with an optimistic LastSeq check. Every +/// link UPDATE is guarded by ChainSeq IS NULL 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 +/// OccurredAtUtc order, which is why late-arriving site rows need no special case. +/// +/// +/// Append-only. The only AuditLog columns this class writes are ChainSeq and +/// RowHash, and only while they are NULL. In production that UPDATE runs under the +/// scadabridge_audit_chainer role, which holds a COLUMN-level UPDATE grant on just +/// those two columns; the ingest writer role keeps its table-wide DENY UPDATE. +/// +/// +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 _logger; + + /// Initializes a new instance of the class. + /// The database context whose connection is used. + /// Optional logger. + public AuditLogHashChain(ScadaBridgeDbContext context, ILogger? logger = null) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? NullLogger.Instance; + } + + /// + /// 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. + /// + public int VerifyCommandTimeoutSeconds { get; init; } = 300; + + /// How long verification waits for an in-flight chaining/purge pass to release the lock. + public TimeSpan VerifyLockTimeout { get; init; } = TimeSpan.FromSeconds(60); + + /// + public async Task 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(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); + } + } + } + + /// + public async Task 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(); + 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); + } + } + } + + /// + /// Takes the chain application lock inside (released at commit / + /// rollback). Returns false when the lock could not be granted within . + /// + internal static async Task 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 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 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 OpenAsync(DbConnection conn, CancellationToken ct) + { + if (conn.State == ConnectionState.Open) + { + return false; + } + + await conn.OpenAsync(ct).ConfigureAwait(false); + return true; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.Designer.cs new file mode 100644 index 00000000..ef68e4ca --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.Designer.cs @@ -0,0 +1,2255 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaBridgeDbContext))] + [Migration("20261109211317_AddAuditLogHashChain")] + partial class AddAuditLogHashChain + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("BundleImportId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("BundleImportId") + .HasDatabaseName("IX_AuditLogEntries_BundleImportId"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityName") + .HasDatabaseName("IX_AuditLogEntries_EntityName"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.HasIndex("EntityType", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLogEntries_EntityType_Timestamp"); + + b.HasIndex("Timestamp", "Id") + .IsDescending() + .HasDatabaseName("IX_AuditLogEntries_Timestamp_Id"); + + b.HasIndex("User", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLogEntries_User_Timestamp"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("IX_SiteCalls_NonTerminal") + .HasFilter("[TerminalAtUtc] IS NULL"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("CreatedAtUtc"), new[] { "SourceSite", "SourceNode", "Status" }); + + b.HasIndex("TerminalAtUtc") + .HasDatabaseName("IX_SiteCalls_Terminal") + .HasFilter("[TerminalAtUtc] IS NOT NULL"); + + b.HasIndex("CreatedAtUtc", "TrackedOperationId") + .IsDescending() + .HasDatabaseName("IX_SiteCalls_Created_TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedBy") + .HasDatabaseName("IX_DeploymentRecords_DeployedBy"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.HasIndex("RevisionHash") + .HasDatabaseName("IX_DeploymentRecords_RevisionHash"); + + b.HasIndex("DeployedAt", "Id") + .IsDescending() + .HasDatabaseName("IX_DeploymentRecords_DeployedAt_Id"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.PendingDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("InstanceId"); + + b.ToTable("PendingDeployments", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.Property("TraceAllCalls") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("IsMesReportableOverride") + .HasColumnType("bit"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("DataSourceReferenceOverride") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilterOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionNameOverride") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("IsMesReportableOverride") + .HasColumnType("bit"); + + b.Property("SourceCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("SourceReferenceOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "SourceCanonicalName") + .IsUnique(); + + b.ToTable("InstanceNativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiRollupHourly", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("HourStartUtc") + .HasColumnType("datetime2"); + + b.Property("MaxValue") + .HasColumnType("float"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("MinValue") + .HasColumnType("float"); + + b.Property("SampleCount") + .HasColumnType("int"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ScopeKey") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Value") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("HourStartUtc") + .HasDatabaseName("IX_KpiRollupHourly_Hour"); + + b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "HourStartUtc") + .IsUnique() + .HasDatabaseName("IX_KpiRollupHourly_Series"); + + b.ToTable("KpiRollupHourly", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CapturedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ScopeKey") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Value") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAtUtc") + .HasDatabaseName("IX_KpiSample_Captured"); + + b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "CapturedAtUtc") + .HasDatabaseName("IX_KpiSample_Series"); + + b.ToTable("KpiSample", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("DeliveredAt") + .HasDatabaseName("IX_Notifications_Delivered") + .HasFilter("[Status] = 'Delivered'"); + + b.HasIndex("CreatedAt", "NotificationId") + .IsDescending() + .HasDatabaseName("IX_Notifications_Created_NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.Property("PhoneNumber") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmsConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountSid") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ApiBaseUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("AuthToken") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("FromNumber") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("MessagingServiceSid") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.ToTable("SmsConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("OAuth2Authority") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("OAuth2Scope") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Transport") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas.SharedSchema", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SchemaJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Scope") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedSchemas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites.PendingSecuredWrite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("DecidedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExecutedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExecutionError") + .HasMaxLength(2048) + .IsUnicode(false) + .HasColumnType("varchar(2048)"); + + b.Property("OperatorComment") + .HasMaxLength(1024) + .IsUnicode(false) + .HasColumnType("varchar(1024)"); + + b.Property("OperatorUser") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("SiteId") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("SubmittedAtUtc") + .HasColumnType("datetime2"); + + b.Property("TagPath") + .IsRequired() + .HasMaxLength(512) + .IsUnicode(false) + .HasColumnType("varchar(512)"); + + b.Property("ValueJson") + .IsRequired() + .HasMaxLength(4000) + .IsUnicode(false) + .HasColumnType("varchar(4000)"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("VerifierComment") + .HasMaxLength(1024) + .IsUnicode(false) + .HasColumnType("varchar(1024)"); + + b.Property("VerifierUser") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("SiteId") + .HasDatabaseName("IX_PendingSecuredWrites_Site"); + + b.HasIndex("Status", "SubmittedAtUtc") + .HasDatabaseName("IX_PendingSecuredWrites_Status_Submitted"); + + b.ToTable("PendingSecuredWrites", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Administrator" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Designer" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployer" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployer" + }, + new + { + Id = 5, + LdapGroupName = "SCADA-Viewers", + Role = "Viewer" + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasColumnType("nvarchar(max)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasColumnType("nvarchar(max)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactRevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ArtifactsDeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("IsMesReportable") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilter") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("IsMesReportable") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateNativeAlarmSources"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport.BundleUnlockAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttemptedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ClientKey") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("ClientKey", "AttemptedAtUtc") + .HasDatabaseName("IX_BundleUnlockAttempt_Client_Attempted"); + + b.ToTable("BundleUnlockAttempt", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogChainHead", b => + { + b.Property("ChainMonth") + .HasColumnType("date"); + + b.Property("LastHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("binary(32)") + .IsFixedLength(); + + b.Property("LastSeq") + .HasColumnType("bigint"); + + b.Property("PurgedThroughHash") + .HasMaxLength(32) + .HasColumnType("binary(32)") + .IsFixedLength(); + + b.Property("PurgedThroughSeq") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("ChainMonth"); + + b.ToTable("AuditLogChainHead", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogChainTombstone", b => + { + b.Property("ChainMonth") + .HasColumnType("date"); + + b.Property("ChainSeq") + .HasColumnType("bigint"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurgedAtUtc") + .HasColumnType("datetime2"); + + b.Property("RowHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("binary(32)") + .IsFixedLength(); + + b.HasKey("ChainMonth", "ChainSeq"); + + b.ToTable("AuditLogChainTombstone", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Actor") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ChainSeq") + .HasColumnType("bigint"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasColumnName("Category"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", true); + + b.Property("IngestedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2(7)") + .HasComputedColumnSql("CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))", false); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", true); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ParentExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true); + + b.Property("RowHash") + .HasMaxLength(32) + .HasColumnType("binary(32)") + .IsFixedLength(); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSiteId") + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", true); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", true); + + b.Property("Target") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("ChainSeq") + .HasDatabaseName("IX_AuditLog_ChainSeq") + .HasFilter("[ChainSeq] IS NOT NULL"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("OccurredAtUtc"), new[] { "Status" }); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution"); + + b.HasIndex("Actor", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Actor_Occurred"); + + b.HasIndex("Kind", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Kind_Occurred"); + + b.HasIndex("OccurredAtUtc", "EventId") + .HasDatabaseName("IX_AuditLog_Unchained") + .HasFilter("[ChainSeq] IS NULL"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.PendingDeployment", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("NativeAlarmSourceOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("NativeAlarmSources") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + + b.Navigation("NativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("NativeAlarmSources"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.cs new file mode 100644 index 00000000..3828f011 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.cs @@ -0,0 +1,144 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + /// + /// AuditLog hash-chain tamper evidence (audit roadmap T1, alog.md §11.4, ScadaBridge#41): + /// + /// dbo.AuditLog.ChainSeq (bigint NULL) + RowHash + /// (binary(32) NULL) — nullable columns with no default, so the ADD is a + /// metadata-only change even on a large table. Every existing row starts unchained. + /// dbo.AuditLogChainHead (per-month chain end + partition-purge checkpoint + /// anchor) and dbo.AuditLogChainTombstone (links removed by the per-channel + /// retention DELETE). + /// Two partition-aligned filtered indexes (raw SQL, because EF's CreateIndex + /// cannot express ON ps_AuditLog_Month(OccurredAtUtc)): 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 + /// WITH (ONLINE = ON) (Enterprise edition) in the generated script. + /// A new scadabridge_audit_chainer role with a COLUMN-level UPDATE grant on + /// (ChainSeq, RowHash) only, plus the purger's rights on the two chain tables. + /// The writer role's table-wide DENY UPDATE is untouched, so the ingest + /// principal still cannot write a link. + /// + /// + public partial class AddAuditLogHashChain : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ChainSeq", + table: "AuditLog", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "RowHash", + table: "AuditLog", + type: "binary(32)", + fixedLength: true, + maxLength: 32, + nullable: true); + + migrationBuilder.CreateTable( + name: "AuditLogChainHead", + columns: table => new + { + ChainMonth = table.Column(type: "date", nullable: false), + LastSeq = table.Column(type: "bigint", nullable: false), + LastHash = table.Column(type: "binary(32)", fixedLength: true, maxLength: 32, nullable: false), + PurgedThroughSeq = table.Column(type: "bigint", nullable: true), + PurgedThroughHash = table.Column(type: "binary(32)", fixedLength: true, maxLength: 32, nullable: true), + UpdatedAtUtc = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogChainHead", x => x.ChainMonth); + }); + + migrationBuilder.CreateTable( + name: "AuditLogChainTombstone", + columns: table => new + { + ChainMonth = table.Column(type: "date", nullable: false), + ChainSeq = table.Column(type: "bigint", nullable: false), + RowHash = table.Column(type: "binary(32)", fixedLength: true, maxLength: 32, nullable: false), + EventId = table.Column(type: "uniqueidentifier", nullable: false), + PurgedAtUtc = table.Column(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;"); + } + + /// + 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"); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs index 2dfe2da7..323aba9c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs @@ -1796,6 +1796,61 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations b.ToTable("BundleUnlockAttempt", (string)null); }); + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogChainHead", b => + { + b.Property("ChainMonth") + .HasColumnType("date"); + + b.Property("LastHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("binary(32)") + .IsFixedLength(); + + b.Property("LastSeq") + .HasColumnType("bigint"); + + b.Property("PurgedThroughHash") + .HasMaxLength(32) + .HasColumnType("binary(32)") + .IsFixedLength(); + + b.Property("PurgedThroughSeq") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("ChainMonth"); + + b.ToTable("AuditLogChainHead", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogChainTombstone", b => + { + b.Property("ChainMonth") + .HasColumnType("date"); + + b.Property("ChainSeq") + .HasColumnType("bigint"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurgedAtUtc") + .HasColumnType("datetime2"); + + b.Property("RowHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("binary(32)") + .IsFixedLength(); + + b.HasKey("ChainMonth", "ChainSeq"); + + b.ToTable("AuditLogChainTombstone", (string)null); + }); + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b => { b.Property("EventId") @@ -1814,6 +1869,9 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations .HasMaxLength(256) .HasColumnType("nvarchar(256)"); + b.Property("ChainSeq") + .HasColumnType("bigint"); + b.Property("Channel") .IsRequired() .HasMaxLength(32) @@ -1856,6 +1914,11 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations .HasColumnType("uniqueidentifier") .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true); + b.Property("RowHash") + .HasMaxLength(32) + .HasColumnType("binary(32)") + .IsFixedLength(); + b.Property("SourceNode") .HasMaxLength(64) .IsUnicode(false) @@ -1882,6 +1945,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations b.HasKey("EventId", "OccurredAtUtc"); + b.HasIndex("ChainSeq") + .HasDatabaseName("IX_AuditLog_ChainSeq") + .HasFilter("[ChainSeq] IS NOT NULL"); + b.HasIndex("CorrelationId") .HasDatabaseName("IX_AuditLog_CorrelationId") .HasFilter("[CorrelationId] IS NOT NULL"); @@ -1906,6 +1973,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations .IsDescending(false, true) .HasDatabaseName("IX_AuditLog_Kind_Occurred"); + b.HasIndex("OccurredAtUtc", "EventId") + .HasDatabaseName("IX_AuditLog_Unchained") + .HasFilter("[ChainSeq] IS NULL"); + b.HasIndex("SourceNode", "OccurredAtUtc") .HasDatabaseName("IX_AuditLog_Node_Occurred"); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs index f0a0cb43..3049ce8d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -48,6 +48,22 @@ public class AuditLogRepository : IAuditLogRepository private const string CanonicalColumnList = "EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson"; + // Hash chain (ScadaBridge#41). Wraps a maintenance-path batch in a transaction holding + // the EXCLUSIVE chain application lock (Maintenance.AuditChainLock), so it never + // interleaves with a chaining pass or a verification. NOCOUNT is ON for everything but + // the one mutating statement the caller places between prefix and suffix, so + // ExecuteNonQuery still returns exactly that statement's row count. XACT_ABORT rolls + // the transaction back on any error or client timeout (attention). + private const string ChainLockedBatchPrefix = + "SET XACT_ABORT ON;\nSET NOCOUNT ON;\nBEGIN TRANSACTION;\n" + + "DECLARE @chainLock int;\n" + + "EXEC @chainLock = sp_getapplock @Resource = N'" + Maintenance.AuditChainLock.Resource + "', @LockMode = 'Exclusive', @LockOwner = 'Transaction', @LockTimeout = -1;\n" + + "IF @chainLock < 0 THROW 51000, 'AuditLog hash-chain lock was not granted.', 1;\n" + + "SET NOCOUNT OFF;\n"; + + private const string ChainLockedBatchSuffix = + "\nSET NOCOUNT ON;\nCOMMIT TRANSACTION;"; + private readonly ScadaBridgeDbContext _context; private readonly ILogger _logger; @@ -514,6 +530,13 @@ VALUES BEGIN TRY BEGIN TRANSACTION; + -- 0. Hash chain (ScadaBridge#41): hold the exclusive chain lock for the whole + -- switch so no chaining pass / verification interleaves with the anchor + -- update in step 3b. + DECLARE @chainLock int; + EXEC @chainLock = sp_getapplock @Resource = N'{Maintenance.AuditChainLock.Resource}', @LockMode = 'Exclusive', @LockOwner = 'Transaction', @LockTimeout = -1; + IF @chainLock < 0 THROW 51000, 'AuditLog hash-chain lock was not granted.', 1; + -- 1. Defensive cleanup for databases created before -- AlignAuditLogEventIdUniqueness: the migration drops the -- non-aligned UX_AuditLog_EventId, but a database restored from an @@ -530,7 +553,8 @@ VALUES -- expression text + PERSISTED) or ALTER TABLE ... SWITCH PARTITION -- rejects the operation with msg 4904/4948. The ordinal order also -- matches dbo.AuditLog_v2 (the CollapseAuditLogToCanonical migration): - -- 10 canonical columns first, then the 6 computed columns. + -- 10 canonical columns first, then the 6 computed columns, then the two + -- hash-chain columns AddAuditLogHashChain appended (ScadaBridge#41). CREATE TABLE dbo.[{stagingTableName}] ( EventId uniqueidentifier NOT NULL, OccurredAtUtc datetime2(7) NOT NULL, @@ -548,6 +572,8 @@ VALUES ExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier) PERSISTED, ParentExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier) PERSISTED, IngestedAtUtc AS CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7)), + ChainSeq bigint NULL, + RowHash binary(32) NULL, CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc) ) ON [PRIMARY]; @@ -559,6 +585,16 @@ VALUES DECLARE @sql nvarchar(max) = 'ALTER TABLE dbo.AuditLog SWITCH PARTITION ' + CAST(@partitionNumber AS nvarchar(10)) + ' TO dbo.[{stagingTableName}];'; EXEC sp_executesql @sql; + -- 3b. Hash chain (ScadaBridge#41): the months this partition held are gone as a + -- whole, so record each month's current chain end as its checkpoint anchor + -- (verification resumes after it; a late-arriving row chains on from it) + -- and drop the per-channel tombstones the anchor now subsumes. + UPDATE dbo.AuditLogChainHead + SET PurgedThroughSeq = LastSeq, PurgedThroughHash = LastHash, UpdatedAtUtc = SYSUTCDATETIME() + WHERE $partition.pf_AuditLog_Month(CAST(ChainMonth AS datetime2(7))) = @partitionNumber; + DELETE FROM dbo.AuditLogChainTombstone + WHERE $partition.pf_AuditLog_Month(CAST(ChainMonth AS datetime2(7))) = @partitionNumber; + -- 4. Drop staging — the rows are discarded here. This is the purge. DROP TABLE dbo.[{stagingTableName}]; @@ -686,8 +722,22 @@ VALUES // The trailing AUDIT-PURGE-ALLOWED marker on the DELETE line below is the // single narrow exemption the append-only CI guard (AuditLogAppendOnlyGuardTests) // recognizes; any other UPDATE/DELETE targeting AuditLog still trips the guard. + // + // Hash chain (ScadaBridge#41): a row deleted from the middle of a month's chain must + // leave its link behind or verification reports the gap as tampering. The DELETE + // OUTPUTs every removed row into a table variable and the same transaction writes a + // tombstone (month, ChainSeq, RowHash) for each CHAINED one; the whole batch runs + // under the exclusive chain lock so a chaining pass or a verification never sees the + // row gone without its tombstone. Rows not yet chained need no tombstone. const string deleteBatchSql = - "DELETE TOP (@batch) FROM dbo.AuditLog WHERE Category = @channel AND OccurredAtUtc < @threshold;"; // AUDIT-PURGE-ALLOWED: per-channel retention override, maintenance path + ChainLockedBatchPrefix + + "DECLARE @purged TABLE (EventId uniqueidentifier NOT NULL, OccurredAtUtc datetime2(7) NOT NULL, ChainSeq bigint NULL, RowHash binary(32) NULL);\n" + + "DELETE TOP (@batch) FROM dbo.AuditLog OUTPUT deleted.EventId, deleted.OccurredAtUtc, deleted.ChainSeq, deleted.RowHash INTO @purged WHERE Category = @channel AND OccurredAtUtc < @threshold;\n" + // AUDIT-PURGE-ALLOWED: per-channel retention override, maintenance path + "SET NOCOUNT ON;\n" + + "INSERT INTO dbo.AuditLogChainTombstone (ChainMonth, ChainSeq, RowHash, EventId, PurgedAtUtc)\n" + + "SELECT DATEFROMPARTS(YEAR(OccurredAtUtc), MONTH(OccurredAtUtc), 1), ChainSeq, RowHash, EventId, SYSUTCDATETIME()\n" + + "FROM @purged WHERE ChainSeq IS NOT NULL AND RowHash IS NOT NULL;\n" + + ChainLockedBatchSuffix; long totalDeleted = 0; @@ -1363,8 +1413,16 @@ VALUES // The trailing AUDIT-PURGE-ALLOWED marker on the UPDATE line below is the // single narrow exemption the append-only CI guard (AuditLogAppendOnlyGuardTests) // recognises for an UPDATE; any other UPDATE targeting AuditLog still trips the guard. + // + // Hash chain (ScadaBridge#41): SourceNode is inside every chained row's hash, so the + // backfill touches ONLY unchained rows (ChainSeq IS NULL) and runs under the + // exclusive chain lock — a chaining pass can never hash a row whose SourceNode this + // batch is about to change. Run the backfill BEFORE enabling AuditLog:HashChain; + // once a row is chained its SourceNode is frozen. const string updateBatchSql = - "UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel WHERE SourceNode IS NULL AND OccurredAtUtc < @before;"; // AUDIT-PURGE-ALLOWED: SourceNode sentinel backfill, maintenance path + ChainLockedBatchPrefix + + "UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel WHERE SourceNode IS NULL AND OccurredAtUtc < @before AND ChainSeq IS NULL;" + // AUDIT-PURGE-ALLOWED: SourceNode sentinel backfill, maintenance path + ChainLockedBatchSuffix; long totalUpdated = 0; diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs index 8fb4a859..be26339a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs @@ -139,6 +139,10 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext public DbSet AuditLogEntries => Set(); /// Gets the set of audit log rows (central dbo.AuditLog persistence shape; mapped to/from the canonical record at the repository boundary). public DbSet AuditLogs => Set(); + /// Gets the per-month AuditLog hash-chain heads (ScadaBridge#41). + public DbSet AuditLogChainHeads => Set(); + /// Gets the AuditLog hash-chain purge tombstones (ScadaBridge#41). + public DbSet AuditLogChainTombstones => Set(); /// Gets the set of site calls. public DbSet SiteCalls => Set(); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs index de890f9e..4fcc5191 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs @@ -110,6 +110,10 @@ public static class ServiceCollectionExtensions // fresh scope on each tick (mirrors AuditLogPurgeActor / AuditLogIngestActor). services.AddScoped(); + // AuditLog hash-chain tamper evidence (ScadaBridge#41): chained by the central + // AuditLogHashChainService, verified by the VerifyAuditChain management command. + services.AddScoped(); + // Opt-in config-audit retention (ScadaBridge:ConfigAudit:RetentionDays, default 0 = off). // Options binding to configuration is done by the host; without it the defaults apply // and the purge service is a no-op. diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogHashChainServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogHashChainServiceTests.cs new file mode 100644 index 00000000..6595fb95 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogHashChainServiceTests.cs @@ -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; + +/// +/// Policy tests for (ScadaBridge#41) over a scripted +/// : 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 AuditLogHashChainTests in ConfigurationDatabase.Tests. +/// +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 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 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(), + Options.Create(options), + NullLogger.Instance); + } + + /// The job is off by default and starting it then does nothing. + /// A task that represents the asynchronous operation. + [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); + } + + /// A tick drains batches until one comes back empty. + /// A task that represents the asynchronous operation. + [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 + } + + /// The peer node holding the lock ends the tick at once. + /// A task that represents the asynchronous operation. + [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); + } + + /// A backlog yields after MaxBatchesPerTick batches. + /// A task that represents the asynchronous operation. + [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); + } + + /// A failing tick is contained (logged), never thrown out of the loop. + /// A task that represents the asynchronous operation. + [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)); + } + + /// Invalid tuning fails startup validation. + /// IntervalSeconds. + /// BatchSize. + /// MaxBatchesPerTick. + [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); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditLogAppendOnlyGuardTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditLogAppendOnlyGuardTests.cs index 1dc373e8..3136c893 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditLogAppendOnlyGuardTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditLogAppendOnlyGuardTests.cs @@ -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 /// on separate lines is an accepted, undetected edge case. /// -/// Allow-list. Two narrow maintenance-path exemptions carry the exact +/// Allow-list. Three narrow exemptions carry the exact /// trailing comment: /// /// @@ -47,6 +47,11 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// one-time ops procedure; the append-only invariant still applies to all /// other columns and all other UPDATE forms. /// +/// +/// AuditLogHashChain.WriteLinksAsync (ScadaBridge#41): the chaining job's +/// UPDATE dbo.AuditLog SET ChainSeq, RowHash, guarded by +/// ChainSeq IS NULL — it only ever fills the two hash-chain link columns once. +/// /// /// The allow-list is applied in the file-scan test only /// () — the diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs index 8a6f7236..e3d03a49 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs @@ -97,8 +97,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable // C5: 10 canonical columns (EventId, OccurredAtUtc, Actor, Action, Outcome, // Channel[=Category], Target, SourceNode, CorrelationId, DetailsJson) + 6 // persisted computed columns (Kind, Status, SourceSiteId, ExecutionId, - // ParentExecutionId, IngestedAtUtc) = 16. - Assert.Equal(16, properties.Count); + // ParentExecutionId, IngestedAtUtc) + 2 hash-chain link columns (ChainSeq, + // RowHash — ScadaBridge#41) = 18. + Assert.Equal(18, properties.Count); } /// Verifies that the channel property maps to the canonical category column. @@ -177,6 +178,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable var expected = new[] { "IX_AuditLog_Actor_Occurred", + "IX_AuditLog_ChainSeq", // hash chain (ScadaBridge#41) "IX_AuditLog_Channel_Status_Occurred", "IX_AuditLog_CorrelationId", "IX_AuditLog_Execution", @@ -186,6 +188,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable "IX_AuditLog_ParentExecution", "IX_AuditLog_Site_Occurred", "IX_AuditLog_Target_Occurred", + "IX_AuditLog_Unchained", // hash chain (ScadaBridge#41) // UX_AuditLog_EventId is intentionally absent — dropped by // AlignAuditLogEventIdUniqueness; the aligned clustered PK carries it. }; diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditChainVerifierTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditChainVerifierTests.cs new file mode 100644 index 00000000..0754a222 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditChainVerifierTests.cs @@ -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; + +/// +/// 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. +/// +public class AuditChainVerifierTests +{ + private static readonly DateTime Month = new(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); + + /// An untouched chain verifies intact and covers every row. + [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); + } + + /// Editing any stored column of a chained row is a HashMismatch at that row. + [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); + } + + /// A row deleted from the middle (no tombstone) is a MissingRow at its sequence number. + [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 + } + + /// Deleting the newest rows leaves a self-consistent prefix; the head catches it. + [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); + } + + /// Swapping two rows' sequence numbers is a HashMismatch at the first moved row. + [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); + } + + /// A duplicated sequence number is reported, not silently skipped. + [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); + } + + /// A sanctioned per-channel purge leaves a tombstone that bridges the gap. + [Fact] + public void TombstonedRow_BridgesTheGap() + { + var (rows, head) = BuildChain(5); + var purged = rows[2]; + rows.RemoveAt(2); + var tombstones = new Dictionary { [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); + } + + /// A tombstone with the wrong hash does not hide a deletion. + [Fact] + public void ForgedTombstone_IsDetectedAtTheNextRow() + { + var (rows, head) = BuildChain(5); + var purged = rows[2]; + rows.RemoveAt(2); + var tombstones = new Dictionary { [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); + } + + /// After a partition purge, verification resumes at the checkpoint anchor. + [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); + } + + /// A month with neither rows nor head reports NotChained. + [Fact] + public void EmptyMonth_IsNotChained() + { + var result = Verify([], null, unchained: 7); + + Assert.Equal(AuditChainStatus.NotChained, result.Status); + Assert.Equal(7, result.UnchainedRows); + } + + /// + /// 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. + /// + [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)); + } + + /// NULL and the empty string hash differently (presence byte), as do adjacent-field shifts. + [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 rows, + AuditChainHeadState? head, + IReadOnlyDictionary? tombstones = null, + long unchained = 0) + { + var verifier = new AuditChainVerifier(Month, head, tombstones ?? new Dictionary()); + foreach (var row in rows) + { + if (!verifier.Accept(row)) + { + break; + } + } + + return verifier.Complete(unchained); + } + + private static (List Rows, AuditChainHeadState Head) BuildChain(int count) + { + var rows = new List(); + 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}}}"); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditLogHashChainTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditLogHashChainTests.cs new file mode 100644 index 00000000..4ff728e0 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditLogHashChainTests.cs @@ -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; + +/// +/// 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. +/// +/// +/// 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. +/// +public class AuditLogHashChainTests : IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + /// Creates the test class with the shared migrated SQL Server fixture. + /// The shared migration fixture. + public AuditLogHashChainTests(MsSqlMigrationFixture fixture) => _fixture = fixture; + + /// + /// 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. + /// + /// A task that represents the asynchronous operation. + [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); + } + + /// Editing a chained row's content is reported at that row. + /// A task that represents the asynchronous operation. + [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); + } + + /// Deleting a chained row outside the purge path is reported as a missing sequence number. + /// A task that represents the asynchronous operation. + [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); + } + + /// Swapping two rows' chain positions is reported. + /// A task that represents the asynchronous operation. + [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); + } + + /// + /// 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. + /// + /// A task that represents the asynchronous operation. + [SkippableFact] + public async Task PerChannelPurge_LeavesAVerifiableChain() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + var month = Month(2026, 6); + var rows = new List(); + 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); + } + + /// + /// 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. + /// + /// A task that represents the asynchronous operation. + [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); + } + + /// + /// The SourceNode backfill (the other sanctioned AuditLog UPDATE) must not touch a chained + /// row — SourceNode is inside its hash. + /// + /// A task that represents the asynchronous operation. + [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($"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> 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 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 VerifyAsync(DateTime month) + { + await using var ctx = CreateContext(); + return await new AuditLogHashChain(ctx).VerifyMonthAsync(month); + } + + private async Task SeqOfAsync(Guid eventId) + => await ScalarAsync($"SELECT ChainSeq FROM dbo.AuditLog WHERE EventId = '{eventId}';"); + + private async Task> 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(); + 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 ScalarAsync(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().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"); +}