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 c005d609..9c39eb26 100644 --- a/docs/components/Transport.md +++ b/docs/components/Transport.md @@ -49,6 +49,11 @@ After `LoadAsync` validates and decrypts a bundle, the plaintext content is stor Without this, an abandoned wizard or a plain CLI preview held one of the eight `MaxConcurrentImportSessions` slots — and the decrypted bundle content — for the full TTL; eight of them locked out every import on the active node (deferred register row 43, observed live). +**Session-cap observability (register row 53).** That leak was only ever inferable from rotating test failures, because nothing counted open sessions and nothing logged a rejection. Two signals now close the gap: + +- **Gauge `scadabridge.central.transport.import_sessions.open`** — open (unexpired) sessions on this node, on the `ZB.MOM.WW.ScadaBridge` meter, which is already in `SiteServiceRegistration.ObservedMeters` (the `ZbTelemetryOptions.Meters` allowlist; an unlisted meter exports nothing, silently). The provider is registered by `BundleSessionEvictionService.StartAsync` and identity-checked-cleared on `StopAsync`, mirroring the StoreAndForward queue-depth gauge. `IBundleSessionStore.OpenSessionCount` deliberately excludes expired-but-unswept entries — counting the raw dictionary would report the node as pinned at the cap for up to a sweep interval after a burst had actually drained, which is the exact false positive this instrument exists to rule out. Transport is registered only in the host's Central branch, so the gauge reports 0 on a site node. +- **Warning log on rejection** — `BundleSessionStore.Open` logs the open count, the cap, and the TTL when it rejects. It fires only on a real cap hit: a store full of *expired* sessions is reclaimed first and logs nothing. A count that stays pinned at the cap across repeated warnings is a leak; an occasional warning is genuine concurrency, and the fix there is to raise `ScadaBridge:Transport:MaxConcurrentImportSessions` (each slot pins up to `MaxBundleSizeMb` of decrypted plaintext, so the cap is a memory budget). + ### Conflict resolution and `BundleImportId` correlation `PreviewAsync` compares each bundle artifact against the target environment using `ArtifactDiff`, classifying items as `Identical`, `Modified`, `New`, or `Blocker`. The operator assigns a `ResolutionAction` (`Add`, `Overwrite`, `Skip`, `Rename`) per item. `ApplyAsync` honours those resolutions in a single EF transaction and threads a new `BundleImportId` GUID through every per-entity audit row via the scoped `IAuditCorrelationContext`. This makes every configuration row written by a bundle import queryable as a group from the audit log. @@ -122,10 +127,18 @@ Passphrase lockout operates at two levels: per-bundle (3-strike counter in `Bund The two levels interact in a specific order, and it is load-bearing (register row 44 — the limiter had been registered, injected and unit-tested but never called, so only the per-bundle lockout was real): -1. **Per-bundle lockout first.** An already-locked bundle throws `BundleLockedException` without registering a rate-limiter attempt, so hammering a locked bundle cannot spend the client's hourly budget. +1. **Per-bundle lockout first.** An already-locked bundle throws `BundleLockedException` (Commons — register row 51 moved it out of `Transport.Import` for the same reason the throttle exception lives there: `ManagementService` does not reference `Transport`, so a lockout used to reach the CLI as `MapFault`'s generic "An internal error occurred" plus a correlation id) without registering a rate-limiter attempt, so hammering a locked bundle cannot spend the client's hourly budget. 2. **Per-IP throttle next, before the decrypt.** Over budget throws `BundleUnlockThrottledException` (Commons, so the management surface can catch it and return a curated message). Because it precedes the decrypt, a denial burns **no** per-bundle strike — otherwise the throttle would be a way to lock out a bundle someone is legitimately importing. Every unlock attempt is registered, successful ones included: the documented budget counts attempts, not failures. 3. Only then the AES-GCM decrypt, whose failure increments the per-bundle counter as before. +**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 @@ -255,11 +268,11 @@ A template's scripts reference a `SharedScript` or `ExternalSystem` that exists ### Passphrase lockout -After 3 wrong passphrase attempts against the same bundle (keyed by `ContentHash`), `BundleImporter.LoadAsync` throws `BundleLockedException`. The session is unusable. Re-upload the bundle file to get a new session with a fresh counter. A `BundleImportUnlockFailed` audit row is written on each failed attempt. +After 3 wrong passphrase attempts against the same bundle (keyed by `ContentHash`), `BundleImporter.LoadAsync` throws `BundleLockedException`. The session is unusable. Re-upload the bundle file to get a new session with a fresh counter. A `BundleImportUnlockFailed` audit row is written on each failed attempt. Both the Central UI and the CLI surface the lockout reason verbatim — the exception lives in `Commons/Types/Transport/` so `ManagementActor` can curate it into a `ManagementCommandException` (register row 51). ### Unlock throttled ("Too many bundle unlock attempts") -`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 a1b7287e..de37bd1b 100644 --- a/docs/requirements/Component-Transport.md +++ b/docs/requirements/Component-Transport.md @@ -134,7 +134,7 @@ flowchart TD class T muted ``` -The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a `MaxConcurrentImportSessions` cap (default 8; arch-review 05 Task 24 — startup-validated as `> 0` by `TransportOptionsValidator`, since a zero/negative cap would reject every import session forever) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory. +The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a `MaxConcurrentImportSessions` cap (default 8; arch-review 05 Task 24 — startup-validated as `> 0` by `TransportOptionsValidator`, since a zero/negative cap would reject every import session forever) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory. **The cap is observable (register row 53):** open (unexpired) sessions are published as the `scadabridge.central.transport.import_sessions.open` gauge on the `ZB.MOM.WW.ScadaBridge` meter — already in the `ObservedMeters` allowlist, so no telemetry config change is needed — and a rejection at the cap logs at Warning with the current count, the cap and the TTL. Reclaiming a store full of *expired* sessions is not a cap hit and logs nothing. **Session release is explicit, not TTL-only (register row 43).** `ApplyAsync` releases the session it applied (zero-then-remove, T-007), and `IBundleImporter.Abandon(sessionId)` does the same for a session that will never be applied. Every abandonment path calls it: the Central UI wizard on new upload / Back-to-Upload / `ResetSessionState` / lockout reset / `Dispose` (navigation away or circuit teardown), the CLI `transport preview` handler (a preview never applies), and the CLI `transport import` handler for the aborts that precede apply (blocker rows, unresolved mappings). `Abandon` is idempotent — double release, an unknown id, and an already-expired id are all silent no-ops — and since session ids are random GUIDs handed only to their opener, abandoning the id you hold can never evict another operator's in-progress import. Relying on the TTL alone was an availability *and* secret-lifetime defect: eight abandoned wizards exhausted `MaxConcurrentImportSessions` and locked out every import on the active node for 30 minutes, each still holding its decrypted bundle content. @@ -336,7 +336,9 @@ Imports are all-or-nothing per bundle. A bundle either applies fully or not at a - **AES-256-GCM** for content encryption with **PBKDF2-SHA256 / 600 000 iterations** (OWASP 2023+ guidance), per-bundle random salt, random IV per encryption. The GCM auth tag is verified before decryption — a wrong passphrase fails cleanly. - **Passphrase never persisted.** It lives only inside the export/import service call path and is discarded after use. No environment variable, no log line. -- **Failed-unlock rate limit:** per-bundle 3-strike lockout (keyed by `ContentHash`, server-side); per-IP-per-hour cap (`MaxUnlockAttemptsPerIpPerHour`, default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row. The two are evaluated in order — bundle lockout, then per-IP throttle, then decrypt — so hammering an already-locked bundle spends no hourly budget, and a throttle denial (`BundleUnlockThrottledException`, fail-closed with a curated message on both the UI and CLI surfaces) burns **no** per-bundle strike, since it never reaches the decryptor. Successful unlocks count against the hourly budget too: it caps attempts, not failures. The client key is the caller's remote IP — from `CircuitClientAddressProvider` (snapshotted at Blazor circuit establishment, since `HttpContext` is unavailable mid-circuit) for the Central UI, and from `ManagementEnvelope.ClientIp` for the CLI; a caller with no attributable address shares a single stricter bucket rather than bypassing the throttle. Per-client isolation is only real behind a ForwardedHeaders-trusted proxy, the same caveat the login throttle carries in `Component-Security.md`. (Register row 44: the limiter existed, was configured and unit-tested, but was never invoked until this was wired.) +- **Failed-unlock rate limit:** per-bundle 3-strike lockout (keyed by `ContentHash`, server-side); per-IP-per-hour cap (`MaxUnlockAttemptsPerIpPerHour`, default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row. The two are evaluated in order — bundle lockout, then per-IP throttle, then decrypt — so hammering an already-locked bundle spends no hourly budget, and a throttle denial (`BundleUnlockThrottledException`, fail-closed with a curated message on both the UI and CLI surfaces) burns **no** per-bundle strike, since it never reaches the decryptor. Both typed exceptions live in `Commons/Types/Transport/` — `ManagementService` references Commons but not Transport, so a type declared beside the importer cannot be curated by `MapFault` and reaches the CLI as a generic internal-error message (register rows 44 and 51). Successful unlocks count against the hourly budget too: it caps attempts, not failures. The client key is the caller's remote IP — from `CircuitClientAddressProvider` (snapshotted at Blazor circuit establishment, since `HttpContext` is unavailable mid-circuit) for the Central UI, and from `ManagementEnvelope.ClientIp` for the CLI; a caller with no attributable address shares a single stricter bucket rather than bypassing the throttle. Per-client isolation is only real behind a ForwardedHeaders-trusted proxy, the same caveat the login throttle carries in `Component-Security.md`. (Register row 44: the limiter existed, was configured and unit-tested, but was never invoked until this was wired.) + +**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.Commons/Interfaces/Transport/IBundleSessionStore.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleSessionStore.cs index cdc493ec..efcb5c46 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleSessionStore.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleSessionStore.cs @@ -18,6 +18,15 @@ public interface IBundleSessionStore /// Removes all sessions whose expiry has passed. void EvictExpired(); + /// + /// Register row 53: the number of sessions currently held that have not yet expired. + /// Backs the scadabridge.central.transport.import_sessions.open gauge, so it + /// must be cheap and side-effect-free (it is read from the metrics collection thread) + /// and must NOT count expired-but-unswept entries — otherwise the gauge would read + /// as "at the cap" for up to a sweep interval after a burst drained. + /// + int OpenSessionCount { get; } + /// /// T-003: returns the current unlock-failure count for a bundle keyed by its /// content hash. The counter is server-owned so a second tab / CLI caller diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs index 0ffbf176..b2431fe4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs @@ -118,6 +118,14 @@ public static class ScadaBridgeTelemetry /// Provider that yields the live StoreAndForward queue depth; set by a later task. private static Func? _queueDepthProvider; + /// + /// Register row 53: provider that yields the number of currently open (unexpired) + /// Transport bundle-import sessions, registered by the Transport component's + /// session-eviction hosted service. Null on a site node (Transport is central-only), + /// where the gauge simply reports 0. + /// + private static Func? _importSessionCountProvider; + #pragma warning disable IDE0052 // Held to keep the observable gauges alive for the meter's lifetime. /// Gauge reporting the number of currently open site connections. private static readonly ObservableGauge _siteConnectionUp = @@ -138,6 +146,20 @@ public static class ScadaBridgeTelemetry () => Interlocked.Read(ref _liveAlarmAggregatorsActive), unit: "1", description: "Number of per-site live-alarm aggregators currently running on the active central node."); + + /// + /// Register row 53: gauge reporting the number of open Transport bundle-import + /// sessions on this central node. Each open session pins a fully decrypted bundle + /// in memory and consumes one of TransportOptions.MaxConcurrentImportSessions + /// (default 8), so a series that plateaus at the cap is the signature of leaked + /// sessions — the failure mode register row 43 could only be inferred from rotating + /// test failures because nothing counted them. + /// + private static readonly ObservableGauge _transportImportSessionsOpen = + Meter.CreateObservableGauge("scadabridge.central.transport.import_sessions.open", + () => Volatile.Read(ref _importSessionCountProvider) is { } p ? p() : 0L, + unit: "1", + description: "Number of currently open bundle-import sessions on this central node."); #pragma warning restore IDE0052 // ---------------- Emit helpers ---------------- @@ -242,4 +264,42 @@ public static class ScadaBridgeTelemetry // delegate, so a stale stop cannot clobber a successor's provider. Interlocked.CompareExchange(ref _queueDepthProvider, null, provider); } + + /// + /// Register row 53: registers the provider the open-import-sessions gauge reads on + /// each observation. Called by the Transport component's session-eviction hosted + /// service on start. A null provider is ignored so the gauge falls back to 0. + /// The callback is invoked synchronously on the metrics collection thread, so it + /// must stay cheap — the Transport implementation reads a + /// it + /// already owns. + /// + /// A callback returning the current open-session count. + public static void SetImportSessionCountProvider(Func provider) + { + if (provider is null) + { + return; + } + + Volatile.Write(ref _importSessionCountProvider, provider); + } + + /// + /// Clears the open-import-sessions provider, but only if the currently registered + /// provider is the exact delegate passed in + /// (reference-equal compare-and-clear), so a late stop of a superseded instance + /// cannot stomp a newer registrant. Mirrors . + /// + /// The provider delegate to remove; ignored unless it is the + /// one currently registered. + public static void ClearImportSessionCountProvider(Func provider) + { + if (provider is null) + { + return; + } + + Interlocked.CompareExchange(ref _importSessionCountProvider, null, provider); + } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleLockedException.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleLockedException.cs new file mode 100644 index 00000000..682abcca --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleLockedException.cs @@ -0,0 +1,44 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; + +/// +/// T-003: thrown by IBundleImporter.LoadAsync when an encrypted bundle has +/// exceeded the configured failed-unlock attempt limit +/// (TransportOptions.MaxUnlockAttemptsPerSession, default 3). The lockout is tracked +/// server-side keyed by , so a second tab / CLI caller +/// re-uploading the same bytes hits the same counter and cannot side-step the limit. +/// +/// Distinct from on purpose: this one means +/// "this BUNDLE has burned its strikes" and is keyed by the bundle's content hash; that one +/// means "this CLIENT has attempted too many unlocks, across any bundles" and is keyed by +/// the caller's IP. +/// +/// +/// Register row 51: lives in Commons rather than beside the importer for the same reason +/// does — it is part of the +/// IBundleImporter.LoadAsync contract, which is declared here, and the management +/// surface (which references Commons but not Transport) must be able to catch it to return +/// a clear, curated error instead of a generic internal-failure message plus a correlation id. +/// +/// +public sealed class BundleLockedException : Exception +{ + /// Number of recorded unlock failures for this bundle. + public int FailedAttempts { get; } + + /// SHA-256 (hex) of the bundle's content bytes, the lockout's tracking key. + public string BundleContentHash { get; } + + /// + /// Initializes a new . + /// + /// SHA-256 hex from . + /// Number of failures recorded against this bundle. + public BundleLockedException(string bundleContentHash, int failedAttempts) + : base( + $"Bundle is locked after {failedAttempts} failed unlock attempts. " + + "Wait for the lockout window to expire or re-export the bundle to obtain a new content hash.") + { + BundleContentHash = bundleContentHash ?? throw new ArgumentNullException(nameof(bundleContentHash)); + FailedAttempts = failedAttempts; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.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.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 5a6c8f66..7261c3b8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -3567,6 +3567,17 @@ public class ManagementActor : ReceiveActor { throw new ManagementCommandException(ex.Message); } + catch (BundleLockedException ex) + { + // Register row 51: the per-bundle 3-strike lockout. LoadAsync raises + // it from inside its own CryptographicException handler, so without + // this arm the operator saw MapFault's generic internal-error message + // plus a correlation id, never the reason. Listed ahead of the + // CryptographicException arm to keep the read order matching the + // importer's throw order (the two types are unrelated, so the + // ordering is documentation, not dispatch). + throw new ManagementCommandException(ex.Message); + } catch (System.Security.Cryptography.CryptographicException) { throw new ManagementCommandException("Wrong passphrase or bundle was tampered."); @@ -3632,6 +3643,17 @@ public class ManagementActor : ReceiveActor { throw new ManagementCommandException(ex.Message); } + catch (BundleLockedException ex) + { + // Register row 51: the per-bundle 3-strike lockout. LoadAsync raises + // it from inside its own CryptographicException handler, so without + // this arm the operator saw MapFault's generic internal-error message + // plus a correlation id, never the reason. Listed ahead of the + // CryptographicException arm to keep the read order matching the + // importer's throw order (the two types are unrelated, so the + // ordering is documentation, not dispatch). + throw new ManagementCommandException(ex.Message); + } catch (System.Security.Cryptography.CryptographicException) { throw new ManagementCommandException("Wrong passphrase or bundle was tampered."); diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/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/BundleLockedException.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleLockedException.cs deleted file mode 100644 index 987563a4..00000000 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleLockedException.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace ZB.MOM.WW.ScadaBridge.Transport.Import; - -/// -/// T-003: thrown by when an encrypted bundle has -/// exceeded the configured failed-unlock attempt limit -/// (). The lockout is tracked -/// server-side keyed by BundleManifest.ContentHash, so a second tab / CLI caller -/// re-uploading the same bytes hits the same counter and cannot side-step the limit. -/// -public sealed class BundleLockedException : Exception -{ - /// Number of recorded unlock failures for this bundle. - public int FailedAttempts { get; } - - /// SHA-256 (hex) of the bundle's content bytes, the lockout's tracking key. - public string BundleContentHash { get; } - - /// - /// Initializes a new . - /// - /// SHA-256 hex from BundleManifest.ContentHash. - /// Number of failures recorded against this bundle. - public BundleLockedException(string bundleContentHash, int failedAttempts) - : base( - $"Bundle is locked after {failedAttempts} failed unlock attempts. " - + "Wait for the lockout window to expire or re-export the bundle to obtain a new content hash.") - { - BundleContentHash = bundleContentHash ?? throw new ArgumentNullException(nameof(bundleContentHash)); - FailedAttempts = failedAttempts; - } -} diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionEvictionService.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionEvictionService.cs index a0c36115..cae75a32 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionEvictionService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionEvictionService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; +using ZB.MOM.WW.ScadaBridge.Commons.Observability; namespace ZB.MOM.WW.ScadaBridge.Transport.Import; @@ -21,6 +22,15 @@ internal sealed class BundleSessionEvictionService : BackgroundService private readonly IBundleSessionStore _sessionStore; private readonly ILogger _logger; + /// + /// Register row 53: the open-session gauge callback, held in a field so + /// can pass the SAME delegate reference to + /// — the clear is + /// an identity-checked compare-and-clear, so a stale stop cannot unregister a newer + /// instance's provider. Mirrors StoreAndForwardService's queue-depth registration. + /// + private readonly Func _openSessionCountProvider; + /// /// Initializes a new instance of . /// @@ -32,6 +42,35 @@ internal sealed class BundleSessionEvictionService : BackgroundService { _sessionStore = sessionStore ?? throw new ArgumentNullException(nameof(sessionStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _openSessionCountProvider = () => _sessionStore.OpenSessionCount; + } + + /// + /// Register row 53: registers the open-import-sessions gauge provider before the + /// sweep loop starts. This service is the Transport component's only lifecycle seam + /// (the session store itself is a plain singleton), and it is registered by + /// AddTransport, which only the Central branch of the host calls — so the + /// gauge reports 0 on a site node, where there are no import sessions to count. + /// + /// Host startup cancellation token. + /// A task that completes when the base has started. + public override Task StartAsync(CancellationToken cancellationToken) + { + ScadaBridgeTelemetry.SetImportSessionCountProvider(_openSessionCountProvider); + return base.StartAsync(cancellationToken); + } + + /// + /// Deregisters this instance's gauge provider on graceful stop so the gauge falls + /// back to 0 rather than reading through a dead store. The clear is identity-checked, + /// so a late stop cannot clobber a successor that already re-registered. + /// + /// Host shutdown cancellation token. + /// A task that completes when the base has stopped. + public override async Task StopAsync(CancellationToken cancellationToken) + { + ScadaBridgeTelemetry.ClearImportSessionCountProvider(_openSessionCountProvider); + await base.StopAsync(cancellationToken).ConfigureAwait(false); } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionStore.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionStore.cs index 1d52735b..ccac6231 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionStore.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionStore.cs @@ -1,4 +1,6 @@ using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; @@ -48,6 +50,7 @@ public sealed class BundleSessionStore : IBundleSessionStore private readonly TimeProvider _timeProvider; private readonly IOptions _options; + private readonly ILogger _logger; /// T-003: per-bundle unlock-failure entry with expiry. private sealed class UnlockFailureRecord @@ -61,12 +64,42 @@ public sealed class BundleSessionStore : IBundleSessionStore /// /// Transport options. is also used as the TTL for the T-003 per-bundle unlock-failure tracker. /// Time provider used to evaluate session expiry. - public BundleSessionStore(IOptions options, TimeProvider timeProvider) + /// Optional logger. Register row 53: a rejection at is logged at Warning, so a session leak is diagnosable from the node log instead of being inferred from rotating test failures. + public BundleSessionStore( + IOptions options, + TimeProvider timeProvider, + ILogger? logger = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _ = options.Value; _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? NullLogger.Instance; + } + + /// + public int OpenSessionCount + { + get + { + // Register row 53: count only unexpired entries. _sessions.Count would + // include expired-but-unswept sessions (the eviction sweep runs once a + // minute), so the gauge would read "pinned at the cap" for up to a sweep + // interval after a burst had actually drained — exactly the false signal + // this instrument exists to rule out. Read-only: the gauge callback runs + // on the metrics collection thread and must not mutate the store. + var now = _timeProvider.GetUtcNow(); + var count = 0; + foreach (var kv in _sessions) + { + if (kv.Value.ExpiresAt > now) + { + count++; + } + } + + return count; + } } /// @@ -82,6 +115,17 @@ public sealed class BundleSessionStore : IBundleSessionStore EvictExpired(); if (_sessions.Count >= cap) { + // Register row 53: this rejection used to be silent apart from the + // exception the caller saw. A cap hit is either genuine concurrency + // (many operators importing at once) or a session leak like register + // row 43's — the count-vs-cap pair plus the recurrence rate in the + // log is what distinguishes them. + _logger.LogWarning( + "Bundle import session rejected: {OpenSessions} of {Cap} concurrent import sessions are open " + + "(ScadaBridge:Transport:MaxConcurrentImportSessions). Sessions release on apply, cancel or " + + "the {TtlMinutes}-minute TTL; a count that stays pinned at the cap indicates leaked sessions.", + _sessions.Count, cap, _options.Value.BundleSessionTtlMinutes); + throw new InvalidOperationException( $"Too many concurrent import sessions ({cap}). Complete or cancel an in-progress import before starting another."); } diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleUnlockRateLimitedException.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleUnlockRateLimitedException.cs deleted file mode 100644 index 3e8bd70f..00000000 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleUnlockRateLimitedException.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace ZB.MOM.WW.ScadaBridge.Transport.Import; - -/// -/// Thrown by when the caller -/// has exceeded the configured per-IP-per-hour unlock attempt cap -/// (). The 429-equivalent -/// signal: the caller must wait for the trailing-hour window to roll forward before -/// another passphrase attempt is accepted. -/// -public sealed class BundleUnlockRateLimitedException : Exception -{ - /// - /// Rate-limit key the limiter rejected the attempt against — the caller IP when - /// supplied, or the bundle's content hash as the architectural fallback (the - /// importer has no IHttpContext dependency by design). - /// - public string ClientKey { get; } - - /// Per-window cap that was reached. - public int Limit { get; } - - /// - /// Initializes a new . - /// - /// The rate-limit key that exceeded its budget. - /// The configured per-window cap. - public BundleUnlockRateLimitedException(string clientKey, int limit) - : base( - $"Bundle unlock rate limit reached ({limit} attempts per hour). " - + "Wait for the trailing-hour window to expire before retrying.") - { - ClientKey = clientKey ?? throw new ArgumentNullException(nameof(clientKey)); - Limit = limit; - } -} 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.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 84ff8003..d7f7c165 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -3219,6 +3219,51 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Too many bundle unlock attempts", response.Error); } + [Fact] + public void ImportBundleCommand_LockedBundle_SurfacesTheCuratedReason() + { + // Register row 51: the per-bundle 3-strike lockout used to reach the CLI as + // MapFault's generic "An internal error occurred (CorrelationId=...)" because + // BundleLockedException lived in Transport, which ManagementService does not + // reference. It now lives in Commons alongside BundleUnlockThrottledException + // and is curated the same way. + var (_, importer) = AddBundleSubstitutes(); + + importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => + throw new Commons.Types.Transport.BundleLockedException("abc123", 3)); + + var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 }); + var actor = CreateActor(); + actor.Tell(Envelope(new ImportBundleCommand(payload, "wrong", "skip"), "Administrator")); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + Assert.Contains("locked after 3 failed unlock attempts", response.Error); + Assert.DoesNotContain("An internal error occurred", response.Error); + } + + [Fact] + public void PreviewBundleCommand_LockedBundle_SurfacesTheCuratedReason() + { + // Register row 51: preview shares the same LoadAsync fault surface as import, + // so it needs the same curated arm. + var (_, importer) = AddBundleSubstitutes(); + + importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => + throw new Commons.Types.Transport.BundleLockedException("abc123", 3)); + + var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 }); + var actor = CreateActor(); + actor.Tell(Envelope(new PreviewBundleCommand(payload, "wrong"), "Administrator")); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + Assert.Contains("locked after 3 failed unlock attempts", response.Error); + Assert.DoesNotContain("An internal error occurred", response.Error); + } + // ======================================================================== // Native alarm source CRUD (Task 21) // ======================================================================== 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); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs new file mode 100644 index 00000000..ca9f0939 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs @@ -0,0 +1,248 @@ +using System.Diagnostics.Metrics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Commons.Observability; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; +using ZB.MOM.WW.ScadaBridge.Transport.Import; + +namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import; + +/// +/// Register row 53: MaxConcurrentImportSessions had no observability — nothing +/// counted open sessions and nothing logged a rejection at the cap, so the register-row-43 +/// session leak could only be inferred from rotating test failures. These tests pin the +/// two signals that close that gap: the +/// scadabridge.central.transport.import_sessions.open gauge and the Warning-level +/// log emitted when rejects at the cap. +/// +public sealed class BundleSessionObservabilityTests +{ + private const string OpenSessionsGauge = "scadabridge.central.transport.import_sessions.open"; + + // ------------------------------------------------------------------ + // Warning log on cap rejection + // ------------------------------------------------------------------ + + [Fact] + public void Open_AtCap_LogsWarningWithCountAndCap() + { + // The rejection used to be visible only as the exception the caller saw. The + // operator-facing signal must name BOTH the current count and the cap — that + // pair is what distinguishes genuine concurrency from a leak. + var clock = new TestClock(DateTimeOffset.UtcNow); + var opts = Options.Create(new TransportOptions { MaxConcurrentImportSessions = 3 }); + var logger = new CapturingLogger(); + var sut = new BundleSessionStore(opts, clock, logger); + var future = clock.GetUtcNow().AddMinutes(30); + + for (var i = 0; i < 3; i++) + { + sut.Open(SessionExpiringAt(future)); + } + + Assert.Throws(() => sut.Open(SessionExpiringAt(future))); + + var warning = Assert.Single(logger.Entries, e => e.Level == LogLevel.Warning); + Assert.Contains("Bundle import session rejected", warning.Message, StringComparison.Ordinal); + Assert.Contains("3 of 3", warning.Message, StringComparison.Ordinal); + Assert.Contains("MaxConcurrentImportSessions", warning.Message, StringComparison.Ordinal); + } + + [Fact] + public void Open_UnderCap_LogsNothing() + { + // Negative control: the Warning must be a cap signal, not per-open noise. + var clock = new TestClock(DateTimeOffset.UtcNow); + var opts = Options.Create(new TransportOptions { MaxConcurrentImportSessions = 3 }); + var logger = new CapturingLogger(); + var sut = new BundleSessionStore(opts, clock, logger); + + sut.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); + + Assert.Empty(logger.Entries); + } + + [Fact] + public void Open_AtCapOfExpiredSessions_ReclaimsAndLogsNothing() + { + // A store that is full only of stale entries reclaims and admits the caller — + // that is not a cap hit and must not produce a Warning, or the signal would fire + // routinely on any node whose sweep has not yet run. + var clock = new TestClock(DateTimeOffset.UtcNow); + var opts = Options.Create(new TransportOptions { MaxConcurrentImportSessions = 2 }); + var logger = new CapturingLogger(); + var sut = new BundleSessionStore(opts, clock, logger); + + sut.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(5))); + sut.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(5))); + clock.Advance(TimeSpan.FromMinutes(10)); + + sut.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); + + Assert.Empty(logger.Entries); + } + + // ------------------------------------------------------------------ + // OpenSessionCount / gauge + // ------------------------------------------------------------------ + + [Fact] + public void OpenSessionCount_ExcludesExpiredButUnsweptSessions() + { + // The eviction sweep runs once a minute, so a raw dictionary count would report + // the store as pinned at the cap for up to a sweep interval after a burst had + // actually drained — exactly the false signal this instrument exists to rule out. + var clock = new TestClock(DateTimeOffset.UtcNow); + var sut = new BundleSessionStore(Options.Create(new TransportOptions()), clock, NullLogger.Instance); + + sut.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(5))); + var live = SessionExpiringAt(clock.GetUtcNow().AddMinutes(60)); + sut.Open(live); + + Assert.Equal(2, sut.OpenSessionCount); + + clock.Advance(TimeSpan.FromMinutes(10)); + + // The short session has expired but nothing has swept it yet. + Assert.Equal(1, sut.OpenSessionCount); + } + + [Fact] + public async Task EvictionService_RegistersTheGaugeProvider_AndClearsItOnStop() + { + // The eviction service is Transport's only lifecycle seam, so it owns the + // process-global gauge slot. The gauge is read as a delta against a baseline + // because the instrument is a process-wide static. + var clock = new TestClock(DateTimeOffset.UtcNow); + var store = new BundleSessionStore(Options.Create(new TransportOptions()), clock, NullLogger.Instance); + var service = new BundleSessionEvictionService(store, NullLogger.Instance); + + var baseline = ReadGauge(); + + await service.StartAsync(CancellationToken.None); + try + { + store.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); + store.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); + + Assert.Equal(2, ReadGauge()); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + + // After the clear the gauge falls back to reporting 0, not through a dead store. + Assert.Equal(0, ReadGauge()); + Assert.Equal(0, baseline); + } + + [Fact] + public async Task EvictionService_StopDoesNotClobberANewerInstancesProvider() + { + // Identity-checked compare-and-clear: a late stop of a superseded instance must + // leave a newer registrant's provider in place (mirrors the StoreAndForward + // queue-depth gauge's contract). + var clock = new TestClock(DateTimeOffset.UtcNow); + var oldStore = new BundleSessionStore(Options.Create(new TransportOptions()), clock, NullLogger.Instance); + var newStore = new BundleSessionStore(Options.Create(new TransportOptions()), clock, NullLogger.Instance); + var oldService = new BundleSessionEvictionService(oldStore, NullLogger.Instance); + var newService = new BundleSessionEvictionService(newStore, NullLogger.Instance); + + await oldService.StartAsync(CancellationToken.None); + await newService.StartAsync(CancellationToken.None); + try + { + newStore.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); + + // Late stop of the superseded instance. + await oldService.StopAsync(CancellationToken.None); + + Assert.Equal(1, ReadGauge()); + } + finally + { + await newService.StopAsync(CancellationToken.None); + } + + Assert.Equal(0, ReadGauge()); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /// + /// Forces one synchronous observation of the open-import-sessions gauge and returns + /// the measured value. Mirrors the MeterListener helper used by the Communication + /// and StoreAndForward telemetry suites. + /// + /// The gauge's current value, or 0 if it never reported. + private static long ReadGauge() + { + long observed = 0; + using var listener = new MeterListener(); + listener.InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName + && instrument.Name == OpenSessionsGauge) + { + l.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback((_, measurement, _, _) => observed = measurement); + listener.Start(); + listener.RecordObservableInstruments(); + return observed; + } + + private static BundleSession SessionExpiringAt(DateTimeOffset expiresAt) => new() + { + SessionId = Guid.NewGuid(), + Manifest = StubManifest(), + DecryptedContent = Array.Empty(), + ExpiresAt = expiresAt, + }; + + private static BundleManifest StubManifest() => new( + BundleFormatVersion: 1, + SchemaVersion: "1.0", + CreatedAtUtc: DateTimeOffset.UnixEpoch, + SourceEnvironment: "test", + ExportedBy: "t", + ScadaBridgeVersion: "1", + ContentHash: "0", + Encryption: null, + Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0), + Contents: Array.Empty()); + + /// Minimal mutable-clock for deterministic expiry. + private sealed class TestClock : TimeProvider + { + private DateTimeOffset _now; + public TestClock(DateTimeOffset start) { _now = start; } + public override DateTimeOffset GetUtcNow() => _now; + public void Advance(TimeSpan by) => _now = _now.Add(by); + } + + /// Captures formatted log entries so the cap-rejection Warning can be asserted. + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add((logLevel, formatter(state, exception))); + } + } +}