diff --git a/CLAUDE.md b/CLAUDE.md index e71befef..dbae4824 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,7 +184,7 @@ spec for each is `docs/requirements/Component-.md`, and `README.md` carrie - Append-only enforced via DB roles (writer role has INSERT only, no UPDATE/DELETE). - **Clustered key `(OccurredAtUtc, EventId)` + aligned unique `UX_AuditLog_EventId_Occurred (EventId, OccurredAtUtc)` (migration `FlipAuditLogClusteredKey`, #111, B2 of the #78 benchmark).** The Audit Log page's newest-first page/keyset now reads an ordered range instead of the whole table (300k rows: 90,532 → 34 reads). Offline whole-table rebuild — script `docs/plans/sql/FlipAuditLogClusteredKey.sql` (+ `.rollback.sql`), both centrals stopped: the partition-switch staging table now reads the live key order, but binaries older than the flip hard-code the old one. DEV/QA not applied (sizing + window in #111). - **Parquet cold archive (T2, ScadaBridge#42) — default OFF (`AuditLog:Archive:Enabled` + absolute `Directory`, shared by both centrals).** The purge actor writes each expired partition to `{Directory}/{YYYY}/auditlog-{YYYY-MM}-{stamp}Z.parquet` (10 stored columns + `ChainSeq`/`RowHash`, tick-exact `OccurredAtUtcTicks`) plus `.tombstones.parquet` and a `.manifest.json` (written last; chain head at archive time, SHA-256 per file), read under the chain lock SHARED; then the switch runs with a guard (under the EXCLUSIVE lock: partition row count + each month's `LastSeq` must still equal the archive's, else THROW 51002/51003 and nothing is purged). Fail closed: a partition that cannot be archived is not purged. `audit archive list|verify` = `ListAuditArchives`/`VerifyAuditArchive` (verify-chain's gate): file hashes, chain recomputed from the archive, chain end vs the DB purge anchor (`Matches`/`Superseded`/`NotPurged`/`Mismatch`), CLI exit 3. `audit export --format parquet` streams the 24 export columns (was 501). No migration. **Owner decisions (#109):** destination stays a directory/UNC (no object-storage client; share + permissions named at enablement), parquet-tools-only reading (no re-import), unsigned like the chain. **Retention** `AuditLog:Archive:RetentionDays` (default 0 = never; else > `AuditLog:RetentionDays`, counted from the month's end) and **superseded stale-archive cleanup** (`DeleteSupersededStaleArchives`, default true: a retried purge's `Archived` archive is deleted only when a later `Purged`, verifying archive provably holds every row — same anchor + chain-end hash, unchained rows by EventId; else `KeptStale`) run after every purge tick; deletion touches only the archive name's own three files, manifest first; `audit archive cleanup [--dry-run]` = `CleanupAuditArchives` (Administrator). -- **Hash-chain tamper evidence (T1, ScadaBridge#41) — default OFF (`AuditLog:HashChain:Enabled`).** One SHA-256 chain per UTC calendar month: `ChainSeq` + `RowHash = SHA-256(prev || canonical(row))` on `AuditLog` (migration `AddAuditLogHashChain`), linked by the central `AuditLogHashChainService` (both nodes, serialised by `sp_getapplock`) — never by the ingest path. `AuditLogChainHead` catches tail deletion and carries the partition-switch checkpoint anchor; the per-channel DELETE writes an `AuditLogChainTombstone` per chained row in the same transaction. `audit verify-chain --month` = management command `VerifyAuditChain` (OperationalAudit, unscoped callers only), first break with partition/ChainSeq/EventId, CLI exit 3. A chained row's `SourceNode` is frozen (backfill skips it). **Unsigned by owner decision (#107); external anchor = off-box head export** (`AuditLog:HashChain:HeadExport`, default off: both centrals append changed per-month heads — month, `LastSeq`/`LastHash`, purge anchor, row + tombstone counts — to append-only JSONL files `{Directory}/{yyyy}/audit-chain-heads-{date}-{node}.jsonl` or RFC 5424 syslog UDP/TCP); `audit verify-heads --path` = `VerifyAuditChainHeads` checks them against the DB (a re-hashed forgery passes verify-chain, fails verify-heads; exit 3). **Two-login hardening (#107 item 3):** `AuditLog:MaintenanceConnectionString` (`${secret:}`-capable, unset = runtime login) runs the chain job, verify/head export, partition switch-out + archive and per-channel purge on `AuditMaintenanceContext` (keyed `audit-maintenance` repository/archive); that login gets `scadabridge_audit_chainer` + `scadabridge_audit_purger`, the runtime login `scadabridge_audit_writer` (whose DENYs block chaining/purging) — pinned by `AuditMaintenanceLoginTests`. +- **Hash-chain tamper evidence (T1, ScadaBridge#41) — default OFF (`AuditLog:HashChain:Enabled`).** One SHA-256 chain per UTC calendar month: `ChainSeq` + `RowHash = SHA-256(prev || canonical(row))` on `AuditLog` (migration `AddAuditLogHashChain`), linked by the central `AuditLogHashChainService` (both nodes, serialised by `sp_getapplock`) — never by the ingest path. `AuditLogChainHead` catches tail deletion and carries the partition-switch checkpoint anchor; the per-channel DELETE writes an `AuditLogChainTombstone` per chained row in the same transaction. `audit verify-chain --month` = management command `VerifyAuditChain` (OperationalAudit, unscoped callers only), first break with partition/ChainSeq/EventId, CLI exit 3. A chained row's `SourceNode` is frozen (backfill skips it). **Unsigned by owner decision (#107); external anchor = off-box head export** (`AuditLog:HashChain:HeadExport`, default off: both centrals append changed per-month heads — month, `LastSeq`/`LastHash`, purge anchor, row + tombstone counts — to append-only JSONL files `{Directory}/{yyyy}/audit-chain-heads-{date}-{node}.jsonl` or RFC 5424 syslog UDP/TCP); `audit verify-heads --path` = `VerifyAuditChainHeads` checks them against the DB (a re-hashed forgery passes verify-chain, fails verify-heads; exit 3). **Two-login hardening (#107 item 3):** `AuditLog:MaintenanceConnectionString` (`${secret:}`-capable, unset = runtime login) runs the chain job, verify/head export, partition switch-out + archive and per-channel purge on `AuditMaintenanceContext` (keyed `audit-maintenance` repository/archive); that login gets `scadabridge_audit_chainer` + `scadabridge_audit_purger`, the runtime login `scadabridge_audit_writer` (whose DENYs block chaining/purging) — pinned by `AuditMaintenanceLoginTests`. Since #113 the monthly roll-forward and `backfill-source-node` use it too (migration `AuditMaintenanceGrants`: purger gets `ALTER ANY DATASPACE` + `UPDATE (SourceNode)`). Head-export failures (#115) bump `scadabridge.audit.chain_head_export.failures` and Degrade `/health/ready` entry `audit-chain-head-export`; archive cleanup (#114) also deletes writer-named orphan temp / manifest-less files older than 24 h (`DeletedOrphan`). - Node-of-origin is captured alongside site-of-origin: `SourceNode` (`varchar(64)` NULL) on `AuditLog`, `Notifications`, and `SiteCalls` — `node-a`/`node-b` for site rows (qualified by `SourceSiteId`/`SourceSite`), `central-a`/`central-b` for central direct-write rows. Stamped at the writing node, carried verbatim through telemetry + reconciliation, and indexed via `IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)` on `AuditLog`. - Per-node stuck KPIs (M5.3 T6): Notification Outbox and Site Call Audit expose `PerNodeNotificationKpiRequest`/`PerNodeSiteCallKpiRequest` messages that group stuck/parked/delivered counts by `SourceNode`, surfacing per-node breakdowns on the Health dashboard. - `audit tree --execution-id ` CLI command (M5.3 T8) + `GET /api/audit/tree` endpoint — resolves any node to its chain root and renders the full execution tree; backed by `IAuditLogRepository.GetExecutionTreeAsync`. diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index f3753d51..e5235afa 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -823,8 +823,14 @@ MS SQL for direct-write events). Unredacted secrets never persist. - **Safe deletion.** Cleanup deletes only the three files a well-formed archive name implies (`{name}.manifest.json`, `.parquet`, `.tombstones.parquet`) in that archive's own `{Directory}/{YYYY}` folder — never a path read from a manifest — manifest first, so an - interrupted deletion leaves leftovers that are no longer an archive (orphans of an interrupted - archive WRITE are not cleaned yet — #114). A cleanup failure is a + interrupted deletion leaves leftovers that are no longer an archive. **Orphans of an interrupted + archive WRITE (ScadaBridge#114):** the same pass also deletes, reporting action `DeletedOrphan` + (dry-run aware), the writer's own temp files (`{name}.parquet.tmp`, + `{name}.tombstones.parquet.tmp`, `{name}.manifest.json.tmp`) and data files whose + `.manifest.json` was never written — only files named exactly like that for a well-formed + archive name, directly inside a four-digit `{Directory}/{YYYY}` folder whose year matches the + name, not reparse points, and last written more than 24 h ago (a running write is never + touched). Disk-only; no correctness impact. A cleanup failure is a purge failure on the health surface. `scadabridge audit archive cleanup [--dry-run]` (`CleanupAuditArchives`, Administrator) runs the same pass on demand or reports what it would delete. Pinned by `AuditLogArchiveCleanupTests` (mutation-checked: each safety rule @@ -906,11 +912,17 @@ MS SQL for direct-write events). Unredacted secrets never persist. `AuditMaintenanceLoginTests` runs exactly this setup on SQL Server: the writer ingests and is refused the chain UPDATE and the per-channel DELETE; the maintenance login chains, verifies, - reads heads, purges per channel, enumerates partitions, archives and switches out. Not moved - to the maintenance login: the monthly partition roll-forward (`ALTER PARTITION FUNCTION`, - needs `ALTER ANY DATASPACE` on the runtime login, or run it as a DBA job) and the one-time - `audit backfill-source-node` (needs `UPDATE` on `SourceNode` — a DBA-run step in a hardened - setup) — both tracked in #113. Migrations themselves are applied by a DBA with the production SQL scripts. + reads heads, purges per channel, enumerates partitions, archives and switches out. **Also on the + maintenance login (ScadaBridge#113):** the monthly partition roll-forward + (`ALTER PARTITION FUNCTION … SPLIT RANGE`, `AuditLogPartitionMaintenance` now built on + `AuditMaintenanceContext`) and `audit backfill-source-node` (the management endpoint resolves the + keyed maintenance `IAuditLogRepository`). Migration `AuditMaintenanceGrants` gives + `scadabridge_audit_purger` the two grants they need that `ALTER ON SCHEMA::dbo` does not confer: + database-level `ALTER ANY DATASPACE` (roll-forward) and column-level `UPDATE (SourceNode)` on + `dbo.AuditLog` (backfill; no other column becomes updatable, the writer's DENY still wins). + Unset `AuditLog:MaintenanceConnectionString` keeps both on the runtime login as before — which + then needs those same two grants (or db_owner), exactly as the single-login default always did. + `AuditMaintenanceLoginTests` pins both with a writer-refused negative control. Migrations themselves are applied by a DBA with the production SQL scripts. - **Authorization.** Reading the Audit Log requires the existing **Audit** role extended with a new **OperationalAudit** permission. Per-site row scoping reuses the existing site-permission model; bulk export requires an additional @@ -973,7 +985,12 @@ MS SQL for direct-write events). Unredacted secrets never persist. `FileMode.Append` only, one file per node per day; give the service account create + append-data rights and nothing else), or `Sink: Syslog` — RFC 5424 (`1 … chainhead - {json}`, facility 13 "log audit", severity Notice) over UDP (RFC 5426) or TCP (RFC 6587 - octet counting). A failed write is logged and retried with the same heads next tick (no health counter yet — #115). + octet counting). A failed write is logged and retried with the same heads next tick, and (ScadaBridge#115) + is also a health signal: counter `scadabridge.audit.chain_head_export.failures` on the shared + ScadaBridge meter, plus a `/health/ready` entry `audit-chain-head-export` that is Degraded (HTTP + 200, Overview-visible, not out of rotation) while the last tick failed or no tick has succeeded + for more than two intervals; Healthy when the export is disabled. UDP syslog is fire-and-forget, + so a dropped datagram is invisible by design — use `Protocol: Tcp` where that matters. `scadabridge audit verify-heads --path ` reads those records back (file-sink lines or a syslog collector's log) and the `VerifyAuditChainHeads` command checks each against the database: the link stored at the exported sequence (the head, a live row's diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/HeadExport/AuditChainHeadExportService.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/HeadExport/AuditChainHeadExportService.cs index 9a261f34..999f304d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/HeadExport/AuditChainHeadExportService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/HeadExport/AuditChainHeadExportService.cs @@ -31,6 +31,7 @@ public sealed class AuditChainHeadExportService : IHostedService, IDisposable private readonly string? _node; private readonly TimeProvider _time; private readonly ILogger _logger; + private readonly AuditChainHeadExportStatus _status; private readonly Dictionary _lastExported = new(); private CancellationTokenSource? _cts; private Task? _loop; @@ -42,14 +43,17 @@ public sealed class AuditChainHeadExportService : IHostedService, IDisposable /// This node's name for the records. /// Logger. /// Clock. + /// Health status the tick outcome is recorded on (ScadaBridge#115). public AuditChainHeadExportService( IServiceScopeFactory scopeFactory, IOptions options, IAuditChainHeadSink? sink, string? nodeName, ILogger logger, - TimeProvider? time = null) + TimeProvider? time = null, + AuditChainHeadExportStatus? status = null) { + _status = status ?? new AuditChainHeadExportStatus(); _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); _options = (options ?? throw new ArgumentNullException(nameof(options))).Value.HeadExport; _sink = sink; @@ -67,6 +71,7 @@ public sealed class AuditChainHeadExportService : IHostedService, IDisposable return Task.CompletedTask; } + _status.MarkStarted(_options.IntervalMinutes, _time.GetUtcNow().UtcDateTime); _logger.LogInformation("AuditLog chain-head export every {Minutes} min to {Sink}.", _options.IntervalMinutes, _sink.Describe); var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _cts = cts; @@ -118,6 +123,7 @@ public sealed class AuditChainHeadExportService : IHostedService, IDisposable .ToList(); if (changed.Count == 0) { + _status.RecordSuccess(now); return 0; } @@ -128,6 +134,7 @@ public sealed class AuditChainHeadExportService : IHostedService, IDisposable _lastExported[h.Month] = (h.LastSeq, h.PurgedThroughSeq, h.ChainedRows + h.Tombstones); } + _status.RecordSuccess(now); _logger.LogDebug("Exported {Count} AuditLog chain head(s) to {Sink}.", records.Count, _sink.Describe); return records.Count; } @@ -139,6 +146,7 @@ public sealed class AuditChainHeadExportService : IHostedService, IDisposable { // Contained: the next tick retries the same heads (nothing was marked exported). _logger.LogError(ex, "AuditLog chain-head export to {Sink} failed; retrying next tick.", _sink.Describe); + _status.RecordFailure(_time.GetUtcNow().UtcDateTime, ex.Message); return 0; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/HeadExport/AuditChainHeadExportStatus.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/HeadExport/AuditChainHeadExportStatus.cs new file mode 100644 index 00000000..e323256c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/HeadExport/AuditChainHeadExportStatus.cs @@ -0,0 +1,91 @@ +using System.Diagnostics.Metrics; +using ZB.MOM.WW.ScadaBridge.Commons.Observability; + +namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central.HeadExport; + +/// +/// Health signal of the off-box chain-head export (ScadaBridge#115). Updated by +/// on every tick; read by the Host's +/// audit-chain-head-export /health/ready check. A sink that stays down used to be a +/// log line only, silently stopping the external anchor. +/// +public sealed class AuditChainHeadExportStatus +{ + private static readonly Meter Meter = new(ScadaBridgeTelemetry.MeterName); + + private static readonly Counter FailuresCounter = + Meter.CreateCounter("scadabridge.audit.chain_head_export.failures", unit: "1", + description: "AuditLog chain-head export ticks that failed to read or write the heads (sink offline / unreachable); retried next tick."); + + private readonly object _gate = new(); + private long _totalFailures; + private int _consecutiveFailures; + private DateTime? _lastSuccessUtc; + private DateTime? _lastFailureUtc; + private string? _lastError; + private DateTime? _startedUtc; + + /// Whether the export is running at all (false = disabled, nothing to report). + public bool Enabled { get; private set; } + + /// Minutes between export ticks (for the staleness test). + public int IntervalMinutes { get; private set; } = 60; + + /// Total failed ticks since process start. + public long TotalFailures { get { lock (_gate) { return _totalFailures; } } } + + /// Failed ticks since the last success. + public int ConsecutiveFailures { get { lock (_gate) { return _consecutiveFailures; } } } + + /// UTC time of the last successful tick, or null. + public DateTime? LastSuccessUtc { get { lock (_gate) { return _lastSuccessUtc; } } } + + /// UTC time of the last failed tick, or null. + public DateTime? LastFailureUtc { get { lock (_gate) { return _lastFailureUtc; } } } + + /// Message of the last failure (never a secret: the exception message only), or null. + public string? LastError { get { lock (_gate) { return _lastError; } } } + + /// UTC time the export loop started, or null. + public DateTime? StartedUtc { get { lock (_gate) { return _startedUtc; } } } + + /// Marks the export as running. + /// Tick interval. + /// Start time. + public void MarkStarted(int intervalMinutes, DateTime nowUtc) + { + lock (_gate) + { + Enabled = true; + IntervalMinutes = intervalMinutes; + _startedUtc = nowUtc; + } + } + + /// Records a successful tick. + /// Time of the tick. + public void RecordSuccess(DateTime nowUtc) + { + lock (_gate) + { + _consecutiveFailures = 0; + _lastSuccessUtc = nowUtc; + } + } + + /// Records a failed tick and bumps the failure counter metric. + /// Time of the tick. + /// The failure message. + public void RecordFailure(DateTime nowUtc, string error) + { + lock (_gate) + { + _totalFailures++; + _consecutiveFailures++; + _lastFailureUtc = nowUtc; + _lastError = error; + } + + FailuresCounter.Add(1); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs index 7c1bbc12..c41fdbe0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs @@ -419,6 +419,7 @@ public static class ServiceCollectionExtensions // Off-box chain-head export (ScadaBridge#107 item 1, AuditLog:HashChain:HeadExport, // default off). The sink is built from the bound options; null when disabled. + services.AddSingleton(); services.AddHostedService(sp => { var options = sp.GetRequiredService>(); @@ -434,7 +435,8 @@ public static class ServiceCollectionExtensions options, sink, node, - sp.GetRequiredService>()); + sp.GetRequiredService>(), + status: sp.GetRequiredService()); }); // Parquet cold archive (ScadaBridge#42). Default OFF; when enabled the purge actor diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/AuditLogServiceKeys.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/AuditLogServiceKeys.cs new file mode 100644 index 00000000..158c2c2b --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/AuditLogServiceKeys.cs @@ -0,0 +1,11 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; + +/// DI keys for the AuditLog maintenance-login registrations (ScadaBridge#107/#113). +public static class AuditLogServiceKeys +{ + /// + /// Key of the maintenance-login / IAuditLogArchive: + /// the connection from AuditLog:MaintenanceConnectionString, or the runtime connection when unset. + /// + public const string Maintenance = "audit-maintenance"; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditArchive.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditArchive.cs index 36ede4fa..80e0b478 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditArchive.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditArchive.cs @@ -169,6 +169,13 @@ public enum AuditArchiveCleanupAction /// so it may be the only copy of some rows (see Detail). /// KeptStale, + + /// + /// Deleted as an orphan of an interrupted archive write (ScadaBridge#114): a temp file of the + /// archive writer (*.parquet.tmp, *.tombstones.parquet.tmp, *.manifest.json.tmp) + /// or a data file whose .manifest.json was never written, older than the safe age. + /// + DeletedOrphan, } /// One archive considered by cleanup. diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditLogPartitionArchive.Cleanup.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditLogPartitionArchive.Cleanup.cs index 9a438bba..eb8baaec 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditLogPartitionArchive.Cleanup.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditLogPartitionArchive.Cleanup.cs @@ -127,6 +127,8 @@ public sealed partial class AuditLogPartitionArchive } } + SweepOrphans(dryRun, items); + foreach (var item in items.Where(i => i.Action != AuditArchiveCleanupAction.KeptStale)) { _logger.LogInformation("AuditLog archive cleanup{DryRun}: {Action} {Archive} — {Detail}", @@ -136,6 +138,92 @@ public sealed partial class AuditLogPartitionArchive return new AuditArchiveCleanupResult(dryRun, _options.RetentionDays, items); } + /// + /// Files younger than this are never treated as orphans: a running archive write may still be + /// producing them (ScadaBridge#114). + /// + internal static readonly TimeSpan OrphanMinAge = TimeSpan.FromHours(24); + + // Longest suffix first. A file is only ever considered through one of these exact suffixes + // on a well-formed archive name, so nothing foreign in the directory can match. + private static readonly string[] TempSuffixes = [".manifest.json.tmp", ".tombstones.parquet.tmp", ".parquet.tmp"]; + private static readonly string[] DataSuffixes = [".tombstones.parquet", ".parquet"]; + + /// + /// Removes the leftovers of an interrupted archive write (ScadaBridge#114): the writer's own + /// temp files, and data files whose manifest — the commit marker — was never written. Only + /// files named exactly like the writer names them, directly inside a four-digit + /// {Directory}/{YYYY} folder whose year matches the file's, and last written longer ago + /// than . Reparse points are skipped. + /// + private void SweepOrphans(bool dryRun, List items) + { + var root = _options.Directory!; + if (!Directory.Exists(root)) + { + return; + } + + var cutoff = _time.GetUtcNow().UtcDateTime - OrphanMinAge; + foreach (var yearDir in Directory.EnumerateDirectories(root)) + { + var year = Path.GetFileName(yearDir); + if (year.Length != 4 || !year.All(char.IsAsciiDigit) || IsReparsePoint(yearDir)) + { + continue; + } + + foreach (var path in Directory.EnumerateFiles(yearDir)) + { + var file = Path.GetFileName(path); + var tmpSuffix = TempSuffixes.FirstOrDefault(x => file.EndsWith(x, StringComparison.Ordinal)); + var isTemp = tmpSuffix is not null; + var suffix = tmpSuffix ?? DataSuffixes.FirstOrDefault(x => file.EndsWith(x, StringComparison.Ordinal)); + if (suffix is null) + { + continue; + } + + var stem = file[..^suffix.Length]; + if (!IsValidName(stem) || stem.Substring("auditlog-".Length, 4) != year || IsReparsePoint(path)) + { + continue; + } + + if (!isTemp && File.Exists(Path.Combine(yearDir, stem + ".manifest.json"))) + { + continue; // a committed archive's file + } + + if (File.GetLastWriteTimeUtc(path) > cutoff) + { + continue; // possibly still being written + } + + if (!dryRun) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "AuditLog archive cleanup could not delete orphan {File}.", file); + continue; + } + } + + items.Add(new AuditArchiveCleanupItem(stem, stem.Substring("auditlog-".Length, 7), AuditArchiveCleanupAction.DeletedOrphan, + isTemp + ? $"{file}: temp file of an interrupted archive write, older than {OrphanMinAge.TotalHours:0} h." + : $"{file}: no {stem}.manifest.json (the archive write never committed), older than {OrphanMinAge.TotalHours:0} h.")); + } + } + } + + private static bool IsReparsePoint(string path) + => (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; + /// /// Proves holds every row of ; returns null /// when it does, else why not. diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditMaintenanceContext.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditMaintenanceContext.cs index 6ca5a587..0d767f36 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditMaintenanceContext.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditMaintenanceContext.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance; @@ -23,7 +24,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance; public sealed class AuditMaintenanceContext : IAsyncDisposable, IDisposable { /// DI key of the maintenance-login IAuditLogRepository and IAuditLogArchive. - public const string ServiceKey = "audit-maintenance"; + public const string ServiceKey = AuditLogServiceKeys.Maintenance; private readonly bool _owned; diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261110120000_AuditMaintenanceGrants.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261110120000_AuditMaintenanceGrants.Designer.cs new file mode 100644 index 00000000..a73f0a32 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261110120000_AuditMaintenanceGrants.Designer.cs @@ -0,0 +1,2385 @@ +// +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("20261110120000_AuditMaintenanceGrants")] + partial class AuditMaintenanceGrants + { + /// + 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.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.HasIndex("RowVersion"); + + 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.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("RowVersion"); + + 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("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("RowVersion"); + + 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.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("Id"); + + b.HasIndex("RowVersion"); + + 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("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + 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("RowVersion"); + + 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("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("RowVersion"); + + 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.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("Id"); + + b.HasIndex("RowVersion"); + + 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.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("RowVersion"); + + 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("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SourceCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("SourceReferenceOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.HasIndex("RowVersion"); + + 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.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("RowVersion"); + + 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("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RowVersion"); + + 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("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("RowVersion"); + + 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.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.HasIndex("RowVersion"); + + 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("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + 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("RowVersion"); + + 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("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("RowVersion"); + + 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("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("RowVersion"); + + 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("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RowVersion"); + + 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("RowVersion") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + 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("RowVersion"); + + 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("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + 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("OccurredAtUtc", "EventId"); + + 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("EventId", "OccurredAtUtc") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId_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/20261110120000_AuditMaintenanceGrants.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261110120000_AuditMaintenanceGrants.cs new file mode 100644 index 00000000..25795d23 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261110120000_AuditMaintenanceGrants.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + /// + /// Grants for the two AuditLog maintenance paths that now run on the maintenance login + /// (AuditLog:MaintenanceConnectionString, ScadaBridge#113). Permission DDL only. + /// + /// ALTER ANY DATASPACE to scadabridge_audit_purger — the monthly + /// roll-forward's ALTER PARTITION FUNCTION … SPLIT RANGE / ALTER PARTITION SCHEME + /// … NEXT USED; ALTER ON SCHEMA::dbo does not confer it. + /// column-level UPDATE (SourceNode) to the same role — the one-time + /// audit backfill-source-node. Column-scoped on purpose: no other AuditLog column + /// becomes updatable. + /// + /// + public partial class AuditMaintenanceGrants : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" +IF DATABASE_PRINCIPAL_ID('scadabridge_audit_purger') IS NULL + EXEC sp_executesql N'CREATE ROLE scadabridge_audit_purger'; +GRANT ALTER ANY DATASPACE TO scadabridge_audit_purger; +GRANT UPDATE (SourceNode) ON dbo.AuditLog TO scadabridge_audit_purger;"); // AUDIT-PURGE-ALLOWED: role grant for the maintenance principal (SourceNode backfill) + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" +IF DATABASE_PRINCIPAL_ID('scadabridge_audit_purger') IS NOT NULL +BEGIN + REVOKE ALTER ANY DATASPACE FROM scadabridge_audit_purger; + REVOKE UPDATE (SourceNode) ON dbo.AuditLog FROM scadabridge_audit_purger; +END"); // AUDIT-PURGE-ALLOWED: role grant reversal for the maintenance principal + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs index a8b5593a..f07498d6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs @@ -115,7 +115,11 @@ public static class ServiceCollectionExtensions // hosted service. Scoped because the implementation reuses the per-scope // ScadaBridgeDbContext for raw-SQL execution; the hosted service opens a // fresh scope on each tick (mirrors AuditLogPurgeActor / AuditLogIngestActor). - services.AddScoped(); + // Runs on the maintenance connection when AuditLog:MaintenanceConnectionString is set + // (ScadaBridge#113), else on the scope's own context as before. + services.AddScoped(sp => new AuditLogPartitionMaintenance( + sp.GetRequiredService().Context, + sp.GetService>())); // AuditLog maintenance connection (ScadaBridge#107 item 3): the chain job and both purge // paths run on AuditMaintenanceContext — the scope's own context unless a separate diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Health/AuditChainHeadExportHealthCheck.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Health/AuditChainHeadExportHealthCheck.cs new file mode 100644 index 00000000..172acbb1 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Health/AuditChainHeadExportHealthCheck.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ZB.MOM.WW.ScadaBridge.AuditLog.Central.HeadExport; + +namespace ZB.MOM.WW.ScadaBridge.Host.Health; + +/// +/// /health/ready entry (ScadaBridge#115): Degraded while the off-box AuditLog chain-head export +/// (AuditLog:HashChain:HeadExport) is failing, or has had no success for more than two +/// intervals. Degraded maps to HTTP 200, so it shows on the Overview dashboard without taking +/// the node out of rotation. Healthy when the export is disabled. +/// +public sealed class AuditChainHeadExportHealthCheck : IHealthCheck +{ + private readonly AuditChainHeadExportStatus _status; + private readonly TimeProvider _time; + + /// Initializes the check. + /// The export status singleton. + /// Clock (defaults to the system clock). + public AuditChainHeadExportHealthCheck(AuditChainHeadExportStatus status, TimeProvider? time = null) + { + _status = status ?? throw new ArgumentNullException(nameof(status)); + _time = time ?? TimeProvider.System; + } + + /// + public Task CheckHealthAsync( + HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (!_status.Enabled) + { + return Task.FromResult(HealthCheckResult.Healthy("Chain-head export disabled.")); + } + + var now = _time.GetUtcNow().UtcDateTime; + var data = new Dictionary(StringComparer.Ordinal) + { + ["totalFailures"] = _status.TotalFailures, + ["consecutiveFailures"] = _status.ConsecutiveFailures, + ["intervalMinutes"] = _status.IntervalMinutes, + }; + if (_status.LastSuccessUtc is { } ok) + { + data["lastSuccessUtc"] = ok; + } + + if (_status.ConsecutiveFailures > 0) + { + return Task.FromResult(HealthCheckResult.Degraded( + $"Chain-head export failing ({_status.ConsecutiveFailures} consecutive): {_status.LastError}", data: data)); + } + + var limit = TimeSpan.FromMinutes(2L * _status.IntervalMinutes); + var reference = _status.LastSuccessUtc ?? _status.StartedUtc; + if (reference is { } r && now - r > limit) + { + return Task.FromResult(HealthCheckResult.Degraded( + $"No successful chain-head export for more than 2 intervals (last {(_status.LastSuccessUtc is null ? "never" : _status.LastSuccessUtc.Value.ToString("O"))}).", data: data)); + } + + return Task.FromResult(HealthCheckResult.Healthy("Chain-head export OK.", data)); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index ecdfc7dc..f646b9e9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -579,6 +579,12 @@ try "bundle-signing-policy", failureStatus: null, tags: new[] { ZbHealthTags.Ready }) + // ScadaBridge#115: Degraded (HTTP 200) while the off-box chain-head export is failing + // or stale; healthy when the export is disabled. + .AddTypeActivatedCheck( + "audit-chain-head-export", + failureStatus: null, + tags: new[] { ZbHealthTags.Ready }) // Oldest-Up-member check (review 01 [High]), unscoped: every central member competes for // the one active slot. Since ZB.MOM.WW.Health 0.3.0 this IS the shared check — it selects // by age, so the host-local OldestNodeActiveHealthCheck that existed only to avoid the old diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs index 01c44dce..21e4ec24 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs @@ -426,7 +426,10 @@ public static class AuditEndpoints statusCode: 400); } - var repo = context.RequestServices.GetRequiredService(); + // Maintenance login when AuditLog:MaintenanceConnectionString is set (ScadaBridge#113); + // otherwise the keyed registration resolves to the runtime connection's context. + var repo = context.RequestServices.GetKeyedService(AuditLogServiceKeys.Maintenance) + ?? context.RequestServices.GetRequiredService(); var rowsUpdated = await repo.BackfillSourceNodeAsync(sentinel, beforeUtc.Value, batchSize, context.RequestAborted); return Results.Json(new diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/HeadExport/AuditChainHeadExportServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/HeadExport/AuditChainHeadExportServiceTests.cs index 863d4b98..eeda8baa 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/HeadExport/AuditChainHeadExportServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/HeadExport/AuditChainHeadExportServiceTests.cs @@ -50,7 +50,8 @@ public sealed class AuditChainHeadExportServiceTests new DateTime(2026, month, 1, 0, 0, 0, DateTimeKind.Utc), seq, Enumerable.Repeat((byte)seq, 32).ToArray(), null, null, rows, 0, new DateTime(2026, month, 2, 0, 0, 0, DateTimeKind.Utc)); - private static (AuditChainHeadExportService Service, FakeStore Store, RecordingSink Sink) Create(bool exportUnchanged = false) + private static (AuditChainHeadExportService Service, FakeStore Store, RecordingSink Sink) Create( + bool exportUnchanged = false, AuditChainHeadExportStatus? status = null, TimeProvider? time = null) { var store = new FakeStore(); var sink = new RecordingSink(); @@ -62,7 +63,7 @@ public sealed class AuditChainHeadExportServiceTests }); var service = new AuditChainHeadExportService( services.BuildServiceProvider().GetRequiredService(), - options, sink, "central-a", NullLogger.Instance); + options, sink, "central-a", NullLogger.Instance, time, status); return (service, store, sink); } @@ -120,6 +121,48 @@ public sealed class AuditChainHeadExportServiceTests Assert.Equal(10, Assert.Single(Assert.Single(sink.Writes)).LastSeq); } + /// + /// ScadaBridge#115: a failed tick bumps the failure counter metric and the status + /// (consecutive + total + last error); the next success clears the consecutive count. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task FailedTick_RecordsMetricAndStatus_SuccessResetsConsecutive() + { + var status = new AuditChainHeadExportStatus(); + var (service, store, sink) = Create(status: status); + store.Heads.Add(Head(4, 10, 10)); + long measured = 0; + using var listener = new System.Diagnostics.Metrics.MeterListener(); + listener.InstrumentPublished = (i, l) => + { + if (i.Name == "scadabridge.audit.chain_head_export.failures") + { + l.EnableMeasurementEvents(i); + } + }; + listener.SetMeasurementEventCallback((_, v, _, _) => Interlocked.Add(ref measured, v)); + listener.Start(); + + sink.Fail = true; + await service.TickAsync(default); + await service.TickAsync(default); + + Assert.Equal(2, Interlocked.Read(ref measured)); + Assert.Equal(2, status.ConsecutiveFailures); + Assert.Equal(2, status.TotalFailures); + Assert.Equal("share offline", status.LastError); + Assert.Null(status.LastSuccessUtc); + + sink.Fail = false; + await service.TickAsync(default); + + Assert.Equal(0, status.ConsecutiveFailures); + Assert.Equal(2, status.TotalFailures); + Assert.NotNull(status.LastSuccessUtc); + Assert.Equal(2, Interlocked.Read(ref measured)); + } + /// The validator ignores a disabled export and checks each sink's settings when enabled. /// Which settings to apply. /// A fragment of the expected failure, or null when valid. diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditLogArchiveCleanupTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditLogArchiveCleanupTests.cs index 2f6641be..3f357d07 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditLogArchiveCleanupTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditLogArchiveCleanupTests.cs @@ -210,6 +210,68 @@ public sealed class AuditLogArchiveCleanupTests : IClassFixture + /// ScadaBridge#114: leftovers of an interrupted archive write are removed — but only the + /// writer's own temp / manifest-less data files, old enough, in a year folder. Fresh files, + /// committed archives and foreign files survive; dry run deletes nothing. + /// + /// A task that represents the asynchronous operation. + [SkippableFact] + public async Task InterruptedWriteOrphans_AreRemoved_OnlyWhenOldAndWriterNamed() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + var dir = Dir("orphans"); + var month = Month(2027, 6); + await InsertAsync(Enumerable.Range(1, 3).Select(i => NewEvent(month.AddDays(i)))); + await DrainAsync(); + var committed = await ArchiveAsync(dir, month); + var year = Directory.CreateDirectory(Path.Combine(dir, "2027")).FullName; + + const string orphanA = "auditlog-2027-05-20270601T000000001Z"; + const string freshB = "auditlog-2027-05-20270601T000000002Z"; + var old = DateTime.UtcNow.AddDays(-3); + string Make(string name, DateTime? written = null) + { + var path = Path.Combine(year, name); + File.WriteAllText(path, "x"); + File.SetLastWriteTimeUtc(path, written ?? old); + return path; + } + + var oldTmpData = Make(orphanA + ".parquet.tmp"); + var oldTmpTomb = Make(orphanA + ".tombstones.parquet.tmp"); + var oldTmpManifest = Make(orphanA + ".manifest.json.tmp"); + var oldNoManifestData = Make(orphanA + ".parquet"); + var oldNoManifestTomb = Make(orphanA + ".tombstones.parquet"); + var freshTmp = Make(freshB + ".parquet.tmp", DateTime.UtcNow.AddHours(-1)); + var freshNoManifest = Make(freshB + ".parquet", DateTime.UtcNow.AddHours(-1)); + var foreign = Make("notes.tmp"); + var wrongName = Make("auditlog-bad.parquet.tmp"); + var wrongYear = Make("auditlog-2026-05-20260601T000000003Z.parquet.tmp"); + Directory.CreateDirectory(Path.Combine(dir, "misc")); + var otherDir = Path.Combine(dir, "misc", orphanA + ".parquet.tmp"); + File.WriteAllText(otherDir, "x"); + File.SetLastWriteTimeUtc(otherDir, old); + var committedFiles = new[] { ManifestPath(dir, committed), Path.Combine(year, committed.Name + ".parquet"), Path.Combine(year, committed.Name + ".tombstones.parquet") }; + foreach (var f in committedFiles) + { + File.SetLastWriteTimeUtc(f, old); + } + + var removed = new[] { oldTmpData, oldTmpTomb, oldTmpManifest, oldNoManifestData, oldNoManifestTomb }; + + var dry = await CleanupAsync(dir, dryRun: true); + Assert.Equal(5, dry.Items.Count(i => i.Action == AuditArchiveCleanupAction.DeletedOrphan)); + Assert.All(removed, f => Assert.True(File.Exists(f))); + + var real = await CleanupAsync(dir, dryRun: false); + Assert.Equal(5, real.Items.Count(i => i.Action == AuditArchiveCleanupAction.DeletedOrphan)); + Assert.All(removed, f => Assert.False(File.Exists(f))); + Assert.All(new[] { freshTmp, freshNoManifest, foreign, wrongName, wrongYear, otherDir }.Concat(committedFiles), f => Assert.True(File.Exists(f), f)); + + Assert.Empty((await CleanupAsync(dir, dryRun: false)).Items); + } + private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; private sealed class FixedClock(DateTime now) : TimeProvider diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditMaintenanceLoginTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditMaintenanceLoginTests.cs index 29a9afe0..66d4ce1e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditMaintenanceLoginTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditMaintenanceLoginTests.cs @@ -160,6 +160,55 @@ IF SUSER_ID(N'{MaintenanceLogin}') IS NOT NULL DROP LOGIN [{MaintenanceLogin}];" Assert.Same(plainScope.ServiceProvider.GetRequiredService(), shared.Context); } + /// + /// ScadaBridge#113: the monthly roll-forward (ALTER PARTITION FUNCTION … SPLIT RANGE) and + /// the SourceNode backfill run on the maintenance login — and are refused to the writer + /// login — with only the role grants the AuditMaintenanceGrants migration provisions. + /// + /// A task that represents the asynchronous operation. + [SkippableFact] + public async Task RollForwardAndSourceNodeBackfill_RunOnTheMaintenanceLogin_NotTheWriter() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + var writerCs = As(WriterLogin); + var maintenanceCs = As(MaintenanceLogin); + var month = new DateTime(2027, 4, 1, 0, 0, 0, DateTimeKind.Utc); + + // A pre-feature row: no SourceNode, not chained. + await using (var writerCtx = Context(writerCs)) + { + var legacy = NewEvent(month.AddDays(1), AuditChannel.ApiOutbound) with { SourceNode = null }; + await new AuditLogRepository(writerCtx).InsertIfNotExistsAsync(legacy); + } + + // Negative controls: the writer login can neither split the partition function nor backfill. + await using (var writerCtx = Context(writerCs)) + { + await Assert.ThrowsAsync(() => new AuditLogPartitionMaintenance(writerCtx).EnsureLookaheadAsync(60)); + await Assert.ThrowsAsync(() => + new AuditLogRepository(writerCtx).BackfillSourceNodeAsync("unknown", month.AddYears(1), 100)); + } + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddConfigurationDatabase(writerCs, maintenanceCs); + await using var provider = services.BuildServiceProvider(); + await using (var scope = provider.CreateAsyncScope()) + { + var maintenance = scope.ServiceProvider.GetRequiredService(); + var roll = scope.ServiceProvider.GetRequiredService(); + var before = await roll.GetMaxBoundaryAsync(); + var added = await roll.EnsureLookaheadAsync(60); + Assert.NotEmpty(added); + Assert.True(await roll.GetMaxBoundaryAsync() > before); + + var repo = scope.ServiceProvider.GetRequiredKeyedService( + ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.AuditLogServiceKeys.Maintenance); + Assert.Equal(1, await repo.BackfillSourceNodeAsync("unknown", month.AddYears(1), 100)); + Assert.Equal(MaintenanceLogin, await LoginNameAsync(maintenance.Context)); + } + } + private string As(string login) => new SqlConnectionStringBuilder(_fixture.ConnectionString) { UserID = login, diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AuditChainHeadExportHealthCheckTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AuditChainHeadExportHealthCheckTests.cs new file mode 100644 index 00000000..6c0aa08f --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AuditChainHeadExportHealthCheckTests.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ZB.MOM.WW.ScadaBridge.AuditLog.Central.HeadExport; +using ZB.MOM.WW.ScadaBridge.Host.Health; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// ScadaBridge#115: the chain-head export readiness entry. +public sealed class AuditChainHeadExportHealthCheckTests +{ + private sealed class Clock(DateTime now) : TimeProvider + { + public DateTime Now { get; set; } = now; + public override DateTimeOffset GetUtcNow() => new(DateTime.SpecifyKind(Now, DateTimeKind.Utc)); + } + + private static readonly DateTime T0 = new(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc); + + private static async Task Check(AuditChainHeadExportStatus s, Clock c) + => (await new AuditChainHeadExportHealthCheck(s, c).CheckHealthAsync(new HealthCheckContext())).Status; + + /// Disabled export is healthy; failing or stale export is Degraded; recovery clears it. + [Fact] + public async Task Status_follows_export_outcome() + { + var clock = new Clock(T0); + var status = new AuditChainHeadExportStatus(); + Assert.Equal(HealthStatus.Healthy, await Check(status, clock)); // disabled + + status.MarkStarted(60, T0); + Assert.Equal(HealthStatus.Healthy, await Check(status, clock)); + + status.RecordFailure(T0, "share offline"); + var result = await new AuditChainHeadExportHealthCheck(status, clock).CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Degraded, result.Status); + Assert.Contains("share offline", result.Description); + + status.RecordSuccess(T0); + Assert.Equal(HealthStatus.Healthy, await Check(status, clock)); + + clock.Now = T0.AddMinutes(121); // no success for > 2 intervals + Assert.Equal(HealthStatus.Degraded, await Check(status, clock)); + status.RecordSuccess(clock.Now); + Assert.Equal(HealthStatus.Healthy, await Check(status, clock)); + } +}