5bbd7689fa
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.
981 lines
67 KiB
Markdown
981 lines
67 KiB
Markdown
# 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.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<AuthDisableLoginOptions>` (off), `IOptions<SecurityOptions>` (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<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:
|
||
```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|<proxy-ip>` (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<Exception>(() => 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;
|
||
|
||
/// <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`:
|
||
```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<ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSecurityOptions>() ?? 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<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) */ }
|
||
```
|
||
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<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:
|
||
```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<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)`:
|
||
```csharp
|
||
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)
|
||
```
|
||
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<int>, ...)`; 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<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);
|
||
});
|
||
}
|
||
```
|
||
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<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']"));
|
||
});
|
||
}
|
||
```
|
||
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<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));
|
||
}
|
||
```
|
||
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
|
||
/// <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.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
|
||
/// <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:
|
||
```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<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.
|
||
```
|
||
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) |
|