# PLAN-R2-07 — UI, Management & Security Round-2 Hardening Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal** — Close every NEW (round-2) finding from architecture review 07 round 2 (`archreview/07-ui-management-security.md`, dated 2026-07-12): throttle the last unthrottled LDAP-bind surface (the debug-stream hub) and fix the doc that falsely claims coverage is complete (N1); make `LoginThrottle` keys survive the Traefik topology via trusted-proxy `ForwardedHeaders` and document the username-lockout-DoS trade-off (N2); enforce LDAP-mapping site scope on all four secured-write handlers, where the spec already promises it (N3); stop the Alarm Summary poll from displaying cross-site data after a site switch (N4) and from regressing fresher live state (N5); un-stick `IsLive` when a live-cache aggregator dies (N6); adopt the house disposal guard on the live callback (N7); amortize `LoginThrottle.Prune` (N8); and freeze the secret-scrubber's fragment coverage with a lock-in test (N9). Round-1 partials with documented deferrals (S1 import idempotency, S4 streaming multipart, P2 QueryDeployments/ExportBundle internals, UA6 fleet-wide grid a11y) **stay deferred** — coverage rows only, no tasks. **Architecture** — All fixes stay inside the seams round 1 built: `ManagementAuthenticator` is already the shared Basic→LDAP+throttle flow, so N1 is *deleting* the hub's bespoke bind and delegating; `LoginThrottle` and `SecurityOptions` already live in the Security component, so N2 adds one small pure setup class (`ForwardedHeadersSetup`) wired at the Host composition root ahead of the middleware pipeline; N3 reuses the existing `EnforceSiteScope`/`EnforceSiteScopeForIdentifier` helpers (`ManagementActor.cs:495-536`) exactly as C2's fix did for `DeployArtifacts`; N4/N5/N7 mirror guards that already exist elsewhere in the same file or in `DebugView.razor`; N6 adds a deathwatch hook to the (already reference-counted, lock-guarded) `SiteAlarmLiveCacheService`. Message-contract and repository changes are strictly additive (optional trailing params). Every spec-affecting change updates `docs/requirements/Component-Security.md` / `Component-CentralUI.md` / `Component-Communication.md` in the same task as the code. **Tech Stack** — C#/.NET 10, Akka.NET (TestKit.Xunit2 + NSubstitute for actor tests), ASP.NET Core minimal endpoints + SignalR + Blazor Server (bUnit), EF Core (ConfigurationDatabase). Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`. Test per-project: `dotnet test tests/` (CLI.Tests is in the slnx since PLAN-08 T1 — no special handling). --- ## Parallelization & mutex rules **ManagementActor single-writer lane (initiative-wide rule, same as PLAN-07):** every task that edits `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` serializes into ONE lane — among themselves **and** against any other plan's ManagementActor task. In this round-2 wave only PLAN-R2-07 touches the file (**Tasks 4 and 6** — run T4 → T6, never concurrently), but the rule stands: check the round-2 master tracker before starting either, and if another plan later gains a ManagementActor task, this lane serializes with it. **`SiteAlarmLiveCacheService.cs` mutex with PLAN-R2-02:** PLAN-R2-02 also modifies `src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs` (delta coalescing). **Task 10 (N6) and R2-02's coalescing task must NOT run concurrently** — whichever plan's task starts first finishes and lands its commit before the other begins, and the second task rebases on the first's committed state. Coordinate through the round-2 tracker. **`AlarmSummary.razor` internal lane:** Tasks 7, 8, 9 all edit the same file/methods — run strictly 7 → 8 → 9. **Concurrent lanes at plan start:** - **Free / file-disjoint:** T1 (`DebugStreamHub.cs`), T2 (`Security/ForwardedHeadersSetup.cs` + `Host/Program.cs`), T5 (`ISecuredWriteRepository` + EF repo), T7 (first of the AlarmSummary lane), T10 (subject to the R2-02 mutex), T11 (`LoginThrottle.cs`), T12 (test-only + `ConfigSecretScrubber.cs` xmldoc). - **Ordering constraints:** 3←2; 6←(4,5); 8←7; 9←8. T4 and T6 share the ManagementActor lane (T4 first). --- ### Task 1: Route the DebugStreamHub LDAP bind through `ManagementAuthenticator` (throttled) + fix the false doc claim **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** 2, 4, 5, 7, 10, 11, 12 **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs` (lines 78–146: replace the bespoke bypass + Basic-decode + LDAP-bind + role-map block in `OnConnectedAsync`) - Modify: `docs/requirements/Component-Security.md` (line 208, "Scope — every LDAP-bind surface" bullet) - Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs` (extend — currently only tests the static `IsInstanceAccessAllowed`) The hub currently uses `ManagementAuthenticator` **only** for the DisableLogin bypass (`TryDisableLoginUser`, :82) and then runs its own Basic decode + `ILdapAuthService.AuthenticateAsync` (:119-120) + `RoleMapper` (:129-130) — so the `LoginThrottle` never sees hub connection attempts, and hub failures don't feed the shared lockout. `ManagementAuthenticator.AuthenticateAsync` (`ManagementAuthenticator.cs:70-146`) already does all of it — bypass, decode, `IsLockedOut` refusal, bind, `RecordFailure`/`RecordSuccess`, role resolution, and system-wide `PermittedSiteIds` normalization. Delegate to it. 1. Write the failing tests. Harness: a real `DefaultHttpContext` whose `RequestServices` carries `IOptions` (off), `IOptions` (defaults), a `FakeTimeProvider`-style clock as `TimeProvider`, a singleton `LoginThrottle`, and a substituted `ILdapAuthService` — mirror `ManagementAuthenticatorTests.BasicAuthContext` exactly. Wrap it for SignalR with a minimal `HubCallerContext` fake: ```csharp private sealed class TestHubCallerContext : HubCallerContext { private readonly FeatureCollection _features = new(); public TestHubCallerContext(HttpContext http) => _features.Set(new TestHttpContextFeature { HttpContext = http }); public bool Aborted { get; private set; } public override string ConnectionId => "test-conn"; public override string? UserIdentifier => null; public override System.Security.Claims.ClaimsPrincipal? User => null; public override IDictionary Items { get; } = new Dictionary(); public override IFeatureCollection Features => _features; public override CancellationToken ConnectionAborted => CancellationToken.None; public override void Abort() => Aborted = true; private sealed class TestHttpContextFeature : IHttpContextFeature { public HttpContext? HttpContext { get; set; } } } ``` Build the hub with real light-weight collaborators (`DebugStreamService` needs `CommunicationService` — construct it directly: `new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger.Instance)` — plus a `new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance)` and a bare `ServiceCollection().BuildServiceProvider()`; `IHubContext` is `Substitute.For`), assign `hub.Context = testContext`, set `context.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.1")` and a Basic header for `alice:wrong`. Tests: ```csharp [Fact] public async Task OnConnectedAsync_FailedBinds_FeedTheSharedLoginThrottle() { // 5 failed hub connects for alice@10.0.0.1 must arm the same lockout the // /management and /auth surfaces consult (arch-review R2 N1). for (var i = 0; i < 5; i++) await ConnectAsync("alice", "wrong"); // each Aborted Assert.True(_throttle.IsLockedOut("alice", "10.0.0.1")); } [Fact] public async Task OnConnectedAsync_LockedOutKey_AbortsWithoutContactingLdap() { for (var i = 0; i < 5; i++) _throttle.RecordFailure("alice", "10.0.0.1"); _ldap.ClearReceivedCalls(); var ctx = await ConnectAsync("alice", "whatever"); Assert.True(ctx.Aborted); await _ldap.DidNotReceiveWithAnyArgs().AuthenticateAsync(default!, default!, default); } ``` (`ConnectAsync` = build hub + context, `await hub.OnConnectedAsync()`, return the fake context.) 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DebugStreamHub` — new tests FAIL (the hub binds directly; the throttle never locks and LDAP is contacted while "locked"). 3. Implement — in `OnConnectedAsync`, delete lines 78–137 (the `TryDisableLoginUser` block, the Basic-decode block, the direct `ILdapAuthService` bind, and the `RoleMapper` call) and replace with: ```csharp // Authenticate via the SAME shared Basic→LDAP flow as POST /management and the // audit REST (arch-review R2 N1): ManagementAuthenticator applies the DisableLogin // bypass, the LoginThrottle lockout check BEFORE the bind, and RecordFailure/ // RecordSuccess bookkeeping — so hub connection attempts can no longer be used as // an unthrottled LDAP-bind oracle, and hub failures feed the shared lockout. var outcome = await ManagementAuthenticator.AuthenticateAsync(httpContext); if (outcome.User is null) { _logger.LogWarning("DebugStreamHub connection rejected: authentication failed or throttled"); Context.Abort(); return; } var user = outcome.User; // Role check — Deployer role required (unchanged policy). if (!user.Roles.Contains(Roles.Deployer)) { _logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployer role", user.Username); Context.Abort(); return; } // Persist the resolved identity on the connection so per-instance site-scope // enforcement can be applied to SubscribeInstance calls. PermittedSiteIds is the // authenticator's normalized form (empty == system-wide), same as every other surface. Context.Items[RolesKey] = user.Roles; Context.Items[PermittedSiteIdsKey] = user.PermittedSiteIds; _logger.LogInformation("DebugStreamHub connection established for {Username}", user.Username); await base.OnConnectedAsync(); ``` Drop the now-unused `using System.Text;` / `ZB.MOM.WW.Auth.Abstractions.Ldap` imports if nothing else uses them. 4. Run the filter — PASS. Run the full project: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests` (the five `IsInstanceAccessAllowed` tests and `ManagementAuthenticatorTests` must stay green). 5. Fix `Component-Security.md:208`: the claim was false while the hub bound directly — with this change it becomes true. Reword the bullet so it *describes the mechanism* rather than asserting completeness by fiat, e.g.: "…and the HTTP-Basic management/CLI surfaces fronted by `ManagementAuthenticator` — `POST /management`, the audit REST endpoints, **and the debug-stream hub (`DebugStreamHub.OnConnectedAsync` delegates its whole credential path to `ManagementAuthenticator.AuthenticateAsync`, closing the round-2 N1 gap where the hub bound LDAP directly and bypassed the throttle)**. No bind happens outside these paths." 6. Commit: `git add src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs docs/requirements/Component-Security.md && git commit -m "fix(security): throttle DebugStreamHub LDAP bind via ManagementAuthenticator + correct coverage doc (plan R2-07 T1)"` ### Task 2: `ForwardedHeadersSetup` — trusted-proxy client-IP resolution for the throttle keys **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** 1, 4, 5, 7, 10, 11, 12 **Files:** - Create: `src/ZB.MOM.WW.ScadaBridge.Security/ForwardedHeadersSetup.cs` (options class + pure builder) - Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (central pipeline — insert immediately before `app.UseWebSockets()` at line 340) - Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ForwardedHeadersSetupTests.cs` (create) Every throttle surface keys on `context.Connection.RemoteIpAddress` (`ManagementAuthenticator.cs:111`, `AuthEndpoints.cs:39, :126`), and the repo has zero `UseForwardedHeaders` hits — behind Traefik every client shares the proxy IP and the key degenerates to `username|` (N2). Fix: honor `X-Forwarded-For` **from the trusted proxy only**. 1. Failing tests: ```csharp using Microsoft.AspNetCore.HttpOverrides; using ZB.MOM.WW.ScadaBridge.Security; using Xunit; namespace ZB.MOM.WW.ScadaBridge.Security.Tests; public class ForwardedHeadersSetupTests { [Fact] public void Build_Disabled_ReturnsNull() => Assert.Null(ForwardedHeadersSetup.Build(new ForwardedHeadersSecurityOptions { Enabled = false })); [Fact] public void Build_Enabled_ParsesProxiesAndNetworks_AndClearsDefaults() { var built = ForwardedHeadersSetup.Build(new ForwardedHeadersSecurityOptions { Enabled = true, KnownProxies = ["10.5.0.9"], KnownNetworks = ["172.16.0.0/12"], })!; Assert.Equal(ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, built.ForwardedHeaders); Assert.Equal(1, built.ForwardLimit); Assert.Single(built.KnownProxies); // ONLY the configured proxy — Assert.DoesNotContain(System.Net.IPAddress.IPv6Loopback, // the loopback default is cleared built.KnownProxies); Assert.Single(built.KnownNetworks); } [Theory] [InlineData("not-an-ip", "172.16.0.0/12")] [InlineData("10.5.0.9", "not-a-cidr")] public void Build_MalformedEntry_ThrowsAtStartup(string proxy, string network) => Assert.ThrowsAny(() => ForwardedHeadersSetup.Build( new ForwardedHeadersSecurityOptions { Enabled = true, KnownProxies = [proxy], KnownNetworks = [network] })); } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter ForwardedHeadersSetup` — FAIL (types missing). 3. Implement `ForwardedHeadersSetup.cs`: ```csharp using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; using System.Net; namespace ZB.MOM.WW.ScadaBridge.Security; /// /// Config shape for trusted-proxy forwarded-header handling /// (ScadaBridge:Security:ForwardedHeaders). Disabled by default: a deployment /// with no reverse proxy must NOT honor X-Forwarded-For (a client could spoof its /// throttle key). The documented Traefik topologies enable it with the docker /// network range so LoginThrottle keys on the REAL client IP (arch-review R2 N2). /// public sealed class ForwardedHeadersSecurityOptions { public const string SectionName = "ScadaBridge:Security:ForwardedHeaders"; public bool Enabled { get; set; } /// Exact proxy IPs allowed to supply X-Forwarded-For. public string[] KnownProxies { get; set; } = []; /// CIDR networks allowed to supply X-Forwarded-For (e.g. "172.16.0.0/12"). public string[] KnownNetworks { get; set; } = []; } /// /// Builds the for app.UseForwardedHeaders. /// Pure and fail-fast: malformed entries throw at startup rather than silently /// trusting nothing/everything. Defaults (loopback) are CLEARED — only the /// explicitly configured proxies/networks are trusted, and ForwardLimit=1 means /// only the entry the trusted proxy itself appended is honored. /// public static class ForwardedHeadersSetup { public static ForwardedHeadersOptions? Build(ForwardedHeadersSecurityOptions options) { if (!options.Enabled) return null; var built = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, ForwardLimit = 1, }; built.KnownProxies.Clear(); built.KnownNetworks.Clear(); foreach (var proxy in options.KnownProxies) built.KnownProxies.Add(IPAddress.Parse(proxy)); foreach (var network in options.KnownNetworks) { var parts = network.Split('/'); built.KnownNetworks.Add(new(IPAddress.Parse(parts[0]), int.Parse(parts[1]))); } return built; } } ``` (`KnownNetworks` element type: use whatever the installed ASP.NET Core surface expects — `Microsoft.AspNetCore.HttpOverrides.IPNetwork` historically, `System.Net.IPNetwork` on newer TFMs; the collection initializer `new(...)` resolves either. Match the compiler.) 4. Wire in `Host/Program.cs` — insert immediately **before** `app.UseWebSockets();` (line 340), first middleware in the central pipeline so everything downstream (auth endpoints, `ManagementAuthenticator`, hub) reads the substituted `Connection.RemoteIpAddress`: ```csharp // Trusted-proxy forwarded headers (arch-review R2 N2): behind Traefik every client // shares the proxy's IP, collapsing the LoginThrottle's {username}|{ip} keys onto // one address (per-IP isolation gone + cheap username-lockout DoS). When enabled, // X-Forwarded-For from the CONFIGURED proxies only is honored. MUST run first — // everything downstream keys on Connection.RemoteIpAddress. var forwardedOptions = builder.Configuration .GetSection(ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSecurityOptions.SectionName) .Get() ?? new(); if (ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSetup.Build(forwardedOptions) is { } fho) app.UseForwardedHeaders(fho); ``` 5. Run the filter — PASS; `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — clean; `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests` — no regressions. 6. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Security/ForwardedHeadersSetup.cs src/ZB.MOM.WW.ScadaBridge.Host/Program.cs tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ForwardedHeadersSetupTests.cs && git commit -m "fix(security): trusted-proxy ForwardedHeaders so LoginThrottle keys on the real client IP (plan R2-07 T2)"` ### Task 3: Ship the ForwardedHeaders config to the Traefik topologies + document the lockout-DoS trade-off **Classification:** high-risk **Estimated implement time:** ~4 min **Parallelizable with:** 1, 4, 5, 7, 10, 11, 12 (waits on Task 2) **Files:** - Modify: `docker/central-node-a/appsettings.Central.json`, `docker/central-node-b/appsettings.Central.json` (inside the existing `"Security"` object) - Modify: `docker-env2/central-node-a/appsettings.Central.json`, `docker-env2/central-node-b/appsettings.Central.json` (same) - Modify: `deploy/wonder-app-vd03/appsettings.Central.json` (explicit disabled block + comment; no proxy fronts that host) - Modify: `docs/requirements/Component-Security.md` (§Login throttling — key derivation + new trade-off paragraph) - Modify: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs` (comment only, line 37) 1. Add to both docker central nodes' `"Security"` section (site nodes don't serve the UI/management surface — untouched): ```json "ForwardedHeaders": { "_comment": "Traefik fronts this node on the external scadabridge-net docker network. Trust X-Forwarded-For from the docker bridge address pool so LoginThrottle keys on the real client IP (arch-review R2 N2). Narrow to the Traefik container IP if the network is pinned.", "Enabled": true, "KnownNetworks": [ "172.16.0.0/12" ] } ``` Same block in both `docker-env2` central nodes. In `deploy/wonder-app-vd03/appsettings.Central.json` add the block with `"Enabled": false` and a comment: "No reverse proxy fronts this host today. If one is introduced, set Enabled=true and list ONLY the proxy's IP in KnownProxies — never enable without it, or clients can spoof their throttle key." 2. Validate all five JSONs: `for f in docker/central-node-{a,b}/appsettings.Central.json docker-env2/central-node-{a,b}/appsettings.Central.json deploy/wonder-app-vd03/appsettings.Central.json; do python3 -c "import json,sys;json.load(open(sys.argv[1]))" "$f" || echo "BAD $f"; done` 3. `Component-Security.md` §Login throttling — update the "Key and window" bullet: the IP half of the key is the **forwarded-header-resolved client IP** when `ScadaBridge:Security:ForwardedHeaders` names a trusted proxy, otherwise the raw connection peer. Then append a new bullet documenting the trade-off (this text is the deliverable — keep the substance): - **Proxy topologies and the username-lockout DoS.** The key is deliberately `{username}|{IP}`, not `{username}` alone: keying on username alone would let any network-adjacent actor lock any operator out of a SCADA control surface with five wrong passwords, repeatable indefinitely. That isolation only exists when the throttle sees real client IPs — behind a proxy **without** ForwardedHeaders trust, all clients collapse onto the proxy's IP and the DoS returns. Mitigations: (a) the shipped Traefik topologies enable trusted-proxy ForwardedHeaders (so per-IP isolation is real in the documented deployment); (b) the lockout window is short by default (`LoginLockoutMinutes` = 5) and never touches the directory account itself; (c) residual risk — an attacker spraying from their *own* IP locking out a victim username *at that IP only* — is the throttle working as designed. A success-path bypass ("a correct password unlocks") was considered and rejected: it would let an attacker who has the password bypass the lockout entirely, and it converts the throttle into a password oracle during the lockout window. 4. `LoginThrottleTests.cs:37` — extend the `// per username+IP key` comment: `// per username+IP key — real isolation in production requires the trusted-proxy ForwardedHeaders config (R2 N2); without it all clients share the proxy IP`. 5. `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter LoginThrottle` — still green (comment-only). Optional smoke after the next `bash docker/deploy.sh`: `docker logs scadabridge-central-a` shows no ForwardedHeaders startup fault, and a failed CLI login from the host still 401s (not 429) on the first attempt. 6. Commit: `git add docker docker-env2 deploy/wonder-app-vd03/appsettings.Central.json docs/requirements/Component-Security.md tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs && git commit -m "fix(security): ship trusted-proxy ForwardedHeaders config for Traefik topologies + document lockout-DoS trade-off (plan R2-07 T3)"` ### Task 4: Enforce site scope on secured-write submit / approve / reject **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** 1, 2, 3, 5, 7, 10, 11, 12 (ManagementActor.cs — single-writer lane; run before Task 6) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleSubmitSecuredWrite` :1243-1283, `HandleApproveSecuredWrite` :1285-1440, `HandleRejectSecuredWrite` :1442-1479) - Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs` None of the four secured-write handlers checks `user.PermittedSiteIds` (verified: zero `EnforceSiteScope` occurrences in :1243-1510), while `Component-Security.md:141` says scoping "is layered on at the LDAP-mapping level" — a Verifier scoped to site 3 can today approve a **device write** to any site (N3, the same class C2 closed for `DeployArtifacts`). Ruling: **code moves, not the doc** — enforce. 1. Add a scoped-envelope helper next to the existing `Envelope` helper (`SecuredWriteHandlerTests.cs:151-153`): ```csharp private static ManagementEnvelope ScopedEnvelope(object command, string username, string[] permittedSiteIds, params string[] roles) => new(new AuthenticatedUser(username, username, roles, permittedSiteIds), command, Guid.NewGuid().ToString("N")); ``` Write the failing negative (and positive) tests — every security task needs the out-of-scope rejection asserted: ```csharp [Fact] public void Submit_SiteScopedOperator_OutOfScopeSite_Unauthorized_NoRowInserted() { SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway"); var actor = CreateActor(); actor.Tell(ScopedEnvelope( new SubmitSecuredWriteCommand("SITE1", "Gw1", "Tag.A", "true", "Boolean", "go"), "alice", ["3"], "Operator")); ExpectMsg(TimeSpan.FromSeconds(5)); _securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default); } [Fact] public void Submit_SiteScopedOperator_InScopeSite_Succeeds() { SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway"); var actor = CreateActor(); actor.Tell(ScopedEnvelope( new SubmitSecuredWriteCommand("SITE1", "Gw1", "Tag.A", "true", "Boolean", "go"), "alice", ["1"], "Operator")); ExpectMsg(TimeSpan.FromSeconds(5)); } [Fact] public void Approve_SiteScopedVerifier_OutOfScopeSite_Unauthorized_NeverRelays() { SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway"); _securedWriteRepo.GetAsync(42, Arg.Any()).Returns(new PendingSecuredWrite { Id = 42, SiteId = "SITE1", ConnectionName = "Gw1", TagPath = "Tag.A", ValueJson = "true", ValueType = "Boolean", Status = "Pending", OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow, }); var actor = CreateActor(); actor.Tell(ScopedEnvelope(new ApproveSecuredWriteCommand(42, "ok"), "bob", ["3"], "Verifier")); ExpectMsg(TimeSpan.FromSeconds(5)); _securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkApprovedAsync(default, default!, default, default, default); Assert.Equal(0, _comms.CallCount); // the device write is NEVER relayed } [Fact] public void Reject_SiteScopedVerifier_OutOfScopeSite_Unauthorized() { /* same seed, RejectSecuredWriteCommand(42, "no") → ManagementUnauthorized; UpdateAsync not received */ } [Fact] public void Approve_ScopedAdministrator_BypassesSiteScope() { /* ScopedEnvelope(..., "carol", ["3"], "Verifier", "Administrator") on the SITE1 row → NOT ManagementUnauthorized (EnforceSiteScope admin bypass, ManagementActor.cs:499) */ } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter SecuredWrite` — new tests FAIL (no scope check exists). 3. Implement — three insertions, all **before** any state mutation or audit emission: - `HandleSubmitSecuredWrite`: the site entity is already loaded at :1247-1248, so use the cheap overload right after it: ```csharp // Site scope (arch-review R2 N3): an LDAP-mapping-scoped Operator may only // submit device writes to their permitted sites — same rule C2 applies to // DeployArtifacts, on a higher-consequence path. Checked before the row exists. EnforceSiteScope(user, site.Id); ``` - `HandleApproveSecuredWrite`: after the row load + Pending check (:1289-1294), **before** the TTL/self-approval/CAS chain: ```csharp // Site scope (arch-review R2 N3): the approving Verifier must be permitted on // the row's target site — checked BEFORE the TTL/self-approval/CAS chain so an // out-of-scope verifier can neither consume the Pending transition nor relay. await EnforceSiteScopeForIdentifier(sp, user, row.SiteId); ``` - `HandleRejectSecuredWrite`: same line after :1447-1451, against `entity.SiteId`. 4. Run the filter — PASS. Run the full project (`dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests`) — the existing submit/approve/reject tests use the system-wide `Envelope` helper (`PermittedSiteIds` empty), so `EnforceSiteScope` no-ops and they stay green. `RequiredRoleMatrixTests` is untouched: no role-set changes, only in-handler scope enforcement (the frozen table freezes *roles*, not scope — see its header comment "Do not regenerate it mechanically"). If it fails, STOP: something else changed. 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs && git commit -m "fix(security): enforce LDAP-mapping site scope on secured-write submit/approve/reject (plan R2-07 T4)"` ### Task 5: `ISecuredWriteRepository` — additive permitted-sites filter for scoped listing **Classification:** standard **Estimated implement time:** ~4 min **Parallelizable with:** 1, 2, 3, 4, 7, 10, 11, 12 **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs` (`QueryAsync` :51, `CountAsync` :112) - Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs` (`QueryAsync` :55, `CountAsync` :131) - Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs` (extend) Task 6 needs to scope the *unfiltered* list for a site-scoped caller at the query level (so rows **and** `totalCount` are both scoped, and paging stays correct). Give both query methods an additive optional filter. 1. Failing tests (mirror the existing `SecuredWriteRepositoryTests` seeding — rows across site identifiers `"SITE1"`, `"SITE2"`, `"SITE3"`): ```csharp [Fact] public async Task QueryAsync_PermittedSiteIds_ReturnsOnlyPermittedRows() { // seed 2 rows on SITE1, 1 on SITE2, 1 on SITE3 (existing seeding helper) var rows = await _repo.QueryAsync(status: null, siteId: null, skip: 0, take: 100, permittedSiteIds: new[] { "SITE1", "SITE3" }); Assert.Equal(3, rows.Count); Assert.DoesNotContain(rows, r => r.SiteId == "SITE2"); } [Fact] public async Task CountAsync_PermittedSiteIds_CountsOnlyPermittedRows() { var count = await _repo.CountAsync(status: null, siteId: null, permittedSiteIds: new[] { "SITE1", "SITE3" }); Assert.Equal(3, count); } [Fact] public async Task QueryAsync_NullPermittedSiteIds_IsUnfiltered_BackCompat() { /* null → all 4 rows, exactly today's behavior */ } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests --filter SecuredWriteRepository` — FAIL (no such parameter). 3. Implement — additive optional parameter before the `CancellationToken` on both members (interface + impl): ```csharp Task> QueryAsync( string? status, string? siteId, int skip, int take, IReadOnlyCollection? permittedSiteIds = null, CancellationToken ct = default); Task CountAsync( string? status, string? siteId, IReadOnlyCollection? permittedSiteIds = null, CancellationToken ct = default); ``` xmldoc: "`permittedSiteIds` — when non-null, only rows whose `SiteId` (site identifier) is in the set match; `null` matches every site. Applied IN ADDITION to `siteId`. Used by the ManagementActor to constrain an unfiltered list to a site-scoped caller's permitted sites at the query level (arch-review R2 N3), so paging and totals stay correct." EF impl, in both methods' filter composition: ```csharp if (permittedSiteIds is not null) query = query.Where(w => permittedSiteIds.Contains(w.SiteId)); ``` 4. Run the filter — PASS; full project run (existing positional callers compile unchanged — the parameter is trailing-optional). 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs && git commit -m "fix(security): additive permitted-sites filter on secured-write query/count (plan R2-07 T5)"` ### Task 6: Scope-filter `HandleListSecuredWrites` + amend `Component-Security.md:141` + matrix verification **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** none (ManagementActor.cs — single-writer lane; waits on Tasks 4 and 5) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleListSecuredWrites` :1481-1509; dispatch arm :470) - Modify: `docs/requirements/Component-Security.md` (line 141 — the "any site scoping is layered on at the LDAP-mapping level" sentence) - Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs` 1. Failing tests: ```csharp [Fact] public void List_SiteScopedReader_ExplicitOutOfScopeSiteFilter_Unauthorized() { SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway"); var actor = CreateActor(); actor.Tell(ScopedEnvelope(new ListSecuredWritesCommand(null, "SITE1"), "bob", ["3"], "Verifier")); ExpectMsg(TimeSpan.FromSeconds(5)); } [Fact] public void List_SiteScopedReader_Unfiltered_QueriesOnlyPermittedIdentifiers() { _siteRepo.GetAllSitesAsync(Arg.Any()).Returns(new List { new("Site1", "SITE1") { Id = 1 }, new("Site3", "SITE3") { Id = 3 }, }); _securedWriteRepo.QueryAsync(null, null, 0, Arg.Any(), Arg.Any?>(), Arg.Any()) .Returns(new List()); var actor = CreateActor(); actor.Tell(ScopedEnvelope(new ListSecuredWritesCommand(null, null), "bob", ["3"], "Verifier")); ExpectMsg(TimeSpan.FromSeconds(5)); _securedWriteRepo.Received(1).QueryAsync(null, null, 0, Arg.Any(), Arg.Is?>(s => s != null && s.Single() == "SITE3"), Arg.Any()); } [Fact] public void List_SystemWideReader_PassesNullPermittedFilter() { /* Envelope(...) → QueryAsync received with permittedSiteIds == null */ } ``` (Match the real `ListSecuredWritesCommand` parameter order — read `SecuredWriteCommands.cs:63`: `(Status, SiteId, Skip, Take)`.) 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter List_SiteScoped` — FAIL. 3. Implement — change the handler signature to take the user and the dispatch arm at :470 from `HandleListSecuredWrites(sp, cmd)` to `HandleListSecuredWrites(sp, cmd, user)`: ```csharp private static async Task HandleListSecuredWrites( IServiceProvider sp, ListSecuredWritesCommand cmd, AuthenticatedUser user) { // Site scoping (arch-review R2 N3): an explicit SiteId filter is scope-checked // like every other identifier-keyed command; an UNFILTERED list from a // site-scoped non-admin caller is constrained to their permitted sites at the // query level (rows AND totalCount both scoped — see ISecuredWriteRepository). IReadOnlyCollection? permittedIdentifiers = null; if (cmd.SiteId is not null) { await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteId); } else if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) { var siteRepo = sp.GetRequiredService(); var permitted = new HashSet(user.PermittedSiteIds); permittedIdentifiers = (await siteRepo.GetAllSitesAsync()) .Where(s => permitted.Contains(s.Id.ToString())) .Select(s => s.SiteIdentifier) .ToList(); } var repo = sp.GetRequiredService(); var take = Math.Clamp(cmd.Take, 1, 500); var skip = Math.Max(0, cmd.Skip); var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip, take, permittedIdentifiers); var totalCount = await repo.CountAsync(cmd.Status, cmd.SiteId, permittedIdentifiers); // ... (opportunistic expiry sweep unchanged) ``` 4. Run the filter — PASS. Then the lock-in suites — the frozen table needs **no regeneration** (role sets untouched; per its header, the table freezes the `GetRequiredRoles` switch, which this task does not edit) and the dispatch arm still reaches a handler: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter "RequiredRoleMatrixTests|DispatchCoverageTests"` — green. If `RequiredRoleMatrixTests` DOES fail, follow its documented update procedure (hand-author the changed entry against the switch + cross-check `Component-ManagementService.md` §Authorization — never regenerate mechanically). Full project run. 5. Amend `Component-Security.md:141` — replace "Both are coarse global roles like the others; any site scoping is layered on at the LDAP-mapping level." with: "Both are coarse global roles like the others. **Site scoping from the LDAP mapping is enforced server-side on every secured-write command** (arch-review R2 N3): submit checks the target site, approve/reject check the row's site (before the TTL/self-approval/CAS chain), and the list constrains a scoped caller to their permitted sites — mirroring the deployment commands' `EnforceSiteScope` rule. Administrators and system-wide principals (empty permitted-site set) are unrestricted." 6. Commit: `git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs docs/requirements/Component-Security.md && git commit -m "fix(security): scope-filter secured-write listing + align spec with enforced site scoping (plan R2-07 T6)"` ### Task 7: `AlarmSummary.RefreshAsync` stale-site guard — never display cross-site data **Classification:** standard **Estimated implement time:** ~4 min **Parallelizable with:** 1, 2, 3, 4, 5, 10, 11, 12 (AlarmSummary.razor lane — run before Tasks 8, 9) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor` (`RefreshAsync` :283-308) - Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs` `RefreshAsync` captures `siteId` at entry (:285), awaits the multi-second fan-out (:293), then unconditionally assigns `_rows`/`_notReporting`/`_rollup` (:294-296) — a site-A poll completing after a switch to site B overwrites B's alarms with A's, on an operator alarm page (N4). The live path already has exactly this guard (`OnLiveAlarmsChanged`, :374). 1. Failing bUnit test (the harness's constructor stubs `GetSiteAlarmsAsync(Arg.Any, ...)`; override per-site inside the test): ```csharp [Fact] public void InFlightPollForPreviousSite_DoesNotOverwriteNewSitesRows() { // Site 1's fan-out hangs on a TCS; site 2 answers immediately (arch-review R2 N4). var site1Tcs = new TaskCompletionSource(); _summary.GetSiteAlarmsAsync(1, Arg.Any()).Returns(site1Tcs.Task); _summary.GetSiteAlarmsAsync(2, Arg.Any()).Returns(Task.FromResult( new AlarmSummaryResult( new List { new("Site2Instance", new AlarmStateChanged("Site2Instance", "S2-alarm", AlarmState.Active, 100, DateTimeOffset.UtcNow)), }, Array.Empty()))); var cut = Render(); cut.Find("[data-test='alarm-summary-site']").Change("1"); // poll in flight, hung cut.Find("[data-test='alarm-summary-site']").Change("2"); // site 2 renders cut.WaitForAssertion(() => Assert.Equal("Site2Instance", FirstRowInstance(cut))); // The stale site-1 fan-out now completes — it must be dropped, not rendered. site1Tcs.SetResult(new AlarmSummaryResult( new List { new("StaleSite1", new AlarmStateChanged("StaleSite1", "S1-alarm", AlarmState.Active, 999, DateTimeOffset.UtcNow)), }, new[] { "StaleNotReporting" })); cut.WaitForAssertion(() => { Assert.Equal("Site2Instance", FirstRowInstance(cut)); Assert.DoesNotContain("StaleNotReporting", cut.Markup); }); } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary` — new test FAILS (StaleSite1 overwrites the page). 3. Implement — one guard in `RefreshAsync`, immediately after the await at :293: ```csharp var result = await AlarmSummaryService.GetSiteAlarmsAsync(siteId); // Stale-site guard (arch-review R2 N4): the operator may have switched sites // while this fan-out was in flight — drop the result rather than labeling // site A's alarms under site B's picker. Mirrors OnLiveAlarmsChanged (:374). if (_selectedSiteId != siteId) { return; } ``` (`_loading` is reset in the existing `finally` — correct either way, since a switch re-enters `RefreshAsync` for the new site.) 4. Run the filter — PASS, plus the pre-existing AlarmSummary tests stay green. 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs && git commit -m "fix(ui): drop stale-site poll results on Alarm Summary site switch (plan R2-07 T7)"` ### Task 8: While live, the poll updates only `_notReporting` — never regresses live rows **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none (AlarmSummary.razor lane; waits on Task 7) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor` (`RefreshAsync` :283-308; reconciliation comment block :337-351) - Modify: `docs/requirements/Component-CentralUI.md` (line 194, the Refresh bullet) - Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs` The 15 s poll always rebuilds `_rows` from the fan-out even when the cache is live; a poll whose fan-out started before a live delta lands after it and momentarily reverts the delta (N5). The comment at :344-347 ("the poll simply re-affirms the same snapshot") is wrong — fix both. 1. Failing bUnit test (uses the manual Refresh button, `data-test="alarm-summary-refresh"`, to drive the poll path deterministically — it invokes the same `RefreshAsync` the timer does): ```csharp [Fact] public void PollWhileLive_UpdatesNotReportingOnly_DoesNotRegressLiveRows() { // Poll snapshot = Zeta/Alpha (constructor stub) + a not-reporting instance. _summary.GetSiteAlarmsAsync(Arg.Any(), Arg.Any()) .Returns(ci => Task.FromResult(new AlarmSummaryResult( new List { new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 900, DateTimeOffset.UtcNow)), }, new[] { "OfflineInstance" }))); var cut = RenderWithSiteSelected(); // Go live with a fresher delta: Gamma only (arch-review R2 N5). _liveCache.PushAlarms(new List { new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow), }); cut.WaitForAssertion(() => Assert.Equal("Gamma", FirstRowInstance(cut))); // A poll now completes: it owns _notReporting but must NOT rebuild the rows. cut.Find("[data-test='alarm-summary-refresh']").Click(); cut.WaitForAssertion(() => { Assert.Contains("OfflineInstance", cut.Markup); // notReporting refreshed Assert.Equal("Gamma", FirstRowInstance(cut)); // live rows NOT regressed Assert.Single(cut.FindAll("tr[data-test='alarm-summary-row']")); }); } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary` — FAILS (the poll rebuilds Zeta over Gamma). 3. Implement — in `RefreshAsync`, after Task 7's stale-site guard, replace the unconditional assignments: ```csharp // _notReporting is the poll's unique authority — the alarm-only live cache // cannot compute it — so it is always refreshed. _notReporting = result.NotReportingInstances; // While the cache is live, the live deltas own the row set: a poll whose fan-out // started BEFORE a delta must not land after it and momentarily revert the alarm // state (arch-review R2 N5). When not live (pre-seed / degraded stream / dead // aggregator — see R2 N6), the poll remains the full-rebuild safety net. if (!LiveAlarmCache.IsLive(siteId)) { _rows = result.Alarms; _rollup = AlarmSummaryService.ComputeRollup(_rows); } RecomputeVisibleRows(); ``` Rewrite the stale sentence in the reconciliation comment block (:344-347): the poll is "the authority for `_notReporting` and the full-rebuild safety net **only while the cache is not live**; when live it deliberately leaves `_rows` to the delta path". 4. Run the filter — PASS. Note interplay checks: `PollFallbackRenders_WhenCacheNotLiveYet` still passes (not live → full rebuild); `OnChangedDelta_RebuildsRenderedRowsFromLiveSnapshot` still passes (live path untouched). 5. Update `Component-CentralUI.md:194`: append "…when live, the poll updates only the `NotReporting` list and leaves the row set to the delta path, so a slow fan-out can never momentarily revert a fresher live delta (R2 N5)." 6. Commit: `git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs docs/requirements/Component-CentralUI.md && git commit -m "fix(ui): Alarm Summary poll defers row rebuilds to the live path while IsLive (plan R2-07 T8)"` ### Task 9: House disposal guard on the live callback **Classification:** small **Estimated implement time:** ~4 min **Parallelizable with:** none (AlarmSummary.razor lane; waits on Task 8) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor` (`OnLiveAlarmsChanged` :367-382, `Dispose` :507-511) - Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs` (+ add a `CapturedCallback` accessor to `FakeSiteAlarmLiveCache`) `OnLiveAlarmsChanged` does `_ = InvokeAsync(...)` with no `_disposed` flag; a callback racing `Dispose()` can fault the discarded task with `ObjectDisposedException` from `StateHasChanged` on a torn-down renderer (N7). `DebugView.razor` established the pattern (`_disposed` set before teardown + checked before AND inside the marshalled work — :312, :430-433, :606). 1. Failing test — expose the raw registered callback on the fake so the test can simulate a delta already in flight when `Dispose` runs (bUnit's renderer stays alive after `cut.Instance.Dispose()`, so pre-fix the late callback observably rebuilds the rows): ```csharp // In FakeSiteAlarmLiveCache: public Action? CapturedCallback => _onChanged; [Fact] public void LiveCallbackRacingDispose_IsDropped_NoRebuildNoThrow() { var cut = RenderWithSiteSelected(); _liveCache.PushAlarms(new List { new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow), }); cut.WaitForAssertion(() => Assert.Equal("Gamma", FirstRowInstance(cut))); // Snapshot the callback BEFORE disposal — this models a delta thread that // already read the delegate when Dispose ran (arch-review R2 N7). var inFlight = _liveCache.CapturedCallback!; cut.Instance.Dispose(); _liveCache.PushAlarms(new List()); // mutate the fake's snapshot var ex = Record.Exception(inFlight); Assert.Null(ex); // The disposed page must not have re-applied the (now empty) live snapshot. Assert.Equal("Gamma", FirstRowInstance(cut)); } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary` — FAILS (the late callback re-applies the empty snapshot: zero rows). 3. Implement — mirror `DebugView.razor`: ```csharp // N7: set BEFORE teardown so a live callback racing Dispose is dropped both // before the InvokeAsync marshal and inside it (mirrors DebugView.razor). private volatile bool _disposed; private void OnLiveAlarmsChanged(int siteId) { if (_disposed) return; _ = InvokeAsync(() => { if (_disposed || _selectedSiteId != siteId || !LiveAlarmCache.IsLive(siteId)) { return; } ApplyLiveSnapshot(siteId); StateHasChanged(); }); } public void Dispose() { _disposed = true; StopTimer(); DisposeLiveSubscription(); } ``` 4. Run the filter — PASS (including `DisposingComponent_UnsubscribesFromTheLiveCache`). 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs && git commit -m "fix(ui): disposal guard on Alarm Summary live callback, matching the DebugView pattern (plan R2-07 T9)"` ### Task 10: Un-stick `IsLive` — deathwatch resets liveness when the aggregator terminates **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** 1, 2, 3, 4, 5, 7, 11, 12 — **MUTEX: must NOT run concurrently with PLAN-R2-02's `SiteAlarmLiveCacheService` delta-coalescing task** (see "Parallelization & mutex rules"); whichever starts first lands its commit before the other begins **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs` (`StartAggregatorAsync` :219-243; new nested watcher actor + `OnAggregatorTerminated`; `SiteEntry` :416-453) - Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/ISiteAlarmLiveCache.cs` (`IsLive` xmldoc :53-61) - Modify: `docs/requirements/Component-CentralUI.md` (line 194 — "when a stream is unhealthy" wording gains the aggregator-death case) - Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs` `SiteEntry.HasPublished` is set on first publish (:392-393) and never cleared, so `IsLive` (:132-136) reports `true` forever — and `OnSiteChangedAsync` deliberately applies the live snapshot **on top of** the fresh poll when `IsLive` (`AlarmSummary.razor:277-280`), grafting an arbitrarily stale frozen snapshot over correct data if the aggregator died (N6). Fix: deathwatch the aggregator and reset liveness (which also makes the page's poll the authority again, via Task 8's `!IsLive` branch). 1. Failing tests (the harness runs a real TestKit `ActorSystem` — `SiteAlarmLiveCacheServiceTests` — and the aggregator is a real child at `/user/site-alarm-aggregator-{siteId}-{guid}`): ```csharp [Fact] public async Task Aggregator_Death_Resets_IsLive_And_Clears_The_Snapshot() { var service = CreateService(TimeSpan.FromMinutes(5), out _); using var sub = service.Subscribe(SiteId, () => { }); AwaitAssert(() => Assert.True(service.IsLive(SiteId)), TimeSpan.FromSeconds(10)); // Kill the aggregator out from under the service (crash-death, NOT a linger stop). var aggregator = await Sys.ActorSelection($"/user/site-alarm-aggregator-{SiteId}-*") .ResolveOne(TimeSpan.FromSeconds(5)); Sys.Stop(aggregator); AwaitAssert(() => { Assert.False(service.IsLive(SiteId)); // sticky no more (R2 N6) Assert.Empty(service.GetCurrentAlarms(SiteId)); // frozen snapshot never grafted }, TimeSpan.FromSeconds(10)); } [Fact] public void Linger_Stop_Does_Not_Resurrect_The_Aggregator() { // Last viewer leaves, linger fires, entry removed: the deathwatch on the // deliberately-stopped actor must be a no-op (entry gone), not a restart. var service = CreateService(TimeSpan.FromMilliseconds(50), out var factory); var sub = service.Subscribe(SiteId, () => { }); AwaitAssert(() => Assert.True(service.IsLive(SiteId)), TimeSpan.FromSeconds(10)); sub.Dispose(); AwaitAssert(() => Assert.False(service.IsLive(SiteId)), TimeSpan.FromSeconds(10)); var startsAfterStop = factory.GetOrCreateCount; Thread.Sleep(300); Assert.Equal(startsAfterStop, factory.GetOrCreateCount); // no self-heal restart fired } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter SiteAlarmLiveCache` — first test FAILS (`IsLive` stays true after the stop). 3. Implement: - In `StartAggregatorAsync`, inside the lock right after `entry.Actor = system.ActorOf(props, ...)` (:238), capture and watch: ```csharp var aggregator = entry.Actor; // Deathwatch (arch-review R2 N6): if the aggregator terminates for any reason, // liveness must reset — a dead feed must never keep IsLive == true, or the page // grafts a frozen snapshot over fresh poll data for up to 15 s. system.ActorOf(Props.Create(() => new AggregatorTerminationWatcher( aggregator!, () => OnAggregatorTerminated(entry.SiteId, aggregator!)))); ``` - New members on the service: ```csharp /// Watches one aggregator and fires a callback exactly once on Terminated. private sealed class AggregatorTerminationWatcher : ReceiveActor { public AggregatorTerminationWatcher(IActorRef watched, Action onTerminated) { Context.Watch(watched); Receive(_ => { onTerminated(); Context.Stop(Self); }); } } /// /// Terminated hook (R2 N6). A DELIBERATE stop (linger fired, entry already removed /// from _sites) is a no-op — the ReferenceEquals guard also ignores a stale watcher /// firing after a newer aggregator replaced the dead one. A crash-death with live /// viewers resets liveness (IsLive → false, snapshot cleared, so the page's poll is /// authoritative again) and arms the existing bounded start-retry so the feature /// self-heals, mirroring StartAggregatorAsync's transient-failure path. /// private void OnAggregatorTerminated(int siteId, IActorRef deadActor) { lock (_lock) { if (!_sites.TryGetValue(siteId, out var entry) || !ReferenceEquals(entry.Actor, deadActor)) return; entry.Actor = null; entry.HasPublished = false; entry.Current = Empty; if (entry.Subscribers.Count > 0 && !entry.Starting) { entry.Starting = true; entry.StartRetryTimer?.Dispose(); entry.StartRetryTimer = new Timer( _ => { _ = StartAggregatorAsync(entry); }, state: null, dueTime: _options.LiveAlarmCacheReconcileInterval, period: Timeout.InfiniteTimeSpan); } } _logger.LogWarning( "Live alarm aggregator for site {SiteId} terminated unexpectedly; liveness reset " + "(page falls back to polling) and a restart is armed while viewers remain", siteId); } ``` - `ISiteAlarmLiveCache.IsLive` xmldoc: replace "true once the site's aggregator has seeded and published at least once" with "true while a **living** aggregator has seeded and published; reverts to false if the aggregator terminates (R2 N6) — a frozen snapshot is never reported as live". 4. Run the filter — PASS (all pre-existing lifecycle tests — shared start, linger stop, re-subscribe, cap, self-heal — must stay green; the linger-stop path removes the entry before the actor stops, so the new hook's `TryGetValue` guard keeps it inert there). 5. Update `Component-CentralUI.md:194`: "…when a stream is unhealthy, **the aggregator has died (deathwatch resets `IsLive`)**, or a site has not yet seeded, the poll keeps the page fresh…" 6. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs src/ZB.MOM.WW.ScadaBridge.Communication/ISiteAlarmLiveCache.cs tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs docs/requirements/Component-CentralUI.md && git commit -m "fix(sitestream): deathwatch resets live-cache liveness on aggregator termination (plan R2-07 T10)"` ### Task 11: Amortize `LoginThrottle.Prune` off the failure hot path **Classification:** standard **Estimated implement time:** ~4 min **Parallelizable with:** 1, 2, 4, 5, 7, 10, 12 **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.Security/LoginThrottle.cs` (`RecordFailure` :121, `Prune` :146-169; new counter field) - Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs` `RecordFailure` → `Prune` enumerates all entries (and may `OrderBy` the whole map) on **every** failed bind — during the exact spray the throttle exists for, every request pays an O(n≤10k) scan (N8). Amortize: sweep every Nth failure, plus immediately whenever the map exceeds the hard cap (the cap stays the memory-safety invariant; only the expired-entry housekeeping is batched). 1. Failing test (needs a diagnostics accessor — `LoginThrottle` already has `InternalsVisibleTo` for Security.Tests via the csproj): ```csharp [Fact] public void KeySpray_MapStaysBounded_AtTheHardCap() { var (throttle, _) = Create(); // existing helper: throttle over a fake clock // Spray far past the cap: the over-cap eviction must run on EVERY write once // the cap is exceeded, regardless of the amortized cadence (arch-review R2 N8). for (var i = 0; i < LoginThrottle.MaxTrackedKeys + 500; i++) throttle.RecordFailure($"user{i}", "10.0.0.1"); Assert.True(throttle.TrackedKeyCount <= LoginThrottle.MaxTrackedKeys); } [Fact] public void ExpiredEntries_AreEventuallySwept_OnTheAmortizedCadence() { var (throttle, clock) = Create(); throttle.RecordFailure("stale", "10.0.0.1"); clock.Advance(TimeSpan.FromMinutes(10)); // window fully expired for (var i = 0; i < LoginThrottle.PruneEveryNFailures; i++) throttle.RecordFailure($"fresh{i}", "10.0.0.1"); // cadence tick fires within these Assert.False(throttle.IsLockedOut("stale", "10.0.0.1")); Assert.True(throttle.TrackedKeyCount <= LoginThrottle.PruneEveryNFailures); // "stale" swept } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter LoginThrottle` — FAIL (compile: `TrackedKeyCount`/`PruneEveryNFailures` missing). 3. Implement: ```csharp /// Amortization cadence: a full prune sweep runs every Nth failure write /// (plus immediately whenever the map exceeds ), so a /// spray does not pay an O(n) scan — and a possible full sort — on every failed /// request (arch-review R2 N8). Lockout SEMANTICS are unaffected: IsLockedOut reads /// window/lockout expiry directly and never depends on pruning. internal const int PruneEveryNFailures = 64; /// Diagnostics/test accessor: number of currently tracked keys. internal int TrackedKeyCount => _entries.Count; private int _failureWrites; ``` Replace the unconditional `Prune(now);` at :121 with: ```csharp // Amortized prune (R2 N8): every Nth failure, or immediately when over the hard // cap — the cap is the memory-safety invariant and must never wait for the cadence. if (Interlocked.Increment(ref _failureWrites) % PruneEveryNFailures == 0 || _entries.Count > MaxTrackedKeys) { Prune(now); } ``` 4. Run the filter — PASS, and ALL seven pre-existing throttle tests must stay green unchanged (they assert lockout semantics, which never depended on pruning). 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Security/LoginThrottle.cs tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs && git commit -m "fix(security): amortize LoginThrottle.Prune off the failed-bind hot path (plan R2-07 T11)"` ### Task 12: Lock in the scrubber's fragment coverage + document the array-merge limitation **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** 1, 2, 4, 5, 7, 10, 11 **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs` (`IsSecretName` :21 → `internal`; `MergeSentinels` xmldoc :121-126) - Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs` (extend) Detection is name-fragment-based (`password|secret|token|apikey|credential|passphrase`, :18-19). Current config types are fully covered, but a future field named `SigningKey` or `Pin` would silently leak (N9). Freeze the coverage the same way `RequiredRoleMatrixTests` froze the authz table: enumerate every string property on the DataConnections config types and demand each is *either* fragment-matched (scrubbed) *or* on a deliberate non-secret allowlist. 1. Change `IsSecretName` from `private` to `internal` (the csproj already has `InternalsVisibleTo` for the test project). Write the failing test: ```csharp using System.Reflection; using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections; // Frozen non-secret allowlist (arch-review R2 N9), mirroring RequiredRoleMatrixTests: // every string-typed property on the DataConnections config types must EITHER match // the scrubber's secret fragments (it gets elided) OR be listed here after a // deliberate "this is not a secret" decision. A new string property with neither // fails EveryConfigStringProperty_IsScrubbedOrDeliberatelyNonSecret, so a future // "SigningKey"/"Pin" cannot silently leak through List/Get/audit. private static readonly Dictionary KnownNonSecretStringProperties = new() { [typeof(OpcUaEndpointConfig)] = ["EndpointUrl", "SubscriptionDisplayName"], [typeof(OpcUaUserIdentityConfig)] = ["Username", "CertificatePath"], [typeof(MxGatewayEndpointConfig)] = ["Endpoint", "ClientName", "CaFile", "ServerName"], [typeof(OpcUaHeartbeatConfig)] = [/* enumerate on first run; likely none */], [typeof(OpcUaDeadbandConfig)] = [/* likely none */], }; [Fact] public void EveryConfigStringProperty_IsScrubbedOrDeliberatelyNonSecret() { foreach (var (type, allowlist) in KnownNonSecretStringProperties) { var stringProps = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.PropertyType == typeof(string)) .Select(p => p.Name); foreach (var name in stringProps) { var scrubbed = ConfigSecretScrubber.IsSecretName(name); var allowlisted = allowlist.Contains(name); Assert.True(scrubbed ^ allowlisted, $"{type.Name}.{name}: must be EITHER fragment-scrubbed OR explicitly " + "allowlisted as non-secret (and never both) — see arch-review R2 N9."); } } } [Theory] [InlineData("Password")] [InlineData("CertificatePassword")] [InlineData("ApiKey")] public void KnownSecretProperties_MatchTheFragmentList(string name) => Assert.True(ConfigSecretScrubber.IsSecretName(name)); ``` Cross-check the allowlist against the actual types before committing (`src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/` — `OpcUaEndpointConfig.cs`, `OpcUaUserIdentityConfig.cs`, `MxGatewayEndpointConfig.cs`, `OpcUaHeartbeatConfig.cs`, `OpcUaDeadbandConfig.cs`); the XOR also locks the *secret* side (a rename of `Password` off the fragment list fails the test). 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ConfigSecretScrubber` — FAIL first on compile (`IsSecretName` private), then green the assertions. 3. Append to the `MergeSentinels` xmldoc (:121-126) the N9 limitation, so the by-index behavior is a documented contract rather than a surprise: ``` /// Array limitation (arch-review R2 N9): sentinel restoration inside arrays /// pairs incoming[i] with stored[i] BY INDEX. A client that reorders an array of /// credentialed objects while round-tripping sentinels grafts the wrong stored /// secret into each slot — a silent misconfiguration (never a disclosure). Clients /// must not reorder arrays that carry sentinels (the CLI round-trip does not); /// key-based matching is a possible future refinement if a config type ever nests /// credentialed arrays. ``` 4. Run the filter — PASS; full `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests` run. 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs && git commit -m "test(management): freeze scrubber fragment coverage over DataConnection config types + document array-merge limit (plan R2-07 T12)"` --- ## Dependencies on other plans - **PLAN-R2-02 (Communication / site stream):** shares `SiteAlarmLiveCacheService.cs` — **Task 10 and R2-02's delta-coalescing task are mutually exclusive in time** (see the mutex rule at the top). The gRPC/site half of the live-alarm path (stream reconnect, `SubscribeSite` semantics) belongs to R2-02; Task 10 touches only the central service's liveness bookkeeping. - **ManagementActor single-writer lane:** only this plan (Tasks 4, 6) touches `ManagementActor.cs` in round 2, but the initiative-wide rule stands — serialize against any other plan's ManagementActor task that may appear. - **Round-1 documented deferrals stay put:** S1's client-supplied import-id idempotency remains plan-05 domain; S4 streaming multipart, P2 QueryDeployments/ExportBundle projections, and the UA6 fleet-wide grid a11y sweep remain in `docs/plans/2026-07-08-deferred-work-register.md`. No round-2 task may "helpfully" absorb them. ## Execution order **P0 (security-correctness first):** Task 1 (N1) and Task 2 → 3 (N2) and Task 4 → 6 (N3, with Task 5 landing any time before 6) — these five are the Medium security findings. Then Task 7 (N4, the operator-facing Medium) → 8 → 9 on the AlarmSummary lane; Tasks 10, 11, 12 land any time (10 subject to the R2-02 mutex). **Ordering constraints:** 3←2; 6←(4,5); 8←7; 9←8. Serialize {4, 6} (ManagementActor lane) and {7, 8, 9} (AlarmSummary.razor). Everything else is file-disjoint. **Milestone verification:** at plan completion run `dotnet build ZB.MOM.WW.ScadaBridge.slnx` and `dotnet test ZB.MOM.WW.ScadaBridge.slnx` (targeted filters during tasks; the full sweep only here), then rebuild the docker cluster (`bash docker/deploy.sh`) for a smoke: a wrong-password CLI login 401s then 429s on the 6th try (throttle through Traefik with the real client IP), `secured-write list` under `multi-role` still works, and the Alarm Summary page live-updates then survives a site switch mid-poll. --- ## Findings Coverage | Finding | Severity | Disposition | |---|---|---| | N1 — DebugStreamHub binds LDAP with no throttle; `Component-Security.md:208` falsely claims complete coverage | Medium | Task 1 (hub delegates to `ManagementAuthenticator.AuthenticateAsync`; doc corrected in the same task) | | N2 — LoginThrottle keys collapse behind Traefik: no ForwardedHeaders handling anywhere | Medium | Tasks 2, 3 (trusted-proxy `UseForwardedHeaders` bound from config; docker/docker-env2/deploy config shipped; lockout-DoS trade-off + mitigation documented; throttle-test assumption noted) | | N3 — Secured-write handlers ignore site scope while the spec says scoping is layered on at the LDAP-mapping level | Medium | Tasks 4, 5, 6 (enforce at submit/approve/reject via existing `EnforceSiteScope*` helpers; scoped list at the query level; `Component-Security.md:141` amended to match; frozen matrix verified — no role changes, no regeneration needed) | | N4 — `RefreshAsync` applies fan-out results without re-checking the selected site (cross-site data after a switch) | Medium | Task 7 (one-line guard mirroring the live path's `:374` check + bUnit regression) | | N5 — When live, a slower poll snapshot can regress fresher live state | Low | Task 8 (poll updates only `_notReporting` while `IsLive`; stale comment fixed) | | N6 — `IsLive` is sticky: `HasPublished` never resets on aggregator death | Low | Task 10 (deathwatch → liveness reset + snapshot clear + self-heal restart; interface doc updated). **Mutex with PLAN-R2-02's coalescing task.** | | N7 — Live-callback `InvokeAsync` lacks the house disposal guard | Low | Task 9 (`_disposed` set before teardown, checked before and inside the marshal — DebugView pattern) | | N8 — `LoginThrottle.Prune` scans (and may sort) the whole map on every failed bind | Low | Task 11 (every-Nth-failure cadence + immediate over-cap sweep; semantics unchanged) | | N9 — Scrubber array merge is by-index; fragment list has no lock-in against future config fields | Low | Task 12 (frozen XOR coverage test over the DataConnections config types; array-by-index limitation documented as contract) | | *Round-1 residue* S1 — import idempotency on a client-supplied id | High (residual) | **Remains deferred** (round-1 documented deferral; plan-05 / Transport apply-path domain; tracked in the deferred-work register) | | *Round-1 residue* S4 — streaming multipart bundle upload | Medium (residual) | **Remains deferred** (bounded by the round-1 `TransportGate` single-flight; deferred-work register) | | *Round-1 residue* P2 — QueryDeployments/ExportBundle load-all internals | Medium (residual) | **Remains deferred** (bounded by fleet size; needs repo-level projections; deferred-work register) | | *Round-1 residue* UA6 — fleet-wide sortable-grid a11y sweep | UA (residual) | **Remains deferred** (uniform low-severity UX debt; logged in the AlarmSummary component comment + deferred-work register) | | *Round-1 residue* P1/UA5 — the one missed bind surface | Medium (residual) | **Owned here as N1** → Task 1 (no separate row-work) |