From b449fe201a3ea973a2eaed0ee0d59755867c5eb1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 11:37:36 -0400 Subject: [PATCH 1/4] fix(transport): curate BundleLockedException on the management surface (register row 51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ManagementService references Commons but not Transport, so MapFault could not see BundleLockedException and the per-bundle 3-strike lockout reached the CLI as "An internal error occurred (CorrelationId=...)" — the operator got a correlation id instead of the reason, and no hint that further attempts on that bundle would be refused. Applies the pattern register row 44 established for BundleUnlockThrottledException: - Relocate BundleLockedException from Transport/Import/ to Commons/Types/Transport/, beside BundleUnlockThrottledException. Both consumers (BundleImporter, CentralUI TransportImport) already carried the Commons.Types.Transport using, so no call-site edits were needed. - Add curated catch arms in ManagementActor.HandlePreviewBundle and HandleImportBundle that rethrow as ManagementCommandException, so the message survives MapFault's disclosure filter. - Mirror the row-44 throttle fault-mapping test for both handlers. - Document the relocation in docs/components/Transport.md and docs/requirements/Component-Transport.md. Tests: ManagementService 499 passed (+2 new), Transport 167 passed. --- docs/components/Transport.md | 4 +- docs/requirements/Component-Transport.md | 2 +- .../Types/Transport/BundleLockedException.cs | 44 ++++++++++++++++++ .../ManagementActor.cs | 22 +++++++++ .../Import/BundleLockedException.cs | 31 ------------- .../ManagementActorTests.cs | 45 +++++++++++++++++++ 6 files changed, 114 insertions(+), 34 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleLockedException.cs delete mode 100644 src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleLockedException.cs diff --git a/docs/components/Transport.md b/docs/components/Transport.md index c005d609..65dc5d24 100644 --- a/docs/components/Transport.md +++ b/docs/components/Transport.md @@ -122,7 +122,7 @@ Passphrase lockout operates at two levels: per-bundle (3-strike counter in `Bund The two levels interact in a specific order, and it is load-bearing (register row 44 — the limiter had been registered, injected and unit-tested but never called, so only the per-bundle lockout was real): -1. **Per-bundle lockout first.** An already-locked bundle throws `BundleLockedException` without registering a rate-limiter attempt, so hammering a locked bundle cannot spend the client's hourly budget. +1. **Per-bundle lockout first.** An already-locked bundle throws `BundleLockedException` (Commons — register row 51 moved it out of `Transport.Import` for the same reason the throttle exception lives there: `ManagementService` does not reference `Transport`, so a lockout used to reach the CLI as `MapFault`'s generic "An internal error occurred" plus a correlation id) without registering a rate-limiter attempt, so hammering a locked bundle cannot spend the client's hourly budget. 2. **Per-IP throttle next, before the decrypt.** Over budget throws `BundleUnlockThrottledException` (Commons, so the management surface can catch it and return a curated message). Because it precedes the decrypt, a denial burns **no** per-bundle strike — otherwise the throttle would be a way to lock out a bundle someone is legitimately importing. Every unlock attempt is registered, successful ones included: the documented budget counts attempts, not failures. 3. Only then the AES-GCM decrypt, whose failure increments the per-bundle counter as before. @@ -255,7 +255,7 @@ A template's scripts reference a `SharedScript` or `ExternalSystem` that exists ### Passphrase lockout -After 3 wrong passphrase attempts against the same bundle (keyed by `ContentHash`), `BundleImporter.LoadAsync` throws `BundleLockedException`. The session is unusable. Re-upload the bundle file to get a new session with a fresh counter. A `BundleImportUnlockFailed` audit row is written on each failed attempt. +After 3 wrong passphrase attempts against the same bundle (keyed by `ContentHash`), `BundleImporter.LoadAsync` throws `BundleLockedException`. The session is unusable. Re-upload the bundle file to get a new session with a fresh counter. A `BundleImportUnlockFailed` audit row is written on each failed attempt. Both the Central UI and the CLI surface the lockout reason verbatim — the exception lives in `Commons/Types/Transport/` so `ManagementActor` can curate it into a `ManagementCommandException` (register row 51). ### Unlock throttled ("Too many bundle unlock attempts") diff --git a/docs/requirements/Component-Transport.md b/docs/requirements/Component-Transport.md index a1b7287e..8dba53b0 100644 --- a/docs/requirements/Component-Transport.md +++ b/docs/requirements/Component-Transport.md @@ -336,7 +336,7 @@ Imports are all-or-nothing per bundle. A bundle either applies fully or not at a - **AES-256-GCM** for content encryption with **PBKDF2-SHA256 / 600 000 iterations** (OWASP 2023+ guidance), per-bundle random salt, random IV per encryption. The GCM auth tag is verified before decryption — a wrong passphrase fails cleanly. - **Passphrase never persisted.** It lives only inside the export/import service call path and is discarded after use. No environment variable, no log line. -- **Failed-unlock rate limit:** per-bundle 3-strike lockout (keyed by `ContentHash`, server-side); per-IP-per-hour cap (`MaxUnlockAttemptsPerIpPerHour`, default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row. The two are evaluated in order — bundle lockout, then per-IP throttle, then decrypt — so hammering an already-locked bundle spends no hourly budget, and a throttle denial (`BundleUnlockThrottledException`, fail-closed with a curated message on both the UI and CLI surfaces) burns **no** per-bundle strike, since it never reaches the decryptor. Successful unlocks count against the hourly budget too: it caps attempts, not failures. The client key is the caller's remote IP — from `CircuitClientAddressProvider` (snapshotted at Blazor circuit establishment, since `HttpContext` is unavailable mid-circuit) for the Central UI, and from `ManagementEnvelope.ClientIp` for the CLI; a caller with no attributable address shares a single stricter bucket rather than bypassing the throttle. Per-client isolation is only real behind a ForwardedHeaders-trusted proxy, the same caveat the login throttle carries in `Component-Security.md`. (Register row 44: the limiter existed, was configured and unit-tested, but was never invoked until this was wired.) +- **Failed-unlock rate limit:** per-bundle 3-strike lockout (keyed by `ContentHash`, server-side); per-IP-per-hour cap (`MaxUnlockAttemptsPerIpPerHour`, default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row. The two are evaluated in order — bundle lockout, then per-IP throttle, then decrypt — so hammering an already-locked bundle spends no hourly budget, and a throttle denial (`BundleUnlockThrottledException`, fail-closed with a curated message on both the UI and CLI surfaces) burns **no** per-bundle strike, since it never reaches the decryptor. Both typed exceptions live in `Commons/Types/Transport/` — `ManagementService` references Commons but not Transport, so a type declared beside the importer cannot be curated by `MapFault` and reaches the CLI as a generic internal-error message (register rows 44 and 51). Successful unlocks count against the hourly budget too: it caps attempts, not failures. The client key is the caller's remote IP — from `CircuitClientAddressProvider` (snapshotted at Blazor circuit establishment, since `HttpContext` is unavailable mid-circuit) for the Central UI, and from `ManagementEnvelope.ClientIp` for the CLI; a caller with no attributable address shares a single stricter bucket rather than bypassing the throttle. Per-client isolation is only real behind a ForwardedHeaders-trusted proxy, the same caveat the login throttle carries in `Component-Security.md`. (Register row 44: the limiter existed, was configured and unit-tested, but was never invoked until this was wired.) - **Bundle size cap** on upload (default 100 MB, configurable) to bound memory. - **In-transit:** existing HTTPS to the Central UI; no new channel. - **Audit trail is the chain of custody.** Every export, every import (including aborted ones at validation), and every unlock failure is audit-logged with source env, content hash, encrypted yes/no, and artifact summary. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleLockedException.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleLockedException.cs new file mode 100644 index 00000000..682abcca --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleLockedException.cs @@ -0,0 +1,44 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; + +/// +/// T-003: thrown by IBundleImporter.LoadAsync when an encrypted bundle has +/// exceeded the configured failed-unlock attempt limit +/// (TransportOptions.MaxUnlockAttemptsPerSession, default 3). The lockout is tracked +/// server-side keyed by , so a second tab / CLI caller +/// re-uploading the same bytes hits the same counter and cannot side-step the limit. +/// +/// Distinct from on purpose: this one means +/// "this BUNDLE has burned its strikes" and is keyed by the bundle's content hash; that one +/// means "this CLIENT has attempted too many unlocks, across any bundles" and is keyed by +/// the caller's IP. +/// +/// +/// Register row 51: lives in Commons rather than beside the importer for the same reason +/// does — it is part of the +/// IBundleImporter.LoadAsync contract, which is declared here, and the management +/// surface (which references Commons but not Transport) must be able to catch it to return +/// a clear, curated error instead of a generic internal-failure message plus a correlation id. +/// +/// +public sealed class BundleLockedException : Exception +{ + /// Number of recorded unlock failures for this bundle. + public int FailedAttempts { get; } + + /// SHA-256 (hex) of the bundle's content bytes, the lockout's tracking key. + public string BundleContentHash { get; } + + /// + /// Initializes a new . + /// + /// SHA-256 hex from . + /// Number of failures recorded against this bundle. + public BundleLockedException(string bundleContentHash, int failedAttempts) + : base( + $"Bundle is locked after {failedAttempts} failed unlock attempts. " + + "Wait for the lockout window to expire or re-export the bundle to obtain a new content hash.") + { + BundleContentHash = bundleContentHash ?? throw new ArgumentNullException(nameof(bundleContentHash)); + FailedAttempts = failedAttempts; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 5a6c8f66..7261c3b8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -3567,6 +3567,17 @@ public class ManagementActor : ReceiveActor { throw new ManagementCommandException(ex.Message); } + catch (BundleLockedException ex) + { + // Register row 51: the per-bundle 3-strike lockout. LoadAsync raises + // it from inside its own CryptographicException handler, so without + // this arm the operator saw MapFault's generic internal-error message + // plus a correlation id, never the reason. Listed ahead of the + // CryptographicException arm to keep the read order matching the + // importer's throw order (the two types are unrelated, so the + // ordering is documentation, not dispatch). + throw new ManagementCommandException(ex.Message); + } catch (System.Security.Cryptography.CryptographicException) { throw new ManagementCommandException("Wrong passphrase or bundle was tampered."); @@ -3632,6 +3643,17 @@ public class ManagementActor : ReceiveActor { throw new ManagementCommandException(ex.Message); } + catch (BundleLockedException ex) + { + // Register row 51: the per-bundle 3-strike lockout. LoadAsync raises + // it from inside its own CryptographicException handler, so without + // this arm the operator saw MapFault's generic internal-error message + // plus a correlation id, never the reason. Listed ahead of the + // CryptographicException arm to keep the read order matching the + // importer's throw order (the two types are unrelated, so the + // ordering is documentation, not dispatch). + throw new ManagementCommandException(ex.Message); + } catch (System.Security.Cryptography.CryptographicException) { throw new ManagementCommandException("Wrong passphrase or bundle was tampered."); diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleLockedException.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleLockedException.cs deleted file mode 100644 index 987563a4..00000000 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleLockedException.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace ZB.MOM.WW.ScadaBridge.Transport.Import; - -/// -/// T-003: thrown by when an encrypted bundle has -/// exceeded the configured failed-unlock attempt limit -/// (). The lockout is tracked -/// server-side keyed by BundleManifest.ContentHash, so a second tab / CLI caller -/// re-uploading the same bytes hits the same counter and cannot side-step the limit. -/// -public sealed class BundleLockedException : Exception -{ - /// Number of recorded unlock failures for this bundle. - public int FailedAttempts { get; } - - /// SHA-256 (hex) of the bundle's content bytes, the lockout's tracking key. - public string BundleContentHash { get; } - - /// - /// Initializes a new . - /// - /// SHA-256 hex from BundleManifest.ContentHash. - /// Number of failures recorded against this bundle. - public BundleLockedException(string bundleContentHash, int failedAttempts) - : base( - $"Bundle is locked after {failedAttempts} failed unlock attempts. " - + "Wait for the lockout window to expire or re-export the bundle to obtain a new content hash.") - { - BundleContentHash = bundleContentHash ?? throw new ArgumentNullException(nameof(bundleContentHash)); - FailedAttempts = failedAttempts; - } -} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 84ff8003..d7f7c165 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -3219,6 +3219,51 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Too many bundle unlock attempts", response.Error); } + [Fact] + public void ImportBundleCommand_LockedBundle_SurfacesTheCuratedReason() + { + // Register row 51: the per-bundle 3-strike lockout used to reach the CLI as + // MapFault's generic "An internal error occurred (CorrelationId=...)" because + // BundleLockedException lived in Transport, which ManagementService does not + // reference. It now lives in Commons alongside BundleUnlockThrottledException + // and is curated the same way. + var (_, importer) = AddBundleSubstitutes(); + + importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => + throw new Commons.Types.Transport.BundleLockedException("abc123", 3)); + + var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 }); + var actor = CreateActor(); + actor.Tell(Envelope(new ImportBundleCommand(payload, "wrong", "skip"), "Administrator")); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + Assert.Contains("locked after 3 failed unlock attempts", response.Error); + Assert.DoesNotContain("An internal error occurred", response.Error); + } + + [Fact] + public void PreviewBundleCommand_LockedBundle_SurfacesTheCuratedReason() + { + // Register row 51: preview shares the same LoadAsync fault surface as import, + // so it needs the same curated arm. + var (_, importer) = AddBundleSubstitutes(); + + importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => + throw new Commons.Types.Transport.BundleLockedException("abc123", 3)); + + var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 }); + var actor = CreateActor(); + actor.Tell(Envelope(new PreviewBundleCommand(payload, "wrong"), "Administrator")); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + Assert.Contains("locked after 3 failed unlock attempts", response.Error); + Assert.DoesNotContain("An internal error occurred", response.Error); + } + // ======================================================================== // Native alarm source CRUD (Task 21) // ======================================================================== From 372ea4df1b0af2acfaccfdf7f4d8c61c0590e918 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 11:42:31 -0400 Subject: [PATCH 2/4] feat(transport): observability for the import-session cap (register row 53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing counted open import sessions and nothing logged a rejection at MaxConcurrentImportSessions (default 8), so the register-row-43 session leak could only be inferred from rotating test failures. - Gauge scadabridge.central.transport.import_sessions.open on the existing ZB.MOM.WW.ScadaBridge meter — already in SiteServiceRegistration.ObservedMeters (the ZbTelemetryOptions.Meters allowlist), so no telemetry config change is needed and the series is not silently dropped. Provider-backed, mirroring the StoreAndForward queue-depth gauge: registered by BundleSessionEvictionService .StartAsync (Transport's only lifecycle seam) and identity-checked-cleared on StopAsync so a late stop cannot clobber a newer registrant. - IBundleSessionStore.OpenSessionCount counts only UNEXPIRED sessions. The raw dictionary count would report the node as pinned at the cap for up to a one-minute sweep interval after a burst had drained — the exact false positive the instrument exists to rule out. - BundleSessionStore.Open logs Warning with open count, cap and TTL when it rejects. Fires only on a real cap hit: a store full of expired sessions is reclaimed first and logs nothing. - Docs: docs/components/Transport.md + docs/requirements/Component-Transport.md. Tests: Transport 173 passed (+6 new — Warning content, two negative controls, unexpired-only counting, gauge register/clear, no-clobber-on-late-stop). --- docs/components/Transport.md | 5 + docs/requirements/Component-Transport.md | 2 +- .../Transport/IBundleSessionStore.cs | 9 + .../Observability/ScadaBridgeTelemetry.cs | 60 +++++ .../Import/BundleSessionEvictionService.cs | 39 +++ .../Import/BundleSessionStore.cs | 46 +++- .../Import/BundleSessionObservabilityTests.cs | 248 ++++++++++++++++++ 7 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs diff --git a/docs/components/Transport.md b/docs/components/Transport.md index 65dc5d24..91cb2d19 100644 --- a/docs/components/Transport.md +++ b/docs/components/Transport.md @@ -49,6 +49,11 @@ After `LoadAsync` validates and decrypts a bundle, the plaintext content is stor Without this, an abandoned wizard or a plain CLI preview held one of the eight `MaxConcurrentImportSessions` slots — and the decrypted bundle content — for the full TTL; eight of them locked out every import on the active node (deferred register row 43, observed live). +**Session-cap observability (register row 53).** That leak was only ever inferable from rotating test failures, because nothing counted open sessions and nothing logged a rejection. Two signals now close the gap: + +- **Gauge `scadabridge.central.transport.import_sessions.open`** — open (unexpired) sessions on this node, on the `ZB.MOM.WW.ScadaBridge` meter, which is already in `SiteServiceRegistration.ObservedMeters` (the `ZbTelemetryOptions.Meters` allowlist; an unlisted meter exports nothing, silently). The provider is registered by `BundleSessionEvictionService.StartAsync` and identity-checked-cleared on `StopAsync`, mirroring the StoreAndForward queue-depth gauge. `IBundleSessionStore.OpenSessionCount` deliberately excludes expired-but-unswept entries — counting the raw dictionary would report the node as pinned at the cap for up to a sweep interval after a burst had actually drained, which is the exact false positive this instrument exists to rule out. Transport is registered only in the host's Central branch, so the gauge reports 0 on a site node. +- **Warning log on rejection** — `BundleSessionStore.Open` logs the open count, the cap, and the TTL when it rejects. It fires only on a real cap hit: a store full of *expired* sessions is reclaimed first and logs nothing. A count that stays pinned at the cap across repeated warnings is a leak; an occasional warning is genuine concurrency, and the fix there is to raise `ScadaBridge:Transport:MaxConcurrentImportSessions` (each slot pins up to `MaxBundleSizeMb` of decrypted plaintext, so the cap is a memory budget). + ### Conflict resolution and `BundleImportId` correlation `PreviewAsync` compares each bundle artifact against the target environment using `ArtifactDiff`, classifying items as `Identical`, `Modified`, `New`, or `Blocker`. The operator assigns a `ResolutionAction` (`Add`, `Overwrite`, `Skip`, `Rename`) per item. `ApplyAsync` honours those resolutions in a single EF transaction and threads a new `BundleImportId` GUID through every per-entity audit row via the scoped `IAuditCorrelationContext`. This makes every configuration row written by a bundle import queryable as a group from the audit log. diff --git a/docs/requirements/Component-Transport.md b/docs/requirements/Component-Transport.md index 8dba53b0..8da888d1 100644 --- a/docs/requirements/Component-Transport.md +++ b/docs/requirements/Component-Transport.md @@ -134,7 +134,7 @@ flowchart TD class T muted ``` -The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a `MaxConcurrentImportSessions` cap (default 8; arch-review 05 Task 24 — startup-validated as `> 0` by `TransportOptionsValidator`, since a zero/negative cap would reject every import session forever) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory. +The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a `MaxConcurrentImportSessions` cap (default 8; arch-review 05 Task 24 — startup-validated as `> 0` by `TransportOptionsValidator`, since a zero/negative cap would reject every import session forever) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory. **The cap is observable (register row 53):** open (unexpired) sessions are published as the `scadabridge.central.transport.import_sessions.open` gauge on the `ZB.MOM.WW.ScadaBridge` meter — already in the `ObservedMeters` allowlist, so no telemetry config change is needed — and a rejection at the cap logs at Warning with the current count, the cap and the TTL. Reclaiming a store full of *expired* sessions is not a cap hit and logs nothing. **Session release is explicit, not TTL-only (register row 43).** `ApplyAsync` releases the session it applied (zero-then-remove, T-007), and `IBundleImporter.Abandon(sessionId)` does the same for a session that will never be applied. Every abandonment path calls it: the Central UI wizard on new upload / Back-to-Upload / `ResetSessionState` / lockout reset / `Dispose` (navigation away or circuit teardown), the CLI `transport preview` handler (a preview never applies), and the CLI `transport import` handler for the aborts that precede apply (blocker rows, unresolved mappings). `Abandon` is idempotent — double release, an unknown id, and an already-expired id are all silent no-ops — and since session ids are random GUIDs handed only to their opener, abandoning the id you hold can never evict another operator's in-progress import. Relying on the TTL alone was an availability *and* secret-lifetime defect: eight abandoned wizards exhausted `MaxConcurrentImportSessions` and locked out every import on the active node for 30 minutes, each still holding its decrypted bundle content. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleSessionStore.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleSessionStore.cs index cdc493ec..efcb5c46 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleSessionStore.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleSessionStore.cs @@ -18,6 +18,15 @@ public interface IBundleSessionStore /// Removes all sessions whose expiry has passed. void EvictExpired(); + /// + /// Register row 53: the number of sessions currently held that have not yet expired. + /// Backs the scadabridge.central.transport.import_sessions.open gauge, so it + /// must be cheap and side-effect-free (it is read from the metrics collection thread) + /// and must NOT count expired-but-unswept entries — otherwise the gauge would read + /// as "at the cap" for up to a sweep interval after a burst drained. + /// + int OpenSessionCount { get; } + /// /// T-003: returns the current unlock-failure count for a bundle keyed by its /// content hash. The counter is server-owned so a second tab / CLI caller diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs index 0ffbf176..b2431fe4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs @@ -118,6 +118,14 @@ public static class ScadaBridgeTelemetry /// Provider that yields the live StoreAndForward queue depth; set by a later task. private static Func? _queueDepthProvider; + /// + /// Register row 53: provider that yields the number of currently open (unexpired) + /// Transport bundle-import sessions, registered by the Transport component's + /// session-eviction hosted service. Null on a site node (Transport is central-only), + /// where the gauge simply reports 0. + /// + private static Func? _importSessionCountProvider; + #pragma warning disable IDE0052 // Held to keep the observable gauges alive for the meter's lifetime. /// Gauge reporting the number of currently open site connections. private static readonly ObservableGauge _siteConnectionUp = @@ -138,6 +146,20 @@ public static class ScadaBridgeTelemetry () => Interlocked.Read(ref _liveAlarmAggregatorsActive), unit: "1", description: "Number of per-site live-alarm aggregators currently running on the active central node."); + + /// + /// Register row 53: gauge reporting the number of open Transport bundle-import + /// sessions on this central node. Each open session pins a fully decrypted bundle + /// in memory and consumes one of TransportOptions.MaxConcurrentImportSessions + /// (default 8), so a series that plateaus at the cap is the signature of leaked + /// sessions — the failure mode register row 43 could only be inferred from rotating + /// test failures because nothing counted them. + /// + private static readonly ObservableGauge _transportImportSessionsOpen = + Meter.CreateObservableGauge("scadabridge.central.transport.import_sessions.open", + () => Volatile.Read(ref _importSessionCountProvider) is { } p ? p() : 0L, + unit: "1", + description: "Number of currently open bundle-import sessions on this central node."); #pragma warning restore IDE0052 // ---------------- Emit helpers ---------------- @@ -242,4 +264,42 @@ public static class ScadaBridgeTelemetry // delegate, so a stale stop cannot clobber a successor's provider. Interlocked.CompareExchange(ref _queueDepthProvider, null, provider); } + + /// + /// Register row 53: registers the provider the open-import-sessions gauge reads on + /// each observation. Called by the Transport component's session-eviction hosted + /// service on start. A null provider is ignored so the gauge falls back to 0. + /// The callback is invoked synchronously on the metrics collection thread, so it + /// must stay cheap — the Transport implementation reads a + /// it + /// already owns. + /// + /// A callback returning the current open-session count. + public static void SetImportSessionCountProvider(Func provider) + { + if (provider is null) + { + return; + } + + Volatile.Write(ref _importSessionCountProvider, provider); + } + + /// + /// Clears the open-import-sessions provider, but only if the currently registered + /// provider is the exact delegate passed in + /// (reference-equal compare-and-clear), so a late stop of a superseded instance + /// cannot stomp a newer registrant. Mirrors . + /// + /// The provider delegate to remove; ignored unless it is the + /// one currently registered. + public static void ClearImportSessionCountProvider(Func provider) + { + if (provider is null) + { + return; + } + + Interlocked.CompareExchange(ref _importSessionCountProvider, null, provider); + } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionEvictionService.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionEvictionService.cs index a0c36115..cae75a32 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionEvictionService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionEvictionService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; +using ZB.MOM.WW.ScadaBridge.Commons.Observability; namespace ZB.MOM.WW.ScadaBridge.Transport.Import; @@ -21,6 +22,15 @@ internal sealed class BundleSessionEvictionService : BackgroundService private readonly IBundleSessionStore _sessionStore; private readonly ILogger _logger; + /// + /// Register row 53: the open-session gauge callback, held in a field so + /// can pass the SAME delegate reference to + /// — the clear is + /// an identity-checked compare-and-clear, so a stale stop cannot unregister a newer + /// instance's provider. Mirrors StoreAndForwardService's queue-depth registration. + /// + private readonly Func _openSessionCountProvider; + /// /// Initializes a new instance of . /// @@ -32,6 +42,35 @@ internal sealed class BundleSessionEvictionService : BackgroundService { _sessionStore = sessionStore ?? throw new ArgumentNullException(nameof(sessionStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _openSessionCountProvider = () => _sessionStore.OpenSessionCount; + } + + /// + /// Register row 53: registers the open-import-sessions gauge provider before the + /// sweep loop starts. This service is the Transport component's only lifecycle seam + /// (the session store itself is a plain singleton), and it is registered by + /// AddTransport, which only the Central branch of the host calls — so the + /// gauge reports 0 on a site node, where there are no import sessions to count. + /// + /// Host startup cancellation token. + /// A task that completes when the base has started. + public override Task StartAsync(CancellationToken cancellationToken) + { + ScadaBridgeTelemetry.SetImportSessionCountProvider(_openSessionCountProvider); + return base.StartAsync(cancellationToken); + } + + /// + /// Deregisters this instance's gauge provider on graceful stop so the gauge falls + /// back to 0 rather than reading through a dead store. The clear is identity-checked, + /// so a late stop cannot clobber a successor that already re-registered. + /// + /// Host shutdown cancellation token. + /// A task that completes when the base has stopped. + public override async Task StopAsync(CancellationToken cancellationToken) + { + ScadaBridgeTelemetry.ClearImportSessionCountProvider(_openSessionCountProvider); + await base.StopAsync(cancellationToken).ConfigureAwait(false); } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionStore.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionStore.cs index 1d52735b..ccac6231 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionStore.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionStore.cs @@ -1,4 +1,6 @@ using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; @@ -48,6 +50,7 @@ public sealed class BundleSessionStore : IBundleSessionStore private readonly TimeProvider _timeProvider; private readonly IOptions _options; + private readonly ILogger _logger; /// T-003: per-bundle unlock-failure entry with expiry. private sealed class UnlockFailureRecord @@ -61,12 +64,42 @@ public sealed class BundleSessionStore : IBundleSessionStore /// /// Transport options. is also used as the TTL for the T-003 per-bundle unlock-failure tracker. /// Time provider used to evaluate session expiry. - public BundleSessionStore(IOptions options, TimeProvider timeProvider) + /// Optional logger. Register row 53: a rejection at is logged at Warning, so a session leak is diagnosable from the node log instead of being inferred from rotating test failures. + public BundleSessionStore( + IOptions options, + TimeProvider timeProvider, + ILogger? logger = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _ = options.Value; _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? NullLogger.Instance; + } + + /// + public int OpenSessionCount + { + get + { + // Register row 53: count only unexpired entries. _sessions.Count would + // include expired-but-unswept sessions (the eviction sweep runs once a + // minute), so the gauge would read "pinned at the cap" for up to a sweep + // interval after a burst had actually drained — exactly the false signal + // this instrument exists to rule out. Read-only: the gauge callback runs + // on the metrics collection thread and must not mutate the store. + var now = _timeProvider.GetUtcNow(); + var count = 0; + foreach (var kv in _sessions) + { + if (kv.Value.ExpiresAt > now) + { + count++; + } + } + + return count; + } } /// @@ -82,6 +115,17 @@ public sealed class BundleSessionStore : IBundleSessionStore EvictExpired(); if (_sessions.Count >= cap) { + // Register row 53: this rejection used to be silent apart from the + // exception the caller saw. A cap hit is either genuine concurrency + // (many operators importing at once) or a session leak like register + // row 43's — the count-vs-cap pair plus the recurrence rate in the + // log is what distinguishes them. + _logger.LogWarning( + "Bundle import session rejected: {OpenSessions} of {Cap} concurrent import sessions are open " + + "(ScadaBridge:Transport:MaxConcurrentImportSessions). Sessions release on apply, cancel or " + + "the {TtlMinutes}-minute TTL; a count that stays pinned at the cap indicates leaked sessions.", + _sessions.Count, cap, _options.Value.BundleSessionTtlMinutes); + throw new InvalidOperationException( $"Too many concurrent import sessions ({cap}). Complete or cancel an in-progress import before starting another."); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs new file mode 100644 index 00000000..ca9f0939 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs @@ -0,0 +1,248 @@ +using System.Diagnostics.Metrics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Commons.Observability; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; +using ZB.MOM.WW.ScadaBridge.Transport.Import; + +namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import; + +/// +/// Register row 53: MaxConcurrentImportSessions had no observability — nothing +/// counted open sessions and nothing logged a rejection at the cap, so the register-row-43 +/// session leak could only be inferred from rotating test failures. These tests pin the +/// two signals that close that gap: the +/// scadabridge.central.transport.import_sessions.open gauge and the Warning-level +/// log emitted when rejects at the cap. +/// +public sealed class BundleSessionObservabilityTests +{ + private const string OpenSessionsGauge = "scadabridge.central.transport.import_sessions.open"; + + // ------------------------------------------------------------------ + // Warning log on cap rejection + // ------------------------------------------------------------------ + + [Fact] + public void Open_AtCap_LogsWarningWithCountAndCap() + { + // The rejection used to be visible only as the exception the caller saw. The + // operator-facing signal must name BOTH the current count and the cap — that + // pair is what distinguishes genuine concurrency from a leak. + var clock = new TestClock(DateTimeOffset.UtcNow); + var opts = Options.Create(new TransportOptions { MaxConcurrentImportSessions = 3 }); + var logger = new CapturingLogger(); + var sut = new BundleSessionStore(opts, clock, logger); + var future = clock.GetUtcNow().AddMinutes(30); + + for (var i = 0; i < 3; i++) + { + sut.Open(SessionExpiringAt(future)); + } + + Assert.Throws(() => sut.Open(SessionExpiringAt(future))); + + var warning = Assert.Single(logger.Entries, e => e.Level == LogLevel.Warning); + Assert.Contains("Bundle import session rejected", warning.Message, StringComparison.Ordinal); + Assert.Contains("3 of 3", warning.Message, StringComparison.Ordinal); + Assert.Contains("MaxConcurrentImportSessions", warning.Message, StringComparison.Ordinal); + } + + [Fact] + public void Open_UnderCap_LogsNothing() + { + // Negative control: the Warning must be a cap signal, not per-open noise. + var clock = new TestClock(DateTimeOffset.UtcNow); + var opts = Options.Create(new TransportOptions { MaxConcurrentImportSessions = 3 }); + var logger = new CapturingLogger(); + var sut = new BundleSessionStore(opts, clock, logger); + + sut.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); + + Assert.Empty(logger.Entries); + } + + [Fact] + public void Open_AtCapOfExpiredSessions_ReclaimsAndLogsNothing() + { + // A store that is full only of stale entries reclaims and admits the caller — + // that is not a cap hit and must not produce a Warning, or the signal would fire + // routinely on any node whose sweep has not yet run. + var clock = new TestClock(DateTimeOffset.UtcNow); + var opts = Options.Create(new TransportOptions { MaxConcurrentImportSessions = 2 }); + var logger = new CapturingLogger(); + var sut = new BundleSessionStore(opts, clock, logger); + + sut.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(5))); + sut.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(5))); + clock.Advance(TimeSpan.FromMinutes(10)); + + sut.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); + + Assert.Empty(logger.Entries); + } + + // ------------------------------------------------------------------ + // OpenSessionCount / gauge + // ------------------------------------------------------------------ + + [Fact] + public void OpenSessionCount_ExcludesExpiredButUnsweptSessions() + { + // The eviction sweep runs once a minute, so a raw dictionary count would report + // the store as pinned at the cap for up to a sweep interval after a burst had + // actually drained — exactly the false signal this instrument exists to rule out. + var clock = new TestClock(DateTimeOffset.UtcNow); + var sut = new BundleSessionStore(Options.Create(new TransportOptions()), clock, NullLogger.Instance); + + sut.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(5))); + var live = SessionExpiringAt(clock.GetUtcNow().AddMinutes(60)); + sut.Open(live); + + Assert.Equal(2, sut.OpenSessionCount); + + clock.Advance(TimeSpan.FromMinutes(10)); + + // The short session has expired but nothing has swept it yet. + Assert.Equal(1, sut.OpenSessionCount); + } + + [Fact] + public async Task EvictionService_RegistersTheGaugeProvider_AndClearsItOnStop() + { + // The eviction service is Transport's only lifecycle seam, so it owns the + // process-global gauge slot. The gauge is read as a delta against a baseline + // because the instrument is a process-wide static. + var clock = new TestClock(DateTimeOffset.UtcNow); + var store = new BundleSessionStore(Options.Create(new TransportOptions()), clock, NullLogger.Instance); + var service = new BundleSessionEvictionService(store, NullLogger.Instance); + + var baseline = ReadGauge(); + + await service.StartAsync(CancellationToken.None); + try + { + store.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); + store.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); + + Assert.Equal(2, ReadGauge()); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + + // After the clear the gauge falls back to reporting 0, not through a dead store. + Assert.Equal(0, ReadGauge()); + Assert.Equal(0, baseline); + } + + [Fact] + public async Task EvictionService_StopDoesNotClobberANewerInstancesProvider() + { + // Identity-checked compare-and-clear: a late stop of a superseded instance must + // leave a newer registrant's provider in place (mirrors the StoreAndForward + // queue-depth gauge's contract). + var clock = new TestClock(DateTimeOffset.UtcNow); + var oldStore = new BundleSessionStore(Options.Create(new TransportOptions()), clock, NullLogger.Instance); + var newStore = new BundleSessionStore(Options.Create(new TransportOptions()), clock, NullLogger.Instance); + var oldService = new BundleSessionEvictionService(oldStore, NullLogger.Instance); + var newService = new BundleSessionEvictionService(newStore, NullLogger.Instance); + + await oldService.StartAsync(CancellationToken.None); + await newService.StartAsync(CancellationToken.None); + try + { + newStore.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); + + // Late stop of the superseded instance. + await oldService.StopAsync(CancellationToken.None); + + Assert.Equal(1, ReadGauge()); + } + finally + { + await newService.StopAsync(CancellationToken.None); + } + + Assert.Equal(0, ReadGauge()); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /// + /// Forces one synchronous observation of the open-import-sessions gauge and returns + /// the measured value. Mirrors the MeterListener helper used by the Communication + /// and StoreAndForward telemetry suites. + /// + /// The gauge's current value, or 0 if it never reported. + private static long ReadGauge() + { + long observed = 0; + using var listener = new MeterListener(); + listener.InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName + && instrument.Name == OpenSessionsGauge) + { + l.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback((_, measurement, _, _) => observed = measurement); + listener.Start(); + listener.RecordObservableInstruments(); + return observed; + } + + private static BundleSession SessionExpiringAt(DateTimeOffset expiresAt) => new() + { + SessionId = Guid.NewGuid(), + Manifest = StubManifest(), + DecryptedContent = Array.Empty(), + ExpiresAt = expiresAt, + }; + + private static BundleManifest StubManifest() => new( + BundleFormatVersion: 1, + SchemaVersion: "1.0", + CreatedAtUtc: DateTimeOffset.UnixEpoch, + SourceEnvironment: "test", + ExportedBy: "t", + ScadaBridgeVersion: "1", + ContentHash: "0", + Encryption: null, + Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0), + Contents: Array.Empty()); + + /// Minimal mutable-clock for deterministic expiry. + private sealed class TestClock : TimeProvider + { + private DateTimeOffset _now; + public TestClock(DateTimeOffset start) { _now = start; } + public override DateTimeOffset GetUtcNow() => _now; + public void Advance(TimeSpan by) => _now = _now.Add(by); + } + + /// Captures formatted log entries so the cap-rejection Warning can be asserted. + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add((logLevel, formatter(state, exception))); + } + } +} From 677263af576a3372f1db91fdf01c426ffea85dfa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 11:52:34 -0400 Subject: [PATCH 3/4] feat(transport): fleet-wide bundle-unlock throttle window (register row 52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-IP unlock throttle's counters were a process-local ConcurrentDictionary on each central node, so the effective fleet cap was N x MaxUnlockAttemptsPerIpPerHour per client (failing over to the other node handed the client a fresh budget) and a node restart cleared the window outright. The window now lives in the central MS SQL BundleUnlockAttempt table — the HA store both central nodes already share, so no new infrastructure, no new port, no consensus protocol: - Commons: BundleUnlockAttempt entity + IBundleUnlockAttemptRepository. - ConfigurationDatabase: entity config (one index, IX_BundleUnlockAttempt_Client_Attempted, serving both the windowed count and the prune), repository (AsNoTracking count, ExecuteDeleteAsync prune), DbSet, DI registration, migration AddBundleUnlockAttemptTable + the hand-written idempotent production script under docs/plans/sql/. - Transport: IBundleUnlockAttemptWindow seam + SqlBundleUnlockAttemptWindow (singleton; resolves the scoped repository from its own scope per call, like the central purge singletons). BundleUnlockRateLimiter keeps the policy and gains TryRegisterAttemptAsync; BundleImporter.LoadAsync calls it. Behaviour preserved from row 44: locked bundles spend no hourly budget (the lockout check still runs first), throttle denials burn no per-bundle strike and are NOT recorded so they cannot roll the window forward, unattributable clients share the stricter bucket, and the budget counts attempts rather than failures. Config keys and their semantics are unchanged, so the rig's 500 override stays valid. Three deliberate trades, documented in both Transport docs: - FAILS OPEN. A store error logs a Warning naming the client and cap, and permits the attempt. This is a best-effort rate cap over an already authenticated, Administrator-gated surface — the availability of legitimate imports outranks the cap, and a DB blip must not make the import wizard unusable. Falling back to the process-local window was considered and rejected: it would still block a legitimate operator's 11th import. - Approximate. Count-then-insert is not atomic across nodes, so simultaneous attempts can overshoot by the number in flight — bounded, self-correcting within the window, and not worth provider-specific raw SQL. - Pruned opportunistically on the unlock path (CAS-claimed at most every 15 min per process, bounded to 1000 rows, retaining 2x the window, best-effort try/catch) rather than by a new background service — Transport owns no central scheduler. Same shape as DeploymentService's terminal-record sweep. Tests: Transport.IntegrationTests 116 passed (+10 — two limiter instances over one store enforcing a single combined window, restart durability, denials burning no budget, sliding release, bucket isolation, store-failure fail-open, caller-cancellation not swallowed, no-shared-window fallback, prune and its negative control); ConfigurationDatabase 395 passed (+5). --- docs/components/ConfigurationDatabase.md | 8 +- docs/components/Transport.md | 10 +- .../plans/sql/AddBundleUnlockAttemptTable.sql | 45 + .../Component-ConfigurationDatabase.md | 3 + docs/requirements/Component-Transport.md | 2 + .../Entities/Transport/BundleUnlockAttempt.cs | 42 + .../IBundleUnlockAttemptRepository.cs | 46 + ...dleUnlockAttemptEntityTypeConfiguration.cs | 46 + ...03_AddBundleUnlockAttemptTable.Designer.cs | 2121 +++++++++++++++++ ...60815154803_AddBundleUnlockAttemptTable.cs | 41 + .../ScadaBridgeDbContextModelSnapshot.cs | 25 + .../BundleUnlockAttemptRepository.cs | 70 + .../ScadaBridgeDbContext.cs | 5 + .../ServiceCollectionExtensions.cs | 4 + .../Import/BundleImporter.cs | 9 +- .../Import/BundleUnlockRateLimiter.cs | 141 +- .../Import/IBundleUnlockAttemptWindow.cs | 28 + .../Import/SqlBundleUnlockAttemptWindow.cs | 167 ++ .../ServiceCollectionExtensions.cs | 21 +- .../BundleUnlockAttemptRepositoryTests.cs | 90 + .../Import/FleetWideUnlockThrottleTests.cs | 263 ++ 21 files changed, 3157 insertions(+), 30 deletions(-) create mode 100644 docs/plans/sql/AddBundleUnlockAttemptTable.sql create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Transport/BundleUnlockAttempt.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IBundleUnlockAttemptRepository.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/BundleUnlockAttemptEntityTypeConfiguration.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815154803_AddBundleUnlockAttemptTable.Designer.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815154803_AddBundleUnlockAttemptTable.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/BundleUnlockAttemptRepository.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Transport/Import/IBundleUnlockAttemptWindow.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Transport/Import/SqlBundleUnlockAttemptWindow.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/BundleUnlockAttemptRepositoryTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/FleetWideUnlockThrottleTests.cs diff --git a/docs/components/ConfigurationDatabase.md b/docs/components/ConfigurationDatabase.md index 5ca6a6c7..8857f5a8 100644 --- a/docs/components/ConfigurationDatabase.md +++ b/docs/components/ConfigurationDatabase.md @@ -7,7 +7,7 @@ The Configuration Database component is the exclusive EF Core data-access layer The component lives in `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/` and is central-only — site clusters never load it. Its responsibilities break down into four areas: - **DbContext + Fluent mappings** — `ScadaBridgeDbContext` maps ~30 Commons POCO entity types to SQL Server using `IEntityTypeConfiguration` classes in `Configurations/`, registered wholesale via `modelBuilder.ApplyConfigurationsFromAssembly(...)`. -- **Repository implementations** — eleven scoped repositories implement the interfaces declared in Commons, covering every domain area from template authoring to audit log ingest. +- **Repository implementations** — scoped repositories implement the interfaces declared in Commons, covering every domain area from template authoring to audit log ingest. - **Config-change audit** — `AuditService` implements `IAuditService`, staging an `AuditLogEntry` into the change tracker so it commits atomically with the entity change; `AuditCorrelationContext` threads a `BundleImportId` through `AsyncLocal` so bundle-import audit rows are correlated without cross-contaminating concurrent import sessions. - **Partition maintenance** — `AuditLogPartitionMaintenance` implements `IPartitionMaintenance`, rolling `pf_AuditLog_Month` forward by issuing `ALTER PARTITION FUNCTION … SPLIT RANGE` for each missing future monthly boundary. @@ -91,9 +91,11 @@ The predecessor `UX_AuditLog_EventId` — a single-column unique index on `[PRIM **`SiteCallEntityTypeConfiguration`** maps `SiteCall` to `dbo.SiteCalls` with a `TrackedOperationId` PK stored as `varchar(36)` (GUID in `"D"` format) so the column shape matches the wire format and the site SQLite store — one consistent format for operational debugging. +**`BundleUnlockAttemptEntityTypeConfiguration`** maps `BundleUnlockAttempt` to the table of the same name — the fleet-wide sliding window behind Transport's per-client bundle-unlock throttle (register row 52; the counters used to be per-node and per-process, so the effective cap was N × the configured limit). Operational history, not audit: non-partitioned, `[PRIMARY]` filegroup, no DB-role restriction. One named index, `IX_BundleUnlockAttempt_Client_Attempted`, serves both the windowed count and the application's opportunistic prune, which is bounded and best-effort — a store failure in this path is deliberately fail-open at the Transport layer, so nothing here may block an unlock. The table deliberately records no bundle identity; the durable record of a failed unlock is the `BundleImportUnlockFailed` audit row. + ### Repository implementations -All eleven repositories follow the same shape: they take `ScadaBridgeDbContext` by constructor injection, work with Commons POCO types, and never commit — callers invoke `SaveChangesAsync()` to commit the unit of work. +All repositories follow the same shape: they take `ScadaBridgeDbContext` by constructor injection, work with Commons POCO types, and never commit — callers invoke `SaveChangesAsync()` to commit the unit of work. **`AuditLogRepository`** is the most specialized. Its `InsertIfNotExistsAsync` bypasses the change tracker and issues raw interpolated SQL because the computed columns must not appear in the INSERT column list: @@ -152,7 +154,7 @@ services.AddConfigurationDatabase( configuration["ScadaBridge:Database:ConfigurationDb"]!); ``` -This registers `ScadaBridgeDbContext` as scoped (with the runtime encryption overload), all eleven repository interfaces bound to their implementations, `IAuditCorrelationContext` → `AuditCorrelationContext`, `IAuditService` → `AuditService`, `IInstanceLocator` → `InstanceLocator`, `IPartitionMaintenance` → `AuditLogPartitionMaintenance`, and the Data Protection key ring persisted to the database. +This registers `ScadaBridgeDbContext` as scoped (with the runtime encryption overload), every repository interface bound to its implementation, `IAuditCorrelationContext` → `AuditCorrelationContext`, `IAuditService` → `AuditService`, `IInstanceLocator` → `InstanceLocator`, `IPartitionMaintenance` → `AuditLogPartitionMaintenance`, and the Data Protection key ring persisted to the database. The obsolete zero-argument overload throws `InvalidOperationException` at startup (marked `error: true` on the `[Obsolete]` attribute) so a misconfigured host fails fast with a clear message rather than silently producing an empty DI registration. diff --git a/docs/components/Transport.md b/docs/components/Transport.md index 91cb2d19..9c39eb26 100644 --- a/docs/components/Transport.md +++ b/docs/components/Transport.md @@ -131,6 +131,14 @@ The two levels interact in a specific order, and it is load-bearing (register ro 2. **Per-IP throttle next, before the decrypt.** Over budget throws `BundleUnlockThrottledException` (Commons, so the management surface can catch it and return a curated message). Because it precedes the decrypt, a denial burns **no** per-bundle strike — otherwise the throttle would be a way to lock out a bundle someone is legitimately importing. Every unlock attempt is registered, successful ones included: the documented budget counts attempts, not failures. 3. Only then the AES-GCM decrypt, whose failure increments the per-bundle counter as before. +**The per-IP window is fleet-wide (register row 52).** It used to be a process-local `ConcurrentDictionary` on each central node, which had two consequences worth naming: the effective fleet cap was N × `MaxUnlockAttemptsPerIpPerHour` per client (a client that failed over to the other node got a fresh budget), and a node restart cleared the window outright. The window now lives in the central MS SQL `BundleUnlockAttempt` table — the HA store both central nodes already share, so this needs no new infrastructure, no new port and no consensus protocol. `BundleUnlockRateLimiter` keeps the policy; `SqlBundleUnlockAttemptWindow` is the store, resolving a scoped `IBundleUnlockAttemptRepository` from its own scope per call (it is a singleton, like the limiter). Three properties are load-bearing: + +- **It fails OPEN.** A store error logs a Warning naming the client and the cap, and *permits* the attempt. This is a best-effort rate cap layered over a surface that is already authenticated and Administrator-gated, so the availability of legitimate imports outranks the cap — a database blip must not make the import wizard unusable. Falling back to the old process-local window instead was considered and rejected: it would still block a legitimate operator's 11th import during an outage, which is precisely the outcome fail-open exists to prevent. The Warning is the operator's signal that the fleet cap is not currently being enforced. +- **Accuracy is deliberately approximate.** The count-then-insert pair is not atomic across the two nodes, so genuinely simultaneous attempts can overshoot the cap by up to the number in flight. A conditional single-statement insert would close that at the cost of provider-specific raw SQL; the overshoot is bounded and self-corrects within the window. +- **Rows are pruned opportunistically, not by a background service.** Transport owns no central scheduler, so the prune rides the unlock path: claimed by CAS at most once per 15 minutes per process, bounded to 1000 rows per pass, retaining `2 ×` the window so a prune racing a count can never delete a row the count still needs, and wrapped in a try/catch that logs and swallows — retention maintenance must never fail the operator's actual unlock. This is the same shape `DeploymentService` uses for terminal deployment records. + +Behaviour the operator sees is otherwise unchanged: same config key, same semantics ("attempts, not failures"), same ordering relative to the per-bundle lockout, same `` bucket. + The client key is the caller's remote IP: the Central UI wizard reads it from `CircuitClientAddressProvider` (a scoped service that snapshots `Connection.RemoteIpAddress` at circuit establishment, since `HttpContext` is gone by the time a circuit submits a passphrase — the same pattern as `CookieAuthenticationStateProvider`), and the management endpoint stamps it on `ManagementEnvelope.ClientIp` for the CLI `transport preview` / `transport import` paths. A caller with no attributable address is counted against a single **shared** bucket rather than skipping the throttle — "unknown client" degrades to a stricter budget, never to none. As with the login throttle (`Component-Security.md`), per-client isolation is only real when ForwardedHeaders is configured for the fronting proxy; behind an untrusted proxy every caller collapses into the proxy's bucket. ```csharp @@ -264,7 +272,7 @@ After 3 wrong passphrase attempts against the same bundle (keyed by `ContentHash ### Unlock throttled ("Too many bundle unlock attempts") -`BundleUnlockThrottledException` — this **client** (not this bundle) has used its `MaxUnlockAttemptsPerIpPerHour` budget within the trailing hour. Re-uploading does not help; the budget is keyed by IP and rolls forward on a sliding window, and the counters are process-local (a central node restart clears them, deliberately, so an outage doesn't strand legitimate operators). The denial burns no per-bundle strike, so the bundle itself is still importable once the window frees up. Raise `ScadaBridge:Transport:MaxUnlockAttemptsPerIpPerHour` if a legitimate workflow (many encrypted bundles in one sitting) trips it routinely. +`BundleUnlockThrottledException` — this **client** (not this bundle) has used its `MaxUnlockAttemptsPerIpPerHour` budget within the trailing hour. Re-uploading does not help; the budget is keyed by IP, rolls forward on a sliding window, and is **fleet-wide and restart-durable** (register row 52 — see below), so failing over to the other central node does not help either. The denial burns no per-bundle strike, so the bundle itself is still importable once the window frees up. Raise `ScadaBridge:Transport:MaxUnlockAttemptsPerIpPerHour` if a legitimate workflow (many encrypted bundles in one sitting) trips it routinely. ### BundleImportAlarmScriptUnresolved / BundleImportCompositionUnresolved warnings diff --git a/docs/plans/sql/AddBundleUnlockAttemptTable.sql b/docs/plans/sql/AddBundleUnlockAttemptTable.sql new file mode 100644 index 00000000..b5f1c2b6 --- /dev/null +++ b/docs/plans/sql/AddBundleUnlockAttemptTable.sql @@ -0,0 +1,45 @@ +-- Register row 52: the fleet-wide bundle-unlock throttle window. +-- +-- Creates the central [BundleUnlockAttempt] table that both central nodes share, so +-- ScadaBridge:Transport:MaxUnlockAttemptsPerIpPerHour is a fleet cap rather than a +-- per-node, per-process one. Operational history, not audit: non-partitioned, standard +-- [PRIMARY] filegroup, no DB-role restriction, and rows are pruned opportunistically by +-- the application once they age past twice the trailing window. +-- +-- Run with QUOTED_IDENTIFIER ON. Several tables in this database carry filtered indexes +-- and persisted computed columns, and SQL Server refuses DDL/DML against them with error +-- 1934 when the session has QUOTED_IDENTIFIER OFF -- which is exactly what `sqlcmd` gives +-- you by default (pass -I, or keep this header). The SETs sit in their own batch so they +-- are in force when the statements after the GO parse. +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260815154803_AddBundleUnlockAttemptTable' +) +BEGIN + EXEC(N'CREATE TABLE [BundleUnlockAttempt] ( + [Id] bigint NOT NULL IDENTITY, + [ClientKey] varchar(64) NOT NULL, + [AttemptedAtUtc] datetime2 NOT NULL, + CONSTRAINT [PK_BundleUnlockAttempt] PRIMARY KEY ([Id]) + )'); + + EXEC(N'CREATE INDEX [IX_BundleUnlockAttempt_Client_Attempted] + ON [BundleUnlockAttempt] ([ClientKey], [AttemptedAtUtc])'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260815154803_AddBundleUnlockAttemptTable' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260815154803_AddBundleUnlockAttemptTable', N'10.0.7'); +END; + +COMMIT; +GO diff --git a/docs/requirements/Component-ConfigurationDatabase.md b/docs/requirements/Component-ConfigurationDatabase.md index 4d61d690..491e70b2 100644 --- a/docs/requirements/Component-ConfigurationDatabase.md +++ b/docs/requirements/Component-ConfigurationDatabase.md @@ -123,6 +123,9 @@ The configuration database stores all central system data, organized by domain a - **Deployment Records**: Deployment history per instance (timestamp, user, status, deployed configuration snapshot). - **System-Wide Artifact Deployment Records**: Deployment history for shared artifacts (timestamp, user, artifact type, status). +### Transport +- **Bundle Unlock Attempts**: One row per permitted bundle-unlock passphrase attempt (`ClientKey`, `AttemptedAtUtc`), forming the fleet-wide sliding window behind Transport's `MaxUnlockAttemptsPerIpPerHour` throttle — see `Component-Transport.md` (register row 52). Operational history, not audit: non-partitioned, no DB-role restriction, and rows are pruned opportunistically by the application once they age past twice the window. **Denied** attempts are deliberately not recorded (a denial burns no budget), and no bundle identity is stored — the durable record of a failed unlock is the `BundleImportUnlockFailed` audit row. Central-only: written only by the Transport component, which the host registers for central roles. + ### Audit Logging - **Audit Log Entries**: Append-only audit trail (timestamp, user, action, entity type, entity ID, entity name, state as JSON). Stores only the after-state — change history is reconstructed by comparing consecutive entries. Entries are never modified or deleted. No retention policy — retained indefinitely. Indexed on timestamp, user, entity type, entity ID, and action for efficient filtering. diff --git a/docs/requirements/Component-Transport.md b/docs/requirements/Component-Transport.md index 8da888d1..de37bd1b 100644 --- a/docs/requirements/Component-Transport.md +++ b/docs/requirements/Component-Transport.md @@ -337,6 +337,8 @@ Imports are all-or-nothing per bundle. A bundle either applies fully or not at a - **AES-256-GCM** for content encryption with **PBKDF2-SHA256 / 600 000 iterations** (OWASP 2023+ guidance), per-bundle random salt, random IV per encryption. The GCM auth tag is verified before decryption — a wrong passphrase fails cleanly. - **Passphrase never persisted.** It lives only inside the export/import service call path and is discarded after use. No environment variable, no log line. - **Failed-unlock rate limit:** per-bundle 3-strike lockout (keyed by `ContentHash`, server-side); per-IP-per-hour cap (`MaxUnlockAttemptsPerIpPerHour`, default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row. The two are evaluated in order — bundle lockout, then per-IP throttle, then decrypt — so hammering an already-locked bundle spends no hourly budget, and a throttle denial (`BundleUnlockThrottledException`, fail-closed with a curated message on both the UI and CLI surfaces) burns **no** per-bundle strike, since it never reaches the decryptor. Both typed exceptions live in `Commons/Types/Transport/` — `ManagementService` references Commons but not Transport, so a type declared beside the importer cannot be curated by `MapFault` and reaches the CLI as a generic internal-error message (register rows 44 and 51). Successful unlocks count against the hourly budget too: it caps attempts, not failures. The client key is the caller's remote IP — from `CircuitClientAddressProvider` (snapshotted at Blazor circuit establishment, since `HttpContext` is unavailable mid-circuit) for the Central UI, and from `ManagementEnvelope.ClientIp` for the CLI; a caller with no attributable address shares a single stricter bucket rather than bypassing the throttle. Per-client isolation is only real behind a ForwardedHeaders-trusted proxy, the same caveat the login throttle carries in `Component-Security.md`. (Register row 44: the limiter existed, was configured and unit-tested, but was never invoked until this was wired.) + +**The per-IP window is fleet-wide and restart-durable (register row 52).** It is persisted in the central MS SQL `BundleUnlockAttempt` table — the HA store both central nodes already share — rather than in each node's process memory, so the documented cap is a fleet cap and not N × the cap, and a node restart no longer clears it. `BundleUnlockRateLimiter` still owns the policy; `SqlBundleUnlockAttemptWindow` is the store behind the `IBundleUnlockAttemptWindow` seam, and `IBundleUnlockAttemptRepository` (Commons interface, ConfigurationDatabase implementation) is the persistence. Three deliberate trades: **the throttle fails OPEN** — a store error logs a Warning and permits the attempt, because this is a best-effort rate cap over an already-authenticated Administrator-gated surface and the availability of legitimate imports outranks the cap; **accuracy is approximate** — count-then-insert is not atomic across nodes, so simultaneous attempts can overshoot by the number in flight, which is bounded and self-corrects within the window; and **rows are pruned opportunistically on the unlock path** (CAS-claimed at most every 15 minutes per process, bounded to 1000 rows, retaining twice the window, best-effort) rather than by a new background service, since Transport owns no central scheduler. - **Bundle size cap** on upload (default 100 MB, configurable) to bound memory. - **In-transit:** existing HTTPS to the Central UI; no new channel. - **Audit trail is the chain of custody.** Every export, every import (including aborted ones at validation), and every unlock failure is audit-logged with source env, content hash, encrypted yes/no, and artifact summary. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Transport/BundleUnlockAttempt.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Transport/BundleUnlockAttempt.cs new file mode 100644 index 00000000..b550d3d9 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Transport/BundleUnlockAttempt.cs @@ -0,0 +1,42 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport; + +/// +/// One recorded bundle-unlock passphrase attempt, written to the central +/// BundleUnlockAttempt table. Rows form the trailing-hour sliding window the +/// Transport component's per-client unlock throttle +/// (TransportOptions.MaxUnlockAttemptsPerIpPerHour) is evaluated against. +/// +/// +/// +/// Register row 52: the throttle's counters used to be per-node and per-process, so the +/// effective fleet cap was N x limit per client and a node restart cleared the window. +/// Central MS SQL is the store both central nodes already share, so putting the window +/// there makes the cap fleet-wide and restart-durable without new infrastructure. +/// +/// +/// Operational history, NOT audit — the table is non-partitioned, lives on the standard +/// [PRIMARY] filegroup and carries no DB-role restriction. Rows are pruned +/// opportunistically once they age past the window; they are not a durable record of +/// who tried to open which bundle (that is the BundleImportUnlockFailed audit row's +/// job, and note this table deliberately records no bundle identity at all). +/// +/// +/// Persistence-ignorant POCO; the EF Core mapping lives in the Configuration Database +/// component. is UTC, like every timestamp in the system. +/// +/// +public sealed class BundleUnlockAttempt +{ + /// Surrogate identity key assigned by the store. + public long Id { get; set; } + + /// + /// The throttle bucket the attempt was counted against — the caller's remote IP, or + /// the shared <unattributed> bucket when no address could be attributed. + /// Sized for an IPv6 literal with a scope id. + /// + public required string ClientKey { get; set; } + + /// The UTC instant at which the unlock attempt was registered. + public DateTime AttemptedAtUtc { get; set; } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IBundleUnlockAttemptRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IBundleUnlockAttemptRepository.cs new file mode 100644 index 00000000..b9369b10 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IBundleUnlockAttemptRepository.cs @@ -0,0 +1,46 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; + +/// +/// Register row 52: read/write access to the central BundleUnlockAttempt table, +/// the fleet-wide sliding window behind the Transport component's per-client bundle-unlock +/// throttle. Both central nodes share this store, so the documented +/// MaxUnlockAttemptsPerIpPerHour cap is a fleet cap rather than a per-process one. +/// +/// +/// The throttle is a best-effort rate cap layered over an already-authenticated, +/// role-gated surface. Callers are expected to treat a store failure as fail-open (the +/// availability of legitimate imports outranks the cap), so no method here is required to +/// be transactional with respect to the caller's own work. +/// +public interface IBundleUnlockAttemptRepository +{ + /// + /// Counts the attempts recorded for at or after + /// — the trailing-window depth the cap is compared against. + /// + /// Throttle bucket (client IP, or the shared unattributed bucket). + /// Inclusive UTC lower bound of the trailing window. + /// Cancellation token. + /// The number of attempts inside the window. + Task CountAttemptsSinceAsync(string clientKey, DateTime sinceUtc, CancellationToken ct = default); + + /// + /// Appends one attempt row for . + /// + /// Throttle bucket (client IP, or the shared unattributed bucket). + /// UTC instant of the attempt. + /// Cancellation token. + /// A task that completes when the row is committed. + Task RecordAttemptAsync(string clientKey, DateTime attemptedAtUtc, CancellationToken ct = default); + + /// + /// Deletes attempt rows older than , up to + /// in one bounded pass. Bounded so an opportunistic prune on + /// a request path can never turn into an unbounded delete. + /// + /// Exclusive UTC upper bound — rows strictly older than this are eligible. + /// Maximum rows to delete in this pass; must be positive. + /// Cancellation token. + /// The number of rows deleted. + Task PruneAttemptsOlderThanAsync(DateTime beforeUtc, int maxRows, CancellationToken ct = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/BundleUnlockAttemptEntityTypeConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/BundleUnlockAttemptEntityTypeConfiguration.cs new file mode 100644 index 00000000..c3d9ac89 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/BundleUnlockAttemptEntityTypeConfiguration.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport; + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations; + +/// +/// Maps the POCO to the central +/// BundleUnlockAttempt table — the fleet-wide sliding window behind the Transport +/// component's per-client bundle-unlock throttle (register row 52). Operational history, +/// NOT audit, so the table is non-partitioned, standard [PRIMARY] filegroup, no +/// DB-role restriction. +/// +/// +/// One named index, IX_BundleUnlockAttempt_Client_Attempted, serves both access +/// paths: the window count seeks on ClientKey and ranges on +/// AttemptedAtUtc, and the opportunistic prune scans the same index by time. A +/// second time-only index was deliberately not added — the table is kept small by the +/// prune, so the leading-column seek is cheap enough for the prune to share. +/// +public class BundleUnlockAttemptEntityTypeConfiguration : IEntityTypeConfiguration +{ + /// + /// Configures the EF Core entity type mapping for . + /// + /// The entity type builder to configure. + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("BundleUnlockAttempt"); + + // Surrogate long identity assigned by the store. + builder.HasKey(a => a.Id); + + // Bucket key: an IP literal or the "" sentinel. 64 chars covers an + // IPv6 address with a scope id; ASCII, so non-unicode (varchar) like every other + // catalog-bounded column in the schema. + builder.Property(a => a.ClientKey).HasMaxLength(64).IsUnicode(false).IsRequired(); + + builder.Property(a => a.AttemptedAtUtc).IsRequired(); + + // Window index — seeks the bucket, ranges over the trailing hour, and is + // reused by the prune's time scan. Name locked for migration discoverability. + builder.HasIndex(a => new { a.ClientKey, a.AttemptedAtUtc }) + .HasDatabaseName("IX_BundleUnlockAttempt_Client_Attempted"); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815154803_AddBundleUnlockAttemptTable.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815154803_AddBundleUnlockAttemptTable.Designer.cs new file mode 100644 index 00000000..888e421b --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815154803_AddBundleUnlockAttemptTable.Designer.cs @@ -0,0 +1,2121 @@ +// +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("20260815154803_AddBundleUnlockAttemptTable")] + partial class AddBundleUnlockAttemptTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .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("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + 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("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.PendingDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("InstanceId"); + + b.ToTable("PendingDeployments", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "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("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("DataSourceReferenceOverride") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilterOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionNameOverride") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("SourceCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("SourceReferenceOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "SourceCanonicalName") + .IsUnique(); + + b.ToTable("InstanceNativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiRollupHourly", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("HourStartUtc") + .HasColumnType("datetime2"); + + b.Property("MaxValue") + .HasColumnType("float"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("MinValue") + .HasColumnType("float"); + + b.Property("SampleCount") + .HasColumnType("int"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ScopeKey") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Value") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("HourStartUtc") + .HasDatabaseName("IX_KpiRollupHourly_Hour"); + + b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "HourStartUtc") + .IsUnique() + .HasDatabaseName("IX_KpiRollupHourly_Series"); + + b.ToTable("KpiRollupHourly", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CapturedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ScopeKey") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Value") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAtUtc") + .HasDatabaseName("IX_KpiSample_Captured"); + + b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "CapturedAtUtc") + .HasDatabaseName("IX_KpiSample_Series"); + + b.ToTable("KpiSample", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("DeliveredAt") + .HasDatabaseName("IX_Notifications_Delivered") + .HasFilter("[Status] = 'Delivered'"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.Property("PhoneNumber") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmsConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountSid") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ApiBaseUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("AuthToken") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("FromNumber") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("MessagingServiceSid") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.ToTable("SmsConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("OAuth2Authority") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("OAuth2Scope") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Transport") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas.SharedSchema", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SchemaJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Scope") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedSchemas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites.PendingSecuredWrite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("DecidedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExecutedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExecutionError") + .HasMaxLength(2048) + .IsUnicode(false) + .HasColumnType("varchar(2048)"); + + b.Property("OperatorComment") + .HasMaxLength(1024) + .IsUnicode(false) + .HasColumnType("varchar(1024)"); + + b.Property("OperatorUser") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("SiteId") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("SubmittedAtUtc") + .HasColumnType("datetime2"); + + b.Property("TagPath") + .IsRequired() + .HasMaxLength(512) + .IsUnicode(false) + .HasColumnType("varchar(512)"); + + b.Property("ValueJson") + .IsRequired() + .HasMaxLength(4000) + .IsUnicode(false) + .HasColumnType("varchar(4000)"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("VerifierComment") + .HasMaxLength(1024) + .IsUnicode(false) + .HasColumnType("varchar(1024)"); + + b.Property("VerifierUser") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("SiteId") + .HasDatabaseName("IX_PendingSecuredWrites_Site"); + + b.HasIndex("Status", "SubmittedAtUtc") + .HasDatabaseName("IX_PendingSecuredWrites_Status_Submitted"); + + b.ToTable("PendingSecuredWrites", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Administrator" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Designer" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployer" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployer" + }, + new + { + Id = 5, + LdapGroupName = "SCADA-Viewers", + Role = "Viewer" + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilter") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateNativeAlarmSources"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport.BundleUnlockAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttemptedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ClientKey") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("ClientKey", "AttemptedAtUtc") + .HasDatabaseName("IX_BundleUnlockAttempt_Client_Attempted"); + + b.ToTable("BundleUnlockAttempt", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Actor") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("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("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSiteId") + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", true); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", true); + + b.Property("Target") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("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("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/20260815154803_AddBundleUnlockAttemptTable.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815154803_AddBundleUnlockAttemptTable.cs new file mode 100644 index 00000000..cd329a4b --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815154803_AddBundleUnlockAttemptTable.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + /// + public partial class AddBundleUnlockAttemptTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BundleUnlockAttempt", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ClientKey = table.Column(type: "varchar(64)", unicode: false, maxLength: 64, nullable: false), + AttemptedAtUtc = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BundleUnlockAttempt", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_BundleUnlockAttempt_Client_Attempted", + table: "BundleUnlockAttempt", + columns: new[] { "ClientKey", "AttemptedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BundleUnlockAttempt"); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs index 4487dbc2..a5d3b17f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs @@ -1716,6 +1716,31 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations 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.AuditLogRow", b => { b.Property("EventId") diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/BundleUnlockAttemptRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/BundleUnlockAttemptRepository.cs new file mode 100644 index 00000000..8c479393 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/BundleUnlockAttemptRepository.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; + +/// +/// EF Core implementation of over the central +/// BundleUnlockAttempt table (register row 52). Plain reads/writes against the +/// shared ; the prune uses ExecuteDeleteAsync so +/// no rows are materialised into the change tracker. +/// +public class BundleUnlockAttemptRepository : IBundleUnlockAttemptRepository +{ + private readonly ScadaBridgeDbContext _context; + + /// + /// Initializes a new instance of the class. + /// + /// The EF Core database context. + public BundleUnlockAttemptRepository(ScadaBridgeDbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task CountAttemptsSinceAsync( + string clientKey, DateTime sinceUtc, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(clientKey); + + return await _context.BundleUnlockAttempts + .AsNoTracking() + .Where(a => a.ClientKey == clientKey && a.AttemptedAtUtc >= sinceUtc) + .CountAsync(ct); + } + + /// + public async Task RecordAttemptAsync( + string clientKey, DateTime attemptedAtUtc, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(clientKey); + + _context.BundleUnlockAttempts.Add(new BundleUnlockAttempt + { + ClientKey = clientKey, + AttemptedAtUtc = attemptedAtUtc, + }); + await _context.SaveChangesAsync(ct); + } + + /// + public async Task PruneAttemptsOlderThanAsync( + DateTime beforeUtc, int maxRows, CancellationToken ct = default) + { + if (maxRows < 1) + { + throw new ArgumentOutOfRangeException(nameof(maxRows), "Prune batch size must be at least 1."); + } + + // Bounded by construction: OrderBy + Take caps the delete regardless of how much + // history accumulated while the prune was failing or the node was down, so an + // opportunistic sweep on a request path can never become an unbounded delete. + return await _context.BundleUnlockAttempts + .Where(a => a.AttemptedAtUtc < beforeUtc) + .OrderBy(a => a.AttemptedAtUtc) + .Take(maxRows) + .ExecuteDeleteAsync(ct); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs index ceb436ef..435049d5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs @@ -13,6 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; @@ -152,6 +153,10 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext /// Gets the set of hourly KPI rollups (pre-aggregated long-range trend backbone). public DbSet KpiRollupHourly => Set(); + // Transport (bundle export/import) + /// Gets the set of recorded bundle-unlock attempts — the fleet-wide sliding window behind the per-client unlock throttle (register row 52). + public DbSet BundleUnlockAttempts => Set(); + // Data Protection Keys (for shared ASP.NET Data Protection across nodes) /// Gets the set of data protection keys. public DbSet DataProtectionKeys => Set(); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs index 1d2636f0..7bbbdd58 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs @@ -87,6 +87,10 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + // Register row 52: the fleet-wide bundle-unlock throttle window. Central-only in + // practice (Transport is registered in the host's Central branch), but registered + // here with every other repository so the ConfigurationDatabase surface stays uniform. + services.AddScoped(); // Auth re-arch: inbound API keys are no longer persisted in SQL Server — // the repository now exposes only API-method access, so a plain scoped // registration suffices (no peppered-hasher accessor to wire). diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index c4263069..c11b727d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -293,11 +293,18 @@ public sealed class BundleImporter : IBundleImporter // operator is legitimately importing. // Registered on every unlock attempt (successful ones included) because the // documented budget counts attempts, not failures. + // + // Register row 52: the async overload consults the FLEET-WIDE window (central + // MS SQL) so the cap is not multiplied by the number of central nodes and does + // not reset on a node restart. It fails open on a store error — see + // BundleUnlockRateLimiter's remarks. var throttleKey = string.IsNullOrWhiteSpace(clientKey) ? UnattributedClientKey : clientKey; var perIpCap = _options.Value.MaxUnlockAttemptsPerIpPerHour; - if (!_unlockRateLimiter.TryRegisterAttempt(throttleKey, perIpCap)) + if (!await _unlockRateLimiter + .TryRegisterAttemptAsync(throttleKey, perIpCap, ct) + .ConfigureAwait(false)) { _logger?.LogWarning( "Bundle unlock throttled: client {ClientKey} exceeded {Cap} attempts in the trailing hour.", diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleUnlockRateLimiter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleUnlockRateLimiter.cs index 00190eb0..120cd867 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleUnlockRateLimiter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleUnlockRateLimiter.cs @@ -1,26 +1,37 @@ using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace ZB.MOM.WW.ScadaBridge.Transport.Import; /// -/// In-memory sliding-window rate limiter for bundle-unlock passphrase -/// attempts, keyed by client IP. The design doc (§11) declares a per-IP-per-hour cap -/// (default 10) as a brute-force defence against a stolen bundle; this class is the -/// minimal server-side implementation. +/// Sliding-window rate limiter for bundle-unlock passphrase attempts, keyed by client IP. +/// The design doc (§11) declares a per-IP-per-hour cap (default 10) as a brute-force +/// defence against a stolen bundle; this class owns that policy. /// -/// Algorithm: each key (an IP string, or any opaque caller identifier) holds a queue -/// of attempt timestamps. first prunes entries older -/// than the configured window, then either appends the current timestamp and returns -/// true if the count is still under the threshold, or refuses to append and -/// returns false if appending would cross it. The trailing-hour count is the -/// queue length post-prune. +/// The window is fleet-wide when an is +/// supplied (register row 52). The counters used to be per-node AND per-process, so +/// the effective cap was N x limit per client across the central pair and a node restart +/// silently cleared the window. now consults the +/// shared central-MS-SQL window instead, which both central nodes already share. The +/// production composition root always supplies one. /// /// -/// Storage is a process-local . The -/// counters do not survive a host restart — that is by design: a restart resets the -/// brute-force window in favour of legitimate operators after an outage. Persisting -/// the counters would require a multi-node consensus story the simple in-memory -/// design avoids. +/// Fail-open is deliberate. A store failure in the throttle path logs a Warning and +/// PERMITS the attempt. This is a best-effort rate cap layered over a surface that is +/// already authenticated and role-gated (Administrator-only bundle import), so the +/// availability of legitimate imports outranks the cap: a database blip must not make the +/// import wizard unusable. Falling back to the process-local window instead was considered +/// and rejected — it would still block a legitimate operator's 11th import during an +/// outage, which is the outcome fail-open exists to prevent. +/// +/// +/// Algorithm (the process-local fallback used when no shared window is registered): each +/// key holds a queue of attempt timestamps. first prunes +/// entries older than the configured window, then either appends the current timestamp and +/// returns true if the count is still under the threshold, or refuses to append and +/// returns false if appending would cross it. Either way a REJECTED attempt is never +/// appended, so a denial burns no budget — the same contract the shared window implements. /// /// /// Thread-safety: the per-key queue is protected by a per-key lock taken inside the @@ -39,10 +50,13 @@ public sealed class BundleUnlockRateLimiter private readonly ConcurrentDictionary _buckets = new(StringComparer.OrdinalIgnoreCase); private readonly TimeProvider _timeProvider; private readonly TimeSpan _window; + private readonly IBundleUnlockAttemptWindow? _sharedWindow; + private readonly ILogger _logger; /// /// Initializes a new using the documented - /// 1-hour trailing window and the system clock. Suitable for production DI. + /// 1-hour trailing window and the system clock, with no shared window (process-local + /// counters only). /// public BundleUnlockRateLimiter() : this(TimeProvider.System, DefaultWindow) { @@ -50,11 +64,28 @@ public sealed class BundleUnlockRateLimiter /// /// Initializes a new with an injected clock - /// (for deterministic tests) and a custom trailing window. + /// (for deterministic tests) and a custom trailing window, with no shared window. /// /// Clock used for both timestamping new attempts and pruning expired ones. /// Trailing window over which attempts are counted (typically 1 hour). public BundleUnlockRateLimiter(TimeProvider timeProvider, TimeSpan window) + : this(timeProvider, window, sharedWindow: null, logger: null) + { + } + + /// + /// Initializes a new over a fleet-wide shared + /// window (register row 52). This is the overload the production composition root uses. + /// + /// Clock used for the process-local fallback window. + /// Trailing window over which attempts are counted (typically 1 hour). + /// Shared cross-node attempt window; when null the limiter falls back to process-local counters. + /// Optional logger. Used for the fail-open Warning when the shared window is unavailable. + public BundleUnlockRateLimiter( + TimeProvider timeProvider, + TimeSpan window, + IBundleUnlockAttemptWindow? sharedWindow, + ILogger? logger) { ArgumentNullException.ThrowIfNull(timeProvider); if (window <= TimeSpan.Zero) @@ -64,13 +95,81 @@ public sealed class BundleUnlockRateLimiter _timeProvider = timeProvider; _window = window; + _sharedWindow = sharedWindow; + _logger = logger ?? NullLogger.Instance; } /// - /// Attempts to register a new passphrase try against the configured per-key - /// limit. Returns true when the attempt is permitted (and recorded); - /// returns false when the key has exhausted its budget for the trailing - /// window — the caller should reject the unlock request with a 429-equivalent. + /// Gets a value indicating whether this limiter is backed by a fleet-wide shared + /// window. False means the cap degrades to per-node, per-process counters. + /// + public bool IsFleetWide => _sharedWindow is not null; + + /// + /// Registers a passphrase try against the configured per-key limit, using the + /// fleet-wide shared window when one is registered and the process-local window + /// otherwise. This is the method the importer calls. + /// + /// Opaque caller identifier — typically the remote IP. + /// The trailing-window cap (MaxUnlockAttemptsPerIpPerHour). + /// Cancellation token. + /// + /// true if the attempt is permitted; false if the caller has exhausted + /// its budget. Also true when the shared window is unavailable — see the + /// class remarks on why the throttle fails open. + /// + public async Task TryRegisterAttemptAsync( + string clientKey, int maxAttemptsPerWindow, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(clientKey); + if (maxAttemptsPerWindow < 1) + { + throw new ArgumentOutOfRangeException( + nameof(maxAttemptsPerWindow), "Limit must be at least 1."); + } + + if (_sharedWindow is null) + { + return TryRegisterAttempt(clientKey, maxAttemptsPerWindow); + } + + try + { + return await _sharedWindow + .TryRegisterAttemptAsync(clientKey, maxAttemptsPerWindow, _window, ct) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // The caller's own cancellation, not a store failure — do not swallow it. + throw; + } + catch (Exception ex) + { + // Fail OPEN: a best-effort rate cap must never take the import surface down + // with the database. The Warning is the operator's signal that the fleet cap + // is currently not being enforced. + _logger.LogWarning( + ex, + "Fleet-wide bundle-unlock throttle unavailable; permitting the unlock attempt for client " + + "{ClientKey} without enforcing the {Cap}/hour cap.", + clientKey, maxAttemptsPerWindow); + return true; + } + } + + /// + /// Attempts to register a new passphrase try against the configured per-key limit in + /// the PROCESS-LOCAL window. Returns true when the attempt is permitted (and + /// recorded); returns false when the key has exhausted its budget for the + /// trailing window — the caller should reject the unlock request with a 429-equivalent. + /// + /// Register row 52: this bypasses the fleet-wide window. Callers on the unlock path + /// must use , which routes to the shared window + /// when one is registered and falls back here otherwise. This overload remains public + /// for hosts that register Transport without a shared window, and for the tests that + /// pin the sliding-window arithmetic directly. + /// /// /// /// Opaque caller identifier — typically the remote IP, but any stable per-source diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/IBundleUnlockAttemptWindow.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/IBundleUnlockAttemptWindow.cs new file mode 100644 index 00000000..8e0340ba --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/IBundleUnlockAttemptWindow.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.ScadaBridge.Transport.Import; + +/// +/// Register row 52: a sliding window of bundle-unlock attempts that is shared across the +/// central pair, so TransportOptions.MaxUnlockAttemptsPerIpPerHour is a FLEET cap +/// rather than a per-node, per-process one. +/// +/// Implementations are expected to THROW on store failure. The fail-open decision belongs +/// to , which owns the policy (log a Warning and +/// permit the attempt), not to the storage layer. +/// +/// +public interface IBundleUnlockAttemptWindow +{ + /// + /// Registers one unlock attempt against the shared window and reports whether it was + /// within budget. Implementations must NOT record an attempt they are rejecting — + /// a denial burns no budget, matching the in-memory limiter's contract (register + /// row 44), so a client that keeps hammering cannot extend its own lockout. + /// + /// Throttle bucket — the caller's remote IP, or the shared unattributed bucket. + /// The trailing-window cap (MaxUnlockAttemptsPerIpPerHour). + /// Trailing window the attempts are counted over (one hour in production). + /// Cancellation token. + /// true when the attempt was recorded (within budget); false when the bucket is exhausted. + Task TryRegisterAttemptAsync( + string clientKey, int maxAttemptsPerWindow, TimeSpan window, CancellationToken ct = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/SqlBundleUnlockAttemptWindow.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/SqlBundleUnlockAttemptWindow.cs new file mode 100644 index 00000000..fb59e22d --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/SqlBundleUnlockAttemptWindow.cs @@ -0,0 +1,167 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; + +namespace ZB.MOM.WW.ScadaBridge.Transport.Import; + +/// +/// Register row 52: backed by the central MS SQL +/// BundleUnlockAttempt table — the HA store both central nodes already share, so +/// the fleet-wide cap needs no new infrastructure, no new port and no consensus protocol. +/// +/// Registered as a singleton (the limiter that consumes it is one) and therefore resolves +/// a scoped from its own +/// scope per call, the same way the central purge +/// singletons reach the database. +/// +/// +/// Accuracy is deliberately approximate. The count-then-insert pair is not atomic +/// across the two central nodes, so genuinely simultaneous attempts can overshoot the cap +/// by up to the number in flight. A conditional single-statement insert would close that, +/// at the cost of provider-specific SQL for a best-effort rate cap layered over an +/// already-authenticated, role-gated surface. Overshoot is bounded and self-corrects +/// within the window; it is not worth raw SQL. +/// +/// +public sealed class SqlBundleUnlockAttemptWindow : IBundleUnlockAttemptWindow +{ + /// + /// Minimum wall-clock gap between opportunistic prunes. The prune runs on the unlock + /// path (Transport owns no central scheduler, and the design constraint was "no new + /// background service"), so it is claimed at most this often per process — the same + /// rate-limited opportunistic-sweep shape DeploymentService uses for terminal + /// deployment records. + /// + internal static readonly TimeSpan PruneMinInterval = TimeSpan.FromMinutes(15); + + /// + /// Upper bound on rows deleted by one opportunistic prune pass. Bounded so a sweep on + /// a request path can never become a long-running delete, even if the table grew while + /// prunes were failing. + /// + internal const int PruneBatchSize = 1000; + + /// + /// Multiple of the trailing window that rows are retained for before becoming + /// prunable. Kept above 1 so a prune racing a count can never delete a row the count + /// still needs. + /// + internal const int PruneRetentionWindows = 2; + + private readonly IServiceScopeFactory _scopeFactory; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + /// Ticks of the last claimed prune, as a CAS-guarded process-local claim. + private long _lastPruneTicks; + + /// + /// Initializes a new . + /// + /// Scope factory used to resolve the scoped attempt repository per call. + /// Clock used for the window boundary and the prune cadence. + /// Optional logger for prune diagnostics. + public SqlBundleUnlockAttemptWindow( + IServiceScopeFactory scopeFactory, + TimeProvider timeProvider, + ILogger? logger = null) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? NullLogger.Instance; + + // Seed the prune claim to "now" so a freshly started node does not prune on its + // very first unlock — the table is almost certainly already tidy, and a node that + // restarts under load would otherwise prune on every restart. + _lastPruneTicks = _timeProvider.GetUtcNow().UtcTicks; + } + + /// + public async Task TryRegisterAttemptAsync( + string clientKey, int maxAttemptsPerWindow, TimeSpan window, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(clientKey); + if (maxAttemptsPerWindow < 1) + { + throw new ArgumentOutOfRangeException( + nameof(maxAttemptsPerWindow), "Limit must be at least 1."); + } + + if (window <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(window), "Window must be positive."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var key = clientKey.Trim(); + + using var scope = _scopeFactory.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + // Two round trips rather than one conditional insert — see the class remarks on + // why the resulting overshoot is accepted. Exceptions propagate: the fail-open + // decision lives in BundleUnlockRateLimiter. + var used = await repo.CountAttemptsSinceAsync(key, now - window, ct).ConfigureAwait(false); + if (used >= maxAttemptsPerWindow) + { + // Rejected attempts are NOT recorded — a denial burns no budget, so a client + // hammering the endpoint cannot roll its own window forward indefinitely. + return false; + } + + await repo.RecordAttemptAsync(key, now, ct).ConfigureAwait(false); + + await TryPruneAsync(repo, now, window, ct).ConfigureAwait(false); + return true; + } + + /// + /// Opportunistic, rate-limited, bounded prune of aged-out attempt rows. Best-effort: + /// any failure is logged and swallowed, because retention maintenance must never fail + /// the operator's actual unlock. + /// + /// Repository resolved for the current call's scope. + /// Current UTC instant. + /// The trailing window; rows older than a multiple of it are prunable. + /// Cancellation token. + /// A task that completes when the prune attempt has finished (or been skipped). + private async Task TryPruneAsync( + IBundleUnlockAttemptRepository repo, DateTime nowUtc, TimeSpan window, CancellationToken ct) + { + var previous = Interlocked.Read(ref _lastPruneTicks); + if (nowUtc.Ticks - previous < PruneMinInterval.Ticks) + { + return; + } + + // Claim the sweep so concurrent unlocks on this node don't all prune at once. + if (Interlocked.CompareExchange(ref _lastPruneTicks, nowUtc.Ticks, previous) != previous) + { + return; + } + + try + { + var cutoff = nowUtc - (window * PruneRetentionWindows); + var deleted = await repo + .PruneAttemptsOlderThanAsync(cutoff, PruneBatchSize, ct) + .ConfigureAwait(false); + + if (deleted > 0) + { + _logger.LogDebug( + "Pruned {Deleted} bundle-unlock attempt row(s) older than {Cutoff:o}.", deleted, cutoff); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // Never fail the unlock for a retention sweep. + _logger.LogWarning(ex, "Opportunistic bundle-unlock attempt prune failed; will retry on a later attempt."); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs index 2e016809..26abf563 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; @@ -43,10 +44,22 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddSingleton(); - // T-004: per-IP-per-hour unlock rate limiter — design doc §11. Singleton - // so the trailing-hour window is shared across every importer scope; the - // counters live in-memory and reset on host restart (by design). - services.AddSingleton(); + // T-004: per-IP-per-hour unlock rate limiter — design doc §11. Singleton so the + // trailing-hour window is shared across every importer scope. + // + // Register row 52: the window is FLEET-WIDE, backed by the central MS SQL + // BundleUnlockAttempt table that both central nodes already share — the counters + // used to be per-node and per-process, so the effective cap was N x limit per + // client and a restart cleared the window. The window resolves its scoped + // repository from its own scope per call, so it is safe as a singleton. + // Constructed explicitly rather than by convention so it is unambiguous which + // constructor overload production uses. + services.AddSingleton(); + services.AddSingleton(sp => new BundleUnlockRateLimiter( + sp.GetRequiredService(), + BundleUnlockRateLimiter.DefaultWindow, + sp.GetRequiredService(), + sp.GetService>())); // T-007: periodic eviction sweep so abandoned sessions clear without // needing a fresh Get() to trigger lazy eviction. services.AddHostedService(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/BundleUnlockAttemptRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/BundleUnlockAttemptRepositoryTests.cs new file mode 100644 index 00000000..20882494 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/BundleUnlockAttemptRepositoryTests.cs @@ -0,0 +1,90 @@ +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories; + +/// +/// Register row 52 coverage for over the +/// in-memory SQLite harness — the fleet-wide bundle-unlock throttle window's persistence +/// layer. Exercises the windowed count (including the bucket-isolation and window-boundary +/// cases the throttle depends on) and the bounded prune. +/// +public class BundleUnlockAttemptRepositoryTests +{ + // Fixed UTC base so the window assertions are deterministic. + private static readonly DateTime Base = new(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc); + + private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext(); + + [Fact] + public async Task CountAttemptsSinceAsync_CountsOnlyTheGivenBucketInsideTheWindow() + { + await using var ctx = NewContext(); + var repo = new BundleUnlockAttemptRepository(ctx); + + // Two inside the trailing hour, one aged out, one from a different client. + await repo.RecordAttemptAsync("10.0.0.1", Base.AddMinutes(-10)); + await repo.RecordAttemptAsync("10.0.0.1", Base.AddMinutes(-50)); + await repo.RecordAttemptAsync("10.0.0.1", Base.AddMinutes(-90)); + await repo.RecordAttemptAsync("10.0.0.2", Base.AddMinutes(-5)); + + var count = await repo.CountAttemptsSinceAsync("10.0.0.1", Base.AddHours(-1)); + + Assert.Equal(2, count); + } + + [Fact] + public async Task CountAttemptsSinceAsync_IsInclusiveAtTheWindowFloor() + { + // The floor is inclusive, matching the in-memory limiter's prune predicate + // (entries at exactly the cutoff still count). Pinned because an off-by-one here + // would silently hand every client one extra attempt per window. + await using var ctx = NewContext(); + var repo = new BundleUnlockAttemptRepository(ctx); + + await repo.RecordAttemptAsync("10.0.0.1", Base.AddHours(-1)); + + Assert.Equal(1, await repo.CountAttemptsSinceAsync("10.0.0.1", Base.AddHours(-1))); + } + + [Fact] + public async Task CountAttemptsSinceAsync_UnknownBucket_ReturnsZero() + { + await using var ctx = NewContext(); + var repo = new BundleUnlockAttemptRepository(ctx); + + Assert.Equal(0, await repo.CountAttemptsSinceAsync("", Base.AddHours(-1))); + } + + [Fact] + public async Task PruneAttemptsOlderThanAsync_DeletesOnlyAgedRows_AndIsBounded() + { + await using var ctx = NewContext(); + var repo = new BundleUnlockAttemptRepository(ctx); + + for (var i = 0; i < 5; i++) + { + await repo.RecordAttemptAsync("10.0.0.1", Base.AddHours(-5).AddMinutes(i)); + } + + await repo.RecordAttemptAsync("10.0.0.1", Base.AddMinutes(-1)); + + // Bounded: only 3 of the 5 eligible rows go in this pass. + var deleted = await repo.PruneAttemptsOlderThanAsync(Base.AddHours(-2), maxRows: 3); + Assert.Equal(3, deleted); + + // The recent row is untouched, and a second pass clears the remainder. + Assert.Equal(1, await repo.CountAttemptsSinceAsync("10.0.0.1", Base.AddHours(-1))); + Assert.Equal(2, await repo.PruneAttemptsOlderThanAsync(Base.AddHours(-2), maxRows: 3)); + Assert.Equal(0, await repo.PruneAttemptsOlderThanAsync(Base.AddHours(-2), maxRows: 3)); + } + + [Fact] + public async Task PruneAttemptsOlderThanAsync_RejectsANonPositiveBatchSize() + { + await using var ctx = NewContext(); + var repo = new BundleUnlockAttemptRepository(ctx); + + await Assert.ThrowsAsync( + () => repo.PruneAttemptsOlderThanAsync(Base, maxRows: 0)); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/FleetWideUnlockThrottleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/FleetWideUnlockThrottleTests.cs new file mode 100644 index 00000000..ff2bcf60 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/FleetWideUnlockThrottleTests.cs @@ -0,0 +1,263 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; +using ZB.MOM.WW.ScadaBridge.Transport.Import; + +namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; + +/// +/// Register row 52: the bundle-unlock throttle's counters used to be per-node AND +/// per-process, so the effective fleet cap was N x MaxUnlockAttemptsPerIpPerHour +/// per client and a node restart silently cleared the window. These tests stand up TWO +/// independent limiter instances — the stand-in for central-a and central-b — over ONE +/// shared relational store and prove they enforce a single combined window, plus the +/// fail-open behaviour a store outage must produce. +/// +public sealed class FleetWideUnlockThrottleTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly ServiceProvider _provider; + + public FleetWideUnlockThrottleTests() + { + // One open in-memory SQLite connection for the fixture's lifetime — the database + // lives exactly as long as the connection, and both "nodes" resolve scoped + // contexts over it, which is what makes the store genuinely shared. + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var services = new ServiceCollection(); + services.AddDbContext(opts => opts.UseSqlite(_connection)); + services.AddScoped(); + _provider = services.BuildServiceProvider(); + + using var scope = _provider.CreateScope(); + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + } + + public void Dispose() + { + _provider.Dispose(); + _connection.Dispose(); + } + + /// + /// Builds a limiter over the shared store, standing in for one central node's + /// singleton. Each call returns a fully independent instance (its own process-local + /// fallback buckets, its own prune claim) — exactly the isolation the two real nodes + /// have. + /// + /// Clock the node observes. + /// A limiter whose window is the shared store. + private BundleUnlockRateLimiter NewNode(TimeProvider clock) => new( + clock, + BundleUnlockRateLimiter.DefaultWindow, + new SqlBundleUnlockAttemptWindow( + _provider.GetRequiredService(), clock), + logger: null); + + [Fact] + public async Task TwoNodesSharingOneStore_EnforceASingleCombinedWindow() + { + // The headline row-52 contract: attempts spread across both central nodes count + // against ONE budget. Before this, five attempts per node were both allowed. + var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero)); + var nodeA = NewNode(clock); + var nodeB = NewNode(clock); + + for (var i = 0; i < 5; i++) + { + Assert.True(await nodeA.TryRegisterAttemptAsync("10.0.0.1", 10)); + Assert.True(await nodeB.TryRegisterAttemptAsync("10.0.0.1", 10)); + } + + // The combined budget of 10 is now spent — on EITHER node. + Assert.False(await nodeA.TryRegisterAttemptAsync("10.0.0.1", 10)); + Assert.False(await nodeB.TryRegisterAttemptAsync("10.0.0.1", 10)); + } + + [Fact] + public async Task DeniedAttempts_BurnNoBudget() + { + // Register row 44's contract, preserved through the move to the shared store: a + // rejected attempt is not recorded, so hammering cannot roll the window forward + // and extend the client's own lockout. + var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero)); + var nodeA = NewNode(clock); + + for (var i = 0; i < 3; i++) + { + Assert.True(await nodeA.TryRegisterAttemptAsync("10.0.0.1", 3)); + } + + for (var i = 0; i < 5; i++) + { + Assert.False(await nodeA.TryRegisterAttemptAsync("10.0.0.1", 3)); + } + + // Exactly the 3 permitted attempts were persisted — the 5 denials added nothing. + using var scope = _provider.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + var recorded = await repo.CountAttemptsSinceAsync( + "10.0.0.1", clock.GetUtcNow().UtcDateTime.AddHours(-1)); + Assert.Equal(3, recorded); + } + + [Fact] + public async Task TheWindowIsRestartDurable() + { + // A node restart used to clear the window outright. A brand-new limiter instance + // (the restarted process) must still see the budget the old one spent. + var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero)); + var before = NewNode(clock); + + for (var i = 0; i < 3; i++) + { + Assert.True(await before.TryRegisterAttemptAsync("10.0.0.1", 3)); + } + + var afterRestart = NewNode(clock); + Assert.False(await afterRestart.TryRegisterAttemptAsync("10.0.0.1", 3)); + } + + [Fact] + public async Task TheWindowSlides_SoAnAgedOutBudgetIsReleased() + { + var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero)); + var node = NewNode(clock); + + for (var i = 0; i < 3; i++) + { + Assert.True(await node.TryRegisterAttemptAsync("10.0.0.1", 3)); + } + + Assert.False(await node.TryRegisterAttemptAsync("10.0.0.1", 3)); + + clock.Advance(TimeSpan.FromMinutes(61)); + Assert.True(await node.TryRegisterAttemptAsync("10.0.0.1", 3)); + } + + [Fact] + public async Task BucketsAreIsolatedPerClient() + { + var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero)); + var node = NewNode(clock); + + Assert.True(await node.TryRegisterAttemptAsync("10.0.0.1", 1)); + Assert.False(await node.TryRegisterAttemptAsync("10.0.0.1", 1)); + + // A different client — and the shared unattributed bucket — are unaffected. + Assert.True(await node.TryRegisterAttemptAsync("10.0.0.2", 1)); + Assert.True(await node.TryRegisterAttemptAsync("", 1)); + } + + [Fact] + public async Task StoreFailure_FailsOpen_AndDoesNotBlockTheUnlock() + { + // Availability of legitimate imports outranks a best-effort rate cap: a database + // error must permit the attempt, not take the import surface down with the DB. + var window = Substitute.For(); + window + .TryRegisterAttemptAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("database unavailable")); + + var limiter = new BundleUnlockRateLimiter( + TimeProvider.System, BundleUnlockRateLimiter.DefaultWindow, window, logger: null); + + for (var i = 0; i < 20; i++) + { + Assert.True(await limiter.TryRegisterAttemptAsync("10.0.0.1", 1)); + } + } + + [Fact] + public async Task CallerCancellation_IsNotSwallowedByTheFailOpenPath() + { + // Fail-open must not hide the caller's own cancellation — that would turn an + // aborted request into a silently permitted attempt. + var window = Substitute.For(); + window + .TryRegisterAttemptAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new OperationCanceledException()); + + var limiter = new BundleUnlockRateLimiter( + TimeProvider.System, BundleUnlockRateLimiter.DefaultWindow, window, logger: null); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + () => limiter.TryRegisterAttemptAsync("10.0.0.1", 10, cts.Token)); + } + + [Fact] + public async Task WithoutASharedWindow_TheLimiterStillEnforcesAProcessLocalCap() + { + // Hosts that register Transport without a shared window keep the pre-row-52 + // behaviour rather than losing the cap entirely. + var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero)); + var limiter = new BundleUnlockRateLimiter(clock, BundleUnlockRateLimiter.DefaultWindow); + + Assert.False(limiter.IsFleetWide); + Assert.True(await limiter.TryRegisterAttemptAsync("10.0.0.1", 1)); + Assert.False(await limiter.TryRegisterAttemptAsync("10.0.0.1", 1)); + } + + [Fact] + public async Task AgedRowsArePrunedOpportunistically_OnceThePruneIntervalHasElapsed() + { + // No background service: the prune rides the unlock path, claimed at most once per + // PruneMinInterval. Rows are retained for PruneRetentionWindows x window so a prune + // racing a count can never delete a row the count still needs. + var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero)); + var node = NewNode(clock); + + Assert.True(await node.TryRegisterAttemptAsync("10.0.0.1", 10)); + + // Move past both the retention horizon (2 hours) and the prune cadence. + clock.Advance(TimeSpan.FromHours(3)); + Assert.True(await node.TryRegisterAttemptAsync("10.0.0.2", 10)); + + using var scope = _provider.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var remaining = await ctx.BundleUnlockAttempts.AsNoTracking().ToListAsync(); + + // The aged row is gone; the one just written by the pruning attempt survives. + Assert.Equal("10.0.0.2", Assert.Single(remaining).ClientKey); + } + + [Fact] + public async Task RecentRowsSurviveAPrunePass() + { + // Negative control for the prune horizon: an attempt still inside the retention + // window must not be deleted, or the throttle would forget live budget. + var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero)); + var node = NewNode(clock); + + Assert.True(await node.TryRegisterAttemptAsync("10.0.0.1", 10)); + + // Past the prune cadence (15 min) but inside the 2-hour retention horizon. + clock.Advance(TimeSpan.FromMinutes(30)); + Assert.True(await node.TryRegisterAttemptAsync("10.0.0.2", 10)); + + using var scope = _provider.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(2, await ctx.BundleUnlockAttempts.AsNoTracking().CountAsync()); + } + + /// Minimal mutable-clock for deterministic windows. + private sealed class TestClock : TimeProvider + { + private DateTimeOffset _now; + public TestClock(DateTimeOffset start) { _now = start; } + public override DateTimeOffset GetUtcNow() => _now; + public void Advance(TimeSpan by) => _now = _now.Add(by); + } +} From 73f518d6a668069af128414ab62877d009b46957 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 11:59:06 -0400 Subject: [PATCH 4/4] chore(transport): delete the dead BundleUnlockRateLimitedException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Superseded by BundleUnlockThrottledException when register row 44 wired the limiter live and moved the typed exception to Commons so ManagementService's MapFault could curate it. The original type has been unreferenced since — never thrown, never caught, only its own definition — and with row 51 moving BundleLockedException into the same Commons namespace there are now three similarly-named unlock exceptions, one of which does nothing. A future maintainer catching the dead one would silently get no coverage. Solution builds clean with it gone (zero references). Transport 173 passed. --- .../BundleUnlockRateLimitedException.cs | 35 ------------------- 1 file changed, 35 deletions(-) delete mode 100644 src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleUnlockRateLimitedException.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleUnlockRateLimitedException.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleUnlockRateLimitedException.cs deleted file mode 100644 index 3e8bd70f..00000000 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleUnlockRateLimitedException.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace ZB.MOM.WW.ScadaBridge.Transport.Import; - -/// -/// Thrown by when the caller -/// has exceeded the configured per-IP-per-hour unlock attempt cap -/// (). The 429-equivalent -/// signal: the caller must wait for the trailing-hour window to roll forward before -/// another passphrase attempt is accepted. -/// -public sealed class BundleUnlockRateLimitedException : Exception -{ - /// - /// Rate-limit key the limiter rejected the attempt against — the caller IP when - /// supplied, or the bundle's content hash as the architectural fallback (the - /// importer has no IHttpContext dependency by design). - /// - public string ClientKey { get; } - - /// Per-window cap that was reached. - public int Limit { get; } - - /// - /// Initializes a new . - /// - /// The rate-limit key that exceeded its budget. - /// The configured per-window cap. - public BundleUnlockRateLimitedException(string clientKey, int limit) - : base( - $"Bundle unlock rate limit reached ({limit} attempts per hour). " - + "Wait for the trailing-hour window to expire before retrying.") - { - ClientKey = clientKey ?? throw new ArgumentNullException(nameof(clientKey)); - Limit = limit; - } -}