Re-ran all 8 domain reviews at HEAD8c888f13against theb910f5ebbaseline: every round-1 finding source-verified (168 fixed, 0 regressions, 0 false claims); 56 new findings (1 Critical / 4 High / 15 Medium / 36 Low), concentrated in post-baseline code (anti-entropy resync, KPI rollup backfill, live alarm stream) and seams the fixes exposed. Headliners: S&F resync predicate inversion can wipe the delivering node's buffer (02-N1 Critical); resync snapshot exceeds the Akka remoting frame size (02-N2); failover drill kills the one node keep-oldest can't survive (01-N1); unbounded rollup backfill per failover (04-R1); live production API key in untracked test.txt (08-NF1). Adds PLAN-R2-01..08 + .tasks.json manifests and the Round-2 board, P0 list, cross-plan mutexes, and wave order in 00-MASTER-TRACKER.
67 KiB
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/<project> (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.csxmldoc). - 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 inOnConnectedAsync) - 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 staticIsInstanceAccessAllowed)
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.
- Write the failing tests. Harness: a real
DefaultHttpContextwhoseRequestServicescarriesIOptions<AuthDisableLoginOptions>(off),IOptions<SecurityOptions>(defaults), aFakeTimeProvider-style clock asTimeProvider, a singletonLoginThrottle, and a substitutedILdapAuthService— mirrorManagementAuthenticatorTests.BasicAuthContextexactly. Wrap it for SignalR with a minimalHubCallerContextfake:
private sealed class TestHubCallerContext : HubCallerContext
{
private readonly FeatureCollection _features = new();
public TestHubCallerContext(HttpContext http) =>
_features.Set<IHttpContextFeature>(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<object, object?> Items { get; } = new Dictionary<object, object?>();
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<CommunicationService>.Instance) — plus a new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance) and a bare ServiceCollection().BuildServiceProvider(); IHubContext<DebugStreamHub> 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:
[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:
// 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 beforeapp.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|<proxy-ip> (N2). Fix: honor X-Forwarded-For from the trusted proxy only.
- Failing tests:
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<Exception>(() => ForwardedHeadersSetup.Build(
new ForwardedHeadersSecurityOptions { Enabled = true, KnownProxies = [proxy], KnownNetworks = [network] }));
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter ForwardedHeadersSetup— FAIL (types missing). - Implement
ForwardedHeadersSetup.cs:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using System.Net;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Config shape for trusted-proxy forwarded-header handling
/// (<c>ScadaBridge:Security:ForwardedHeaders</c>). 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).
/// </summary>
public sealed class ForwardedHeadersSecurityOptions
{
public const string SectionName = "ScadaBridge:Security:ForwardedHeaders";
public bool Enabled { get; set; }
/// <summary>Exact proxy IPs allowed to supply X-Forwarded-For.</summary>
public string[] KnownProxies { get; set; } = [];
/// <summary>CIDR networks allowed to supply X-Forwarded-For (e.g. "172.16.0.0/12").</summary>
public string[] KnownNetworks { get; set; } = [];
}
/// <summary>
/// Builds the <see cref="ForwardedHeadersOptions"/> for <c>app.UseForwardedHeaders</c>.
/// 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.
/// </summary>
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:
// 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<ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSecurityOptions>() ?? new();
if (ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSetup.Build(forwardedOptions) is { } fho)
app.UseForwardedHeaders(fho);
- Run the filter — PASS;
dotnet build ZB.MOM.WW.ScadaBridge.slnx— clean;dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests— no regressions. - 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)
- Add to both docker central nodes'
"Security"section (site nodes don't serve the UI/management surface — untouched):
"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.
LoginThrottleTests.cs:37— extend the// per username+IP keycomment:// per username+IP key — real isolation in production requires the trusted-proxy ForwardedHeaders config (R2 N2); without it all clients share the proxy IP.dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter LoginThrottle— still green (comment-only). Optional smoke after the nextbash docker/deploy.sh:docker logs scadabridge-central-ashows no ForwardedHeaders startup fault, and a failed CLI login from the host still 401s (not 429) on the first attempt.- 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.
- Add a scoped-envelope helper next to the existing
Envelopehelper (SecuredWriteHandlerTests.cs:151-153):
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:
[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<ManagementUnauthorized>(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<ManagementSuccess>(TimeSpan.FromSeconds(5));
}
[Fact]
public void Approve_SiteScopedVerifier_OutOfScopeSite_Unauthorized_NeverRelays()
{
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
_securedWriteRepo.GetAsync(42, Arg.Any<CancellationToken>()).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<ManagementUnauthorized>(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) */ }
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter SecuredWrite— new tests FAIL (no scope check exists). - 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:
// 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:
// 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, againstentity.SiteId.
- 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-wideEnvelopehelper (PermittedSiteIdsempty), soEnforceSiteScopeno-ops and they stay green.RequiredRoleMatrixTestsis 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. - 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.
- Failing tests (mirror the existing
SecuredWriteRepositoryTestsseeding — rows across site identifiers"SITE1","SITE2","SITE3"):
[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 */ }
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests --filter SecuredWriteRepository— FAIL (no such parameter). - Implement — additive optional parameter before the
CancellationTokenon both members (interface + impl):
Task<IReadOnlyList<PendingSecuredWrite>> QueryAsync(
string? status, string? siteId, int skip, int take,
IReadOnlyCollection<string>? permittedSiteIds = null, CancellationToken ct = default);
Task<int> CountAsync(
string? status, string? siteId,
IReadOnlyCollection<string>? 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:
if (permittedSiteIds is not null)
query = query.Where(w => permittedSiteIds.Contains(w.SiteId));
- Run the filter — PASS; full project run (existing positional callers compile unchanged — the parameter is trailing-optional).
- 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
- Failing tests:
[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<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void List_SiteScopedReader_Unfiltered_QueriesOnlyPermittedIdentifiers()
{
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site>
{
new("Site1", "SITE1") { Id = 1 },
new("Site3", "SITE3") { Id = 3 },
});
_securedWriteRepo.QueryAsync(null, null, 0, Arg.Any<int>(),
Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite>());
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ListSecuredWritesCommand(null, null), "bob", ["3"], "Verifier"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_securedWriteRepo.Received(1).QueryAsync(null, null, 0, Arg.Any<int>(),
Arg.Is<IReadOnlyCollection<string>?>(s => s != null && s.Single() == "SITE3"),
Arg.Any<CancellationToken>());
}
[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):
private static async Task<object?> 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<string>? 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<ISiteRepository>();
var permitted = new HashSet<string>(user.PermittedSiteIds);
permittedIdentifiers = (await siteRepo.GetAllSitesAsync())
.Where(s => permitted.Contains(s.Id.ToString()))
.Select(s => s.SiteIdentifier)
.ToList();
}
var repo = sp.GetRequiredService<ISecuredWriteRepository>();
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)
- 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
GetRequiredRolesswitch, 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. IfRequiredRoleMatrixTestsDOES fail, follow its documented update procedure (hand-author the changed entry against the switch + cross-checkComponent-ManagementService.md§Authorization — never regenerate mechanically). Full project run. - 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'EnforceSiteScoperule. Administrators and system-wide principals (empty permitted-site set) are unrestricted." - 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).
- Failing bUnit test (the harness's constructor stubs
GetSiteAlarmsAsync(Arg.Any<int>, ...); override per-site inside the test):
[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<AlarmSummaryResult>();
_summary.GetSiteAlarmsAsync(1, Arg.Any<CancellationToken>()).Returns(site1Tcs.Task);
_summary.GetSiteAlarmsAsync(2, Arg.Any<CancellationToken>()).Returns(Task.FromResult(
new AlarmSummaryResult(
new List<AlarmSummaryRow>
{
new("Site2Instance", new AlarmStateChanged("Site2Instance", "S2-alarm", AlarmState.Active, 100, DateTimeOffset.UtcNow)),
},
Array.Empty<string>())));
var cut = Render<AlarmSummaryPage>();
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<AlarmSummaryRow>
{
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);
});
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary— new test FAILS (StaleSite1 overwrites the page). - Implement — one guard in
RefreshAsync, immediately after the await at :293:
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.
- Failing bUnit test (uses the manual Refresh button,
data-test="alarm-summary-refresh", to drive the poll path deterministically — it invokes the sameRefreshAsyncthe timer does):
[Fact]
public void PollWhileLive_UpdatesNotReportingOnly_DoesNotRegressLiveRows()
{
// Poll snapshot = Zeta/Alpha (constructor stub) + a not-reporting instance.
_summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => Task.FromResult(new AlarmSummaryResult(
new List<AlarmSummaryRow>
{
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<AlarmStateChanged>
{
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']"));
});
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary— FAILS (the poll rebuilds Zeta over Gamma). - Implement — in
RefreshAsync, after Task 7's stale-site guard, replace the unconditional assignments:
// _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 aCapturedCallbackaccessor toFakeSiteAlarmLiveCache)
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).
- Failing test — expose the raw registered callback on the fake so the test can simulate a delta already in flight when
Disposeruns (bUnit's renderer stays alive aftercut.Instance.Dispose(), so pre-fix the late callback observably rebuilds the rows):
// In FakeSiteAlarmLiveCache:
public Action? CapturedCallback => _onChanged;
[Fact]
public void LiveCallbackRacingDispose_IsDropped_NoRebuildNoThrow()
{
var cut = RenderWithSiteSelected();
_liveCache.PushAlarms(new List<AlarmStateChanged>
{
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<AlarmStateChanged>()); // 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));
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary— FAILS (the late callback re-applies the empty snapshot: zero rows). - Implement — mirror
DebugView.razor:
// 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();
}
- Run the filter — PASS (including
DisposingComponent_UnsubscribesFromTheLiveCache). - 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(IsLivexmldoc :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).
- Failing tests (the harness runs a real TestKit
ActorSystem—SiteAlarmLiveCacheServiceTests— and the aggregator is a real child at/user/site-alarm-aggregator-{siteId}-{guid}):
[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
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter SiteAlarmLiveCache— first test FAILS (IsLivestays true after the stop). - Implement:
- In
StartAggregatorAsync, inside the lock right afterentry.Actor = system.ActorOf(props, ...)(:238), capture and watch:
- In
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:
/// <summary>Watches one aggregator and fires a callback exactly once on Terminated.</summary>
private sealed class AggregatorTerminationWatcher : ReceiveActor
{
public AggregatorTerminationWatcher(IActorRef watched, Action onTerminated)
{
Context.Watch(watched);
Receive<Terminated>(_ =>
{
onTerminated();
Context.Stop(Self);
});
}
}
/// <summary>
/// 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.
/// </summary>
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.IsLivexmldoc: 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".
- 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
TryGetValueguard keeps it inert there). - Update
Component-CentralUI.md:194: "…when a stream is unhealthy, the aggregator has died (deathwatch resetsIsLive), or a site has not yet seeded, the poll keeps the page fresh…" - 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).
- Failing test (needs a diagnostics accessor —
LoginThrottlealready hasInternalsVisibleTofor Security.Tests via the csproj):
[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
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter LoginThrottle— FAIL (compile:TrackedKeyCount/PruneEveryNFailuresmissing). - Implement:
/// <summary>Amortization cadence: a full prune sweep runs every Nth failure write
/// (plus immediately whenever the map exceeds <see cref="MaxTrackedKeys"/>), 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.</summary>
internal const int PruneEveryNFailures = 64;
/// <summary>Diagnostics/test accessor: number of currently tracked keys.</summary>
internal int TrackedKeyCount => _entries.Count;
private int _failureWrites;
Replace the unconditional Prune(now); at :121 with:
// 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);
}
- Run the filter — PASS, and ALL seven pre-existing throttle tests must stay green unchanged (they assert lockout semantics, which never depended on pruning).
- 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;MergeSentinelsxmldoc :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.
- Change
IsSecretNamefromprivatetointernal(the csproj already hasInternalsVisibleTofor the test project). Write the failing test:
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<Type, string[]> 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:
/// <b>Array limitation (arch-review R2 N9):</b> 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.
- Run the filter — PASS; full
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Testsrun. - 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,SubscribeSitesemantics) 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.csin 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) |