From 677263af576a3372f1db91fdf01c426ffea85dfa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 11:52:34 -0400 Subject: [PATCH] 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); + } +}