Files
ScadaBridge/archreview/plans/PLAN-07-ui-management-security.md
T
Joseph Doherty 3a21e77c03 docs(archreview): add cross-plan parallelization map for incomplete tasks + sync stale PLAN-04 status
Corrects the Status Board (PLAN-04 24/26, not 0), and adds a Parallelization Map to
00-MASTER-TRACKER plus a per-plan Parallelization block to PLAN-04/05/06/07/08. Derives
ready sets from the live .tasks.json blockedBy graphs and the Files metadata, identifies
the initiative-wide serialization mutexes (ManagementActor.cs single-writer, EF migration
snapshot, Host/Program.cs, ArtifactDeploymentService.cs, SiteStorageService.cs,
NotificationOutboxActor.cs, AlarmSummary.razor), and recommends the safe concurrent
subagent lanes per plan. Flags PLAN-07 T33 as a dedupe-skip (CLI.Tests already in slnx).
2026-07-09 09:53:05 -04:00

1295 lines
90 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# UI, Management & Security Hardening Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal** — Close every finding from architecture review 07 (`archreview/07-ui-management-security.md`): kill the auth-disabled production artifact, honor per-site artifact deployment with a site-scope check, elide connection/external-system/DB secrets everywhere SMTP/SMS already are, make secured-write expiry real server-side, fix the 30s-vs-5min Ask-timeout mismatch, give the CLI actor-side browse/verify/cert-trust parity, canonicalize role casing, throttle LDAP binds, and freeze the authorization table with a matrix test.
**Architecture** — All fixes stay inside the existing management/presentation seam: the `ManagementActor` (single dispatch switch + `GetRequiredRole` gate + `PipeTo`), the `ManagementAuthenticator` Basic→LDAP flow shared by every CLI surface, the Security component's cookie/JWT/policy layer, and the Blazor Central UI. New primitives are small and testable in isolation (`DisableLoginGuard`, `ConfigSecretScrubber`, `LoginThrottle`, `PollGate`), wired at the existing composition points. Message-contract changes are strictly additive (optional trailing record params, new enum members) per the repo's contract-evolution rule; every spec-affecting change updates `docs/requirements/Component-Security.md` / `Component-ManagementService.md` / `Component-CentralUI.md` / `Component-CLI.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 + Blazor Server (bUnit), EF Core (ConfigurationDatabase), System.CommandLine (CLI). Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`. Test per-project: `dotnet test tests/<project>`**CLI tests must be run directly** (`dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests`) until Task 33 lands.
---
## Parallelization (incomplete tasks) — 2026-07-09
T1T3 complete. **Ready now** (`blockedBy` ⊆ {1,2,3}): **{4, 6, 8, 9, 10, 12, 14, 19, 21, 26, 28, 29, 30, 31, 32, 33}**.
**This plan's defining constraint:** 15 tasks edit **`ManagementActor.cs`** (T5, T7, T8, T9, T10, T11, T12, T15, T16, T17, T22, T23, T28, T30, T31) — they **serialize into one lane**, and that lane is shared initiative-wide with PLAN-05 T13 and PLAN-06 (T2/T3/T11/T20/T22).
**Concurrent lanes:**
- **Global ManagementActor lane (one at a time):** ready members = **{8, 9, 10, 12, 28, 30, 31}**. **Start with T10** (GetRequiredRoles refactor) — it unblocks T11, T13, T22, T23.
- **Free / file-disjoint (run as concurrent implementers):** T4 (`ArtifactDeploymentService.cs` — coordinate later with PLAN-06 T10), T6 (`ConfigSecretScrubber.cs`), T14 (`SecuredWriteRepository`), T19 (`ManagementEndpoints`/options), T21 (Commons Browse/Verify/Cert message records), T26 (Security `LoginThrottle`), and **T29 ↔ T32** (both edit `AlarmSummary.razor` — serialize the pair).
- **Skip:** **T33** — CLI.Tests is already in the slnx via PLAN-08 T1 (done); mark `skipped-duplicate` in the `.tasks.json`, don't execute.
See [00-MASTER-TRACKER.md § Parallelization Map](00-MASTER-TRACKER.md#parallelization-map--incomplete-tasks-2026-07-09) for the initiative-wide mutex table.
## Findings Coverage
| Finding | Severity | Disposition |
|---|---|---|
| C1 — `DisableLogin: true` in production deploy artifact, no environment guard | Critical | Tasks 1, 2, 3 |
| C2 — `deploy artifacts --site-id` silently ignored; no site-scope check | High | Tasks 4, 5 |
| C3 — DataConnection/ExternalSystem/DB-connection secrets leak in List/Get + audit | High | Tasks 6, 7, 8, 9 |
| S1 — 30s server Ask vs 5-min bundle/deploy operations; import keeps applying after 504 | High | Tasks 19, 20. Client-supplied import-id idempotency: **Deferred** — Transport apply-path change, plan 05's domain (coordinate; single-flight in Task 30 also narrows the double-apply window) |
| S2 — Pending secured writes never expire; "Expired" status fictional | High | Tasks 14, 15, 16, 18 |
| S3 — Poll timers can overlap their own refreshes | Medium | Task 29 |
| S4 — 200 MB bodies buffered as strings, ~4 copies resident | Medium | Task 30 (single-flight + copy elimination). Streaming multipart upload: **Deferred** — transport re-plumb of the JSON envelope; bounded risk after Task 30 |
| S5 — Fire-and-forget SignalR sends swallow failures | Low | Task 31 |
| P1 — Full LDAP bind per request, no throttle/lockout (password-spray oracle) | Medium | Tasks 26, 27 |
| P2 — Unbounded list handlers materialize whole tables | Medium | Task 28 (ListTemplates/ListInstances additive paging). QueryDeployments/ExportBundle internals: **Deferred** — bounded by fleet size today; needs repo-level projections, low risk |
| P3 — `ListSecuredWrites` hard-caps at 200, no paging | Medium | Tasks 14, 17, 18 |
| P4 — Alarm Summary re-sorts/filters per render | Low | Task 32 |
| C4 — Browse/Verify/Cert commands doc'd as actor commands but UI-only (no CLI parity) | Medium | Tasks 21, 22, 23, 24, 25 |
| C5 — Role-casing asymmetry (UI ordinal vs actor ignore-case), acknowledged deferral | Medium | Task 12 |
| C6 — Area role gate: code says Designer, three docs say Admin, UI page says Deployer | Medium | Task 11 |
| C7 — `InstanceName` passed into slot commented "InstanceId filter" | Low | Task 31 (verified: site `instance_id` column stores the instance **UniqueName**`InstanceActor.LogLifecycleEvent` passes `_instanceUniqueName` — so behavior is correct; comment/xmldoc are wrong) |
| C8 — Stale CLAUDE.md note on SecuredWrite `SourceNode` | Low | Task 31 |
| UA1 — Secured-write lifecycle (no expiry, no paging, history ungated, dual-role hazard) | UA | Tasks 10, 1418; dual-role deployment hazard restated in docs in Task 15 |
| UA2 — ManagementService test depth (~150 commands, no authz matrix test) | UA | Tasks 13, 24 (+ handler tests added by every task above) |
| UA3 — CLI.Tests not in slnx | UA | Task 33 |
| UA4 — No single tracking list for deferred follow-ups | UA | **Deferred to plan 08**, which owns the consolidated 23-item deferred-work inventory |
| UA5 — Login endpoints lack throttling; user-enumeration check | UA | Tasks 26, 27. Enumeration check **verified safe, no change**: `LdapAuthFailureMessages` maps `UserNotFound` and `BadCredentials` to the identical "Invalid username or password." |
| UA6 — Accessibility debt on sortable headers/custom grids | UA | Task 32 (AlarmSummary, the cited file). Fleet-wide grid a11y sweep: **Deferred** — uniform low-severity UX debt, log as follow-up |
| UA7 — "Committed" runtime logs under docker topology | UA | **Won't-fix** — verified false premise: `git check-ignore docker/central-node-a/logs` → ignored, `git ls-files docker | grep -i log` → empty. Logs are untracked local output; nothing to scrub |
---
### Task 1: `DisableLoginGuard` — refuse the flag outside Development
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** 4, 6, 14, 19, 21, 26, 29
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Security/Auth/DisableLoginGuard.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs` (add `AllowOutsideDevelopment` property)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginGuardTests.cs` (create)
1. Write the failing test:
```csharp
using ZB.MOM.WW.ScadaBridge.Security.Auth;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.Security.Tests;
public class DisableLoginGuardTests
{
[Theory]
[InlineData("Production")]
[InlineData("Staging")]
[InlineData("")]
public void Validate_DisableLoginOutsideDevelopment_WithoutAck_Throws(string env)
{
var ex = Assert.Throws<InvalidOperationException>(
() => DisableLoginGuard.Validate(disableLogin: true, environmentName: env, allowOutsideDevelopment: false));
Assert.Contains("DisableLogin", ex.Message);
Assert.Contains("Development", ex.Message);
}
[Theory]
[InlineData("Development", false)] // dev env: allowed without ack
[InlineData("development", false)] // case-insensitive env name
[InlineData("Production", true)] // explicit second ack key
public void Validate_Allowed_DoesNotThrow(string env, bool ack)
=> DisableLoginGuard.Validate(true, env, ack);
[Fact]
public void Validate_FlagOff_NeverThrows()
=> DisableLoginGuard.Validate(false, "Production", false);
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter DisableLoginGuardTests` — expect FAIL (type missing).
3. Implement `DisableLoginGuard.cs`:
```csharp
namespace ZB.MOM.WW.ScadaBridge.Security.Auth;
/// <summary>
/// Startup guard for the dev/test <see cref="AuthDisableLoginOptions.DisableLogin"/> flag.
/// DisableLogin authenticates EVERY request with ALL roles (including Operator+Verifier,
/// nullifying the two-person secured-write control) on a SCADA control surface, so it is
/// refused outside the Development environment unless the operator sets the explicit
/// second acknowledgement key <see cref="AuthDisableLoginOptions.AllowOutsideDevelopment"/>.
/// Called by the Host composition root before AddSecurity(); fail-fast at boot.
/// </summary>
public static class DisableLoginGuard
{
public static void Validate(bool disableLogin, string environmentName, bool allowOutsideDevelopment)
{
if (!disableLogin) return;
if (string.Equals(environmentName, "Development", StringComparison.OrdinalIgnoreCase)) return;
if (allowOutsideDevelopment) return;
throw new InvalidOperationException(
"ScadaBridge:Security:Auth:DisableLogin=true disables ALL authentication on a SCADA control " +
$"surface and is refused outside the Development environment (current: '{environmentName}'). " +
"Remove the flag, run with ASPNETCORE_ENVIRONMENT=Development, or — only if you fully accept an " +
"unauthenticated deployment — additionally set " +
"ScadaBridge:Security:Auth:AllowOutsideDevelopment=true.");
}
}
```
Add to `AuthDisableLoginOptions`:
```csharp
/// <summary>Explicit second acknowledgement key: permits DisableLogin outside Development. Default false.</summary>
public bool AllowOutsideDevelopment { get; set; }
```
4. Run the filter again — expect PASS. Run full project: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests`.
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Security/Auth tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginGuardTests.cs && git commit -m "feat(security): DisableLoginGuard refuses DisableLogin outside Development without explicit ack"`
### Task 2: Wire the guard at the Host composition root + doc
**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** none (waits on Task 1)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (~lines 130138, immediately after `disableLogin` is read)
- Modify: `docs/requirements/Component-Security.md` §Dev Disable-Login Flag (~line 27)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/` — inspect harness; if no config-composition test seam exists, verification is the Security.Tests guard suite (Task 1) + build + step 4 smoke
1. In `Program.cs`, after line 137 (`GetValue<bool>(...DisableLogin)`), insert:
```csharp
// Fail-fast guard: DisableLogin outside Development requires the explicit
// AllowOutsideDevelopment acknowledgement key. See DisableLoginGuard.
ZB.MOM.WW.ScadaBridge.Security.Auth.DisableLoginGuard.Validate(
disableLogin,
builder.Environment.EnvironmentName,
builder.Configuration
.GetSection(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName)
.GetValue<bool>(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.AllowOutsideDevelopment)));
```
2. Update `Component-Security.md` §Dev Disable-Login Flag: document the guard, the `AllowOutsideDevelopment` ack key, and fix the stale "all four roles" → "all six roles (Administrator, Designer, Deployer, Viewer, Operator, Verifier)" while in the section.
3. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — expect clean.
4. Smoke (docker cluster is unaffected — `docker/docker-compose.yml` sets `ASPNETCORE_ENVIRONMENT: Development` and both central nodes have `DisableLogin: false`): optionally run the Host locally with `ASPNETCORE_ENVIRONMENT=Production` and `ScadaBridge__Security__Auth__DisableLogin=true`, assert it exits with the guard message.
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Host/Program.cs docs/requirements/Component-Security.md && git commit -m "feat(host): fail-fast DisableLogin environment guard at composition root"`
### Task 3: Scrub `deploy/wonder-app-vd03` — remove `DisableLogin: true`
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** 1, 4, 6, 14, 19, 21, 26, 29
**Files:**
- Modify: `deploy/wonder-app-vd03/appsettings.Central.json` (lines 3942 `"Auth"` block; line 44 `_comment_Security`)
- Modify: `deploy/wonder-app-vd03/RUNBOOK.md` (add a login note)
1. Delete the block:
```json
"Auth": {
"DisableLogin": true,
"User": "multi-role"
}
```
(and the now-trailing comma on `"RequireHttpsCookie": false`'s following line structure — validate JSON).
2. Append to `_comment_Security`: "Login is REQUIRED on this host: authenticate as one of the GLAuth test users (e.g. multi-role/password) or repoint to corporate AD. DisableLogin is refused outside Development by a startup guard."
3. In `RUNBOOK.md`, add a short "Authentication" note: the UI (:8080) and management API (:8085) now require credentials; CLI calls need `--username/--password`; the previous unauthenticated posture was removed (arch-review C1).
4. Validate: `python3 -c "import json;json.load(open('deploy/wonder-app-vd03/appsettings.Central.json'))"` — expect no error.
5. Commit: `git add deploy/wonder-app-vd03 && git commit -m "fix(deploy): remove DisableLogin=true from wonder-app-vd03 production artifact (arch-review C1)"`
6. Note for the operator relaying this plan: the user-memory file `wonder-app-vd03-host-access.md` records "DisableLogin=true makes :8085 /management unauthenticated" — that note becomes stale on the next redeploy of this artifact.
### Task 4: `ArtifactDeploymentService.DeployToSiteAsync`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 1, 3, 6, 14, 19, 21, 26, 29
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs` (refactor `DeployToAllSitesAsync` ~lines 227345)
- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/` — extend the existing `ArtifactDeploymentService*` test file (locate with `grep -rl ArtifactDeploymentService tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests`)
1. Write failing tests (mirror the existing harness's mocks for `ISiteRepository`/`CommunicationService`):
```csharp
[Fact]
public async Task DeployToSiteAsync_DeploysOnlyToTargetSite()
{
// harness: two sites (Id 1 "SITE1", Id 2 "SITE2") in the site repo mock
var result = await _service.DeployToSiteAsync(1, "tester");
Assert.True(result.IsSuccess);
Assert.Single(result.Value.SiteResults);
Assert.Equal("SITE1", result.Value.SiteResults[0].SiteId);
await _communicationService.Received(1).DeployArtifactsAsync(
"SITE1", Arg.Any<DeployArtifactsCommand>(), Arg.Any<CancellationToken>());
await _communicationService.DidNotReceive().DeployArtifactsAsync(
"SITE2", Arg.Any<DeployArtifactsCommand>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task DeployToSiteAsync_UnknownSite_ReturnsFailure()
{
var result = await _service.DeployToSiteAsync(999, "tester");
Assert.False(result.IsSuccess);
Assert.Contains("999", result.Error);
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter DeployToSiteAsync` — expect FAIL (no such method).
3. Implement: extract the body of `DeployToAllSitesAsync` after the `sites` fetch into `private async Task<Result<ArtifactDeploymentSummary>> DeployCoreAsync(IReadOnlyList<Site> sites, string user, CancellationToken ct)` (everything from `deploymentId` creation down, unchanged). Then:
```csharp
public async Task<Result<ArtifactDeploymentSummary>> DeployToAllSitesAsync(
string user, CancellationToken cancellationToken = default)
{
var sites = await _siteRepo.GetAllSitesAsync(cancellationToken);
if (sites.Count == 0)
return Result<ArtifactDeploymentSummary>.Failure("No sites configured.");
return await DeployCoreAsync(sites, user, cancellationToken);
}
/// <summary>Deploys system-wide artifacts to a single site (management-API --site-id path).</summary>
public async Task<Result<ArtifactDeploymentSummary>> DeployToSiteAsync(
int siteId, string user, CancellationToken cancellationToken = default)
{
var site = await _siteRepo.GetSiteByIdAsync(siteId, cancellationToken);
if (site is null)
return Result<ArtifactDeploymentSummary>.Failure($"Site with ID {siteId} not found.");
return await DeployCoreAsync([site], user, cancellationToken);
}
```
(Match the actual `ISiteRepository.GetSiteByIdAsync` signature — check whether it takes a CancellationToken.)
4. Run the filter — expect PASS. Run `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests` (no regression in the all-sites tests).
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.DeploymentManager tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests && git commit -m "feat(deployment): DeployToSiteAsync for single-site artifact deployment"`
### Task 5: `HandleDeployArtifacts` honors `SiteId` + site-scope enforcement + docs
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (waits on Task 4; touches ManagementActor.cs — serialize with 713, 1518, 22, 23, 30)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleDeployArtifacts` lines 20062013; dispatch arm ~line where `MgmtDeployArtifactsCommand cmd =>` appears, currently passes `user.Username` — pass `user`)
- Modify: `docs/requirements/Component-ManagementService.md` (deployment commands section), `docs/requirements/Component-CLI.md` + `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (`--site-id` semantics: now honored, and fleet-wide requires system-wide deployment rights)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`
1. Write failing tests (using the existing `Envelope`/`ScopedEnvelope` helpers; register a substituted `ArtifactDeploymentService` in `_services` — if it is a concrete class, extract-or-virtualize is out of scope: register the real class with mocked dependencies exactly as the DeploymentManager tests construct it, or Substitute if members are virtual; follow whatever `SecuredWriteHandlerTests` does for `CommunicationService`):
```csharp
[Fact]
public void DeployArtifacts_WithSiteId_CallsSingleSiteDeploy()
{
var actor = CreateActor();
actor.Tell(Envelope(new MgmtDeployArtifactsCommand(SiteId: 7), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_artifactService.Received(1).DeployToSiteAsync(7, "testuser", Arg.Any<CancellationToken>());
}
[Fact]
public void DeployArtifacts_SiteScopedUser_OutOfScopeSite_Unauthorized()
{
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new MgmtDeployArtifactsCommand(SiteId: 7), ["3"], "Deployer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void DeployArtifacts_SiteScopedUser_FleetWide_Unauthorized()
{
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new MgmtDeployArtifactsCommand(SiteId: null), ["3"], "Deployer"));
var resp = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("fleet-wide", resp.Message, StringComparison.OrdinalIgnoreCase);
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DeployArtifacts` — expect FAIL.
3. Implement:
```csharp
private static async Task<object?> HandleDeployArtifacts(
IServiceProvider sp, MgmtDeployArtifactsCommand cmd, AuthenticatedUser user)
{
// A site-scoped Deployer may not trigger a FLEET-WIDE artifact deployment:
// EnforceSiteScope(null) is a no-op by design, so the fleet-wide case needs
// its own guard (arch-review C2 authorization gap).
if (cmd.SiteId is null
&& user.PermittedSiteIds.Length > 0
&& !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
{
throw new SiteScopeViolationException(
"Site-scoped users cannot deploy artifacts fleet-wide; specify a target site id.");
}
EnforceSiteScope(user, cmd.SiteId);
var svc = sp.GetRequiredService<ArtifactDeploymentService>();
var result = cmd.SiteId is int siteId
? await svc.DeployToSiteAsync(siteId, user.Username)
: await svc.DeployToAllSitesAsync(user.Username);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
```
Change the dispatch arm from `HandleDeployArtifacts(sp, cmd, user.Username)` to `HandleDeployArtifacts(sp, cmd, user)`. (Check `SiteScopeViolationException`'s ctor; match `EnforceSiteScope`'s usage.)
4. Run the filter — expect PASS; run the full project.
5. Update the three docs (step Files list) — `--site-id` now deploys to exactly that site; fleet-wide requires system-wide Deployer or Administrator.
6. Commit: `git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests docs/requirements/Component-ManagementService.md docs/requirements/Component-CLI.md src/ZB.MOM.WW.ScadaBridge.CLI/README.md && git commit -m "fix(management): honor MgmtDeployArtifactsCommand.SiteId + enforce site scope (arch-review C2)"`
### Task 6: `ConfigSecretScrubber` — JSON secret elision + sentinel merge
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 1, 3, 4, 14, 19, 21, 26, 29
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs` (create)
1. Failing tests:
```csharp
public class ConfigSecretScrubberTests
{
[Fact]
public void Scrub_RedactsPasswordFields_AtAnyDepth()
{
var json = """{"Endpoint":"opc.tcp://x","UserIdentity":{"Username":"u","Password":"p"},"CertificatePassword":"cp"}""";
var (scrubbed, had) = ConfigSecretScrubber.Scrub(json);
Assert.True(had);
Assert.DoesNotContain("\"p\"", scrubbed);
Assert.DoesNotContain("cp", scrubbed);
Assert.Contains(ConfigSecretScrubber.Sentinel, scrubbed);
Assert.Contains("opc.tcp://x", scrubbed); // non-secrets untouched
}
[Fact]
public void Scrub_NoSecrets_ReturnsFalseFlag()
{
var (_, had) = ConfigSecretScrubber.Scrub("""{"Endpoint":"opc.tcp://x"}""");
Assert.False(had);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not-json{")]
public void Scrub_NullEmptyOrMalformed_PassesThrough(string? input)
{
var (scrubbed, had) = ConfigSecretScrubber.Scrub(input);
Assert.Equal(input, scrubbed);
Assert.False(had);
}
[Fact]
public void MergeSentinels_RestoresStoredSecrets_KeepsEdits()
{
var stored = """{"Endpoint":"old","UserIdentity":{"Password":"real"}}""";
var incoming = $$"""{"Endpoint":"new","UserIdentity":{"Password":"{{ConfigSecretScrubber.Sentinel}}"}}""";
var merged = ConfigSecretScrubber.MergeSentinels(incoming, stored)!;
Assert.Contains("\"new\"", merged);
Assert.Contains("\"real\"", merged);
Assert.DoesNotContain(ConfigSecretScrubber.Sentinel, merged);
}
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ConfigSecretScrubberTests` — FAIL.
3. Implement with `System.Text.Json.Nodes.JsonNode` recursion:
```csharp
/// <summary>
/// Elides secret-bearing values inside opaque connection-config JSON blobs before
/// they leave the management boundary (responses AND audit afterState), mirroring
/// the SmtpConfigPublicShape/SmsConfigPublicShape pattern for structured entities.
/// A property is secret-bearing when its name contains one of the fragments below
/// (case-insensitive) and its value is a JSON string. MergeSentinels lets a client
/// round-trip a scrubbed config through update without wiping stored secrets.
/// </summary>
internal static class ConfigSecretScrubber
{
public const string Sentinel = "***REDACTED***";
private static readonly string[] SecretNameFragments =
["password", "secret", "token", "apikey", "credential", "passphrase"];
// Scrub: parse (return input unchanged on parse failure), walk objects/arrays,
// replace matching string values with Sentinel, return (json, hadSecrets).
// MergeSentinels: walk incoming; wherever value == Sentinel, substitute the value
// at the same path in storedJson (if absent, leave Sentinel — update will store it
// verbatim, which is inert). Null/empty inputs pass through.
}
```
4. Run — PASS. 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs && git commit -m "feat(management): ConfigSecretScrubber for connection-config secret elision"`
### Task 7: DataConnection List/Get/audit secret elision + sentinel-preserving update
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (ManagementActor.cs; waits on Task 6)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (lines 14011456: `HandleListDataConnections`, `HandleGetDataConnection`, `HandleCreateDataConnection`, `HandleUpdateDataConnection`)
- Modify: `docs/requirements/Component-ManagementService.md` (data-connection command responses are secret-elided; update merges the redaction sentinel)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs` (or a new `SecretProjectionTests.cs`)
1. Failing tests (harness: `ISiteRepository` substitute returning a `DataConnection` whose `PrimaryConfiguration` contains `"Password":"opc-secret"`):
```csharp
[Fact]
public void GetDataConnection_ResponseOmitsPasswords()
{
var actor = CreateActor();
actor.Tell(Envelope(new GetDataConnectionCommand(1), "Viewer"));
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("opc-secret", resp.JsonData);
Assert.Contains("hasSecrets", resp.JsonData); // camelCase serializer
}
[Fact]
public void UpdateDataConnection_SentinelConfig_PreservesStoredSecret()
{
// stored config has Password "opc-secret"; incoming config carries the sentinel
var actor = CreateActor();
var incoming = """{"Endpoint":"opc.tcp://new","UserIdentity":{"Password":"***REDACTED***"}}""";
actor.Tell(Envelope(new UpdateDataConnectionCommand(1, "c1", "OpcUa", incoming, null, 3), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
await _siteRepo.Received(1).UpdateDataConnectionAsync(
Arg.Is<DataConnection>(c => c.PrimaryConfiguration!.Contains("opc-secret")
&& !c.PrimaryConfiguration.Contains("***REDACTED***")));
}
[Fact]
public void UpdateDataConnection_AuditAfterState_OmitsPasswords()
{
var actor = CreateActor();
actor.Tell(Envelope(new UpdateDataConnectionCommand(1, "c1", "OpcUa", "{\"Password\":\"newpw\"}", null, 3), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// AuditAsync serializes afterState via IAuditService — assert the captured object graph has no "newpw"
_auditService.Received().LogAsync(Arg.Any<string>(), "Update", "DataConnection",
Arg.Any<string>(), Arg.Any<string>(),
Arg.Is<object>(o => !ManagementActor.SerializeResult(o).Contains("newpw")), Arg.Any<CancellationToken>());
}
```
(Match `UpdateDataConnectionCommand`'s and `IAuditService.LogAsync`'s real signatures — read them first.)
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DataConnection` — new tests FAIL.
3. Implement in `ManagementActor.cs`:
```csharp
/// <summary>Secret-elided projection of a DataConnection (arch-review C3): the raw
/// PrimaryConfiguration/BackupConfiguration JSON can embed OPC UA user + certificate
/// passwords, and List/Get are readable by any authenticated user. Mirrors
/// SmtpConfigPublicShape. Non-config fields are projected verbatim.</summary>
private static object DataConnectionPublicShape(DataConnection c)
{
var (primary, s1) = ConfigSecretScrubber.Scrub(c.PrimaryConfiguration);
var (backup, s2) = ConfigSecretScrubber.Scrub(c.BackupConfiguration);
return new
{
c.Id, c.Name, c.Protocol, c.SiteId, c.FailoverRetryCount,
PrimaryConfiguration = primary, BackupConfiguration = backup,
HasSecrets = s1 || s2,
};
// NOTE: project every additional simple property present on the entity
// (read Commons/Entities .../DataConnection.cs and include all non-config fields).
}
```
- `HandleListDataConnections`: `return (await ...).Select(DataConnectionPublicShape).ToList();` (both branches).
- `HandleGetDataConnection`: `return conn is null ? null : DataConnectionPublicShape(conn);` (after the scope check).
- `HandleCreateDataConnection`: audit + return `DataConnectionPublicShape(conn)`.
- `HandleUpdateDataConnection`: before assignment, `cmd = cmd with { }` is unnecessary — merge directly:
```csharp
conn.PrimaryConfiguration = ConfigSecretScrubber.MergeSentinels(cmd.PrimaryConfiguration, conn.PrimaryConfiguration);
conn.BackupConfiguration = ConfigSecretScrubber.MergeSentinels(cmd.BackupConfiguration, conn.BackupConfiguration);
```
(assign Name/Protocol/FailoverRetryCount as today); audit + return the public shape.
4. Run filter — PASS; full project run. Note: the Central UI data-connection editor talks to repositories directly (not the actor), so UI round-tripping is unaffected; CLI round-trips are protected by the sentinel merge.
5. Update `Component-ManagementService.md`. Commit: `git add -A src/ZB.MOM.WW.ScadaBridge.ManagementService tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests docs/requirements/Component-ManagementService.md && git commit -m "fix(management): elide DataConnection config secrets from responses and audit (arch-review C3)"`
### Task 8: ExternalSystem `AuthConfiguration` elision + preserve-if-null update
**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** none (ManagementActor.cs)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (lines 15871625)
- Modify: `docs/requirements/Component-ManagementService.md`
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/` (same file as Task 7)
1. Failing tests: `ListExternalSystems`/`GetExternalSystem` response must not contain the mocked `AuthConfiguration` value (e.g. `"apiKey":"es-secret"`), must contain `hasAuthConfiguration`; `UpdateExternalSystem` with `AuthConfiguration = null` must preserve the stored value; Create/Update audit afterState must not contain the secret. Same test pattern as Task 7.
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ExternalSystem` — FAIL.
3. Implement `ExternalSystemPublicShape`:
```csharp
/// <summary>Secret-elided ExternalSystemDefinition projection: AuthConfiguration
/// (API keys / Basic credentials) never leaves this boundary. Mirrors SmtpConfigPublicShape.</summary>
private static object ExternalSystemPublicShape(ExternalSystemDefinition d) => new
{
d.Id, d.Name, d.EndpointUrl, d.AuthType,
HasAuthConfiguration = !string.IsNullOrEmpty(d.AuthConfiguration),
};
```
List → `.Select(ExternalSystemPublicShape)`; Get → projected-or-null; Create/Update → audit + return projection; Update gains preserve-if-null: `if (cmd.AuthConfiguration is not null) def.AuthConfiguration = cmd.AuthConfiguration;` (comment mirrors the SMTP preserve-if-null rationale).
4. PASS + full run. 5. Doc + commit: `git commit -m "fix(management): elide ExternalSystem AuthConfiguration from responses and audit (arch-review C3)"`
### Task 9: DatabaseConnection `ConnectionString` elision + preserve-if-null update
**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** none (ManagementActor.cs)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (lines 25262559)
- Modify: `docs/requirements/Component-ManagementService.md`
- Test: same file as Tasks 78
1. Failing tests: List/Get response contains `id`/`name`/`hasConnectionString` but not the mocked connection string (`"Server=db;Password=sql-secret"`); Update with `ConnectionString = null` preserves stored value. Check `UpdateDatabaseConnectionDefCommand.ConnectionString` nullability first — if non-nullable, make it `string?` (additive JSON-wire-compatible).
2. `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DatabaseConnection` — FAIL.
3. Implement `private static object DatabaseConnectionPublicShape(DatabaseConnectionDefinition d) => new { d.Id, d.Name, HasConnectionString = !string.IsNullOrEmpty(d.ConnectionString) };` and apply to List/Get/Create/Update returns (audit shape at :2544/:2557 already `{Id, Name}` — leave). Update handler: `if (cmd.ConnectionString is not null) def.ConnectionString = cmd.ConnectionString;`
4. PASS + full run. CLI note: `db-connection` list/get output shape changes (no connection string) — update `Component-CLI.md`/CLI README sample output if shown.
5. Commit: `git commit -m "fix(management): elide DatabaseConnection ConnectionString from List/Get (arch-review C3)"`
### Task 10: `GetRequiredRoles` any-of refactor + gate `ListSecuredWrites`
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (ManagementActor.cs)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (lines 97242: `HandleEnvelope` + `GetRequiredRole`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs` (new `RequireSecuredWriteAccess` policy)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor` (page `@attribute [Authorize(Policy = ...)]` — check current policy first and align)
- Modify: `docs/requirements/Component-ManagementService.md` §Authorization, `docs/requirements/Component-CentralUI.md` §Secured Writes
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs` (policy registration)
1. Failing tests:
```csharp
[Fact]
public void ListSecuredWrites_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Theory]
[InlineData("Operator")]
[InlineData("Verifier")]
[InlineData("Administrator")]
public void ListSecuredWrites_SecuredWriteRoles_Allowed(string role)
{
var actor = CreateActor(); // ISecuredWriteRepository substitute returns empty list
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), role));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ListSecuredWrites` — FAIL (currently ungated).
3. Implement:
- Rename `GetRequiredRole``internal static IReadOnlyList<string>? GetRequiredRoles(object command)` (internal for Task 13's matrix test). Wrap the existing arms with static readonly arrays: `private static readonly string[] AdminOnly = [Roles.Administrator];`, `DesignerOnly`, `DeployerOnly`, `OperatorOnly`, `VerifierOnly`, and new `SecuredWriteReaders = [Roles.Operator, Roles.Verifier, Roles.Administrator];`. Add arm: `ListSecuredWritesCommand => SecuredWriteReaders,` with a comment (tag values on the history table are process-sensitive — arch-review UA1).
- `HandleEnvelope`:
```csharp
var requiredRoles = GetRequiredRoles(envelope.Command);
if (requiredRoles is not null
&& !requiredRoles.Any(r => user.Roles.Contains(r, StringComparer.OrdinalIgnoreCase)))
{
sender.Tell(new ManagementUnauthorized(correlationId,
$"One of roles [{string.Join(", ", requiredRoles)}] required for {envelope.Command.GetType().Name}"));
return;
}
```
(The existing `CreateSiteCommand_WithDesignRole_ReturnsUnauthorized` asserts `Contains("Administrator")` — still passes.)
- `AuthorizationPolicies.cs`: add `RequireSecuredWriteAccess` = `RequireClaim(JwtTokenService.RoleClaimType, Roles.Operator, Roles.Verifier, Roles.Administrator)` and apply to the SecuredWrites page attribute (check the page's current attribute; per Component-CentralUI it may already be operator-gated — align page, service, and actor).
4. PASS + full ManagementService.Tests + Security.Tests runs.
5. Docs, then commit: `git commit -m "feat(management): any-of role gates; restrict ListSecuredWrites to Operator/Verifier/Admin (arch-review UA1)"`
### Task 11: Reconcile the area-management role gate (code ⇄ three docs)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (ManagementActor.cs; waits on Task 10)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (Area command arms, lines 186/207)
- Modify: `docs/requirements/Component-ManagementService.md` §Authorization (~line 213), `docs/requirements/Component-Security.md` §Administrator (~line 88), `docs/requirements/Component-CentralUI.md` §Area Management (~line 106)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`
**Design decision** (record in the commit message): the Central UI creates areas inside the Deployment Topology workflow (`Topology.razor` is `RequireDeployment`), and the actor gates them Designer — the three docs' "Admin" assignment is stale. Reconcile on **any-of [Designer, Deployer]** (covers both real surfaces; Administrator is not implicitly included in the actor gate, matching current behavior for other Designer commands), and update all three docs to match.
1. Failing test:
```csharp
[Theory]
[InlineData("Designer")]
[InlineData("Deployer")]
public void CreateArea_DesignerOrDeployer_Allowed(string role) { /* Envelope(new CreateAreaCommand(...), role) → not ManagementUnauthorized */ }
[Fact]
public void CreateArea_ViewerOnly_Unauthorized() { /* → ManagementUnauthorized */ }
```
2. `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter CreateArea` — Deployer case FAILS today.
3. Move `CreateAreaCommand or UpdateAreaCommand or DeleteAreaCommand` out of the Designer arm into a new arm `=> AreaManagers` where `private static readonly string[] AreaManagers = [Roles.Designer, Roles.Deployer];` with a comment citing the reconciliation. Update the three docs: area management = Designer or Deployer (management API), Central UI exposes it inside the Deployment Topology workflow (`RequireDeployment`); remove "area management" from the Admin lists.
4. PASS. 5. Commit: `git commit -m "fix(management): reconcile area role gate to Designer|Deployer across actor and all three docs (arch-review C6)"`
### Task 12: Canonicalize role-mapping casing at the write path
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** 14, 19, 21, 26, 29 (ManagementActor.cs region 19041944 — do not run concurrently with other ManagementActor tasks)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (lines 19041944: `ValidateMappingRole`, `HandleCreateRoleMapping`, `HandleUpdateRoleMapping`)
- Modify: `docs/requirements/Component-Security.md` (role-mapping section: stored casing is canonicalized)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RoleMappingValidationTests.cs`
1. Failing test:
```csharp
[Fact]
public void CreateRoleMapping_LowercaseRole_StoresCanonicalCasing()
{
var actor = CreateActor();
actor.Tell(Envelope(new CreateRoleMappingCommand("ldap-group", "deployer"), "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
await _securityRepo.Received(1).AddMappingAsync(
Arg.Is<LdapGroupMapping>(m => m.Role == "Deployer")); // canonical, not verbatim
}
```
2. `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter RoleMapping` — FAIL.
3. Replace `ValidateMappingRole` with:
```csharp
// The stored Role must be CANONICAL casing (Roles.All): ASP.NET RequireClaim policies
// compare ordinal case-sensitively while the actor compares OrdinalIgnoreCase, so a
// row stored as "deployer" passes every actor gate but fails every UI policy — a
// CLI-works/UI-403s split-brain. Canonicalizing at this single write path closes the
// asymmetry (arch-review C5; closes the deferral formerly documented here).
private static string CanonicalizeMappingRole(string role)
{
var canonical = Roles.All.FirstOrDefault(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
return canonical ?? throw new ManagementCommandException(
$"Role '{role}' is not a recognized role. Valid roles are: {string.Join(", ", Roles.All)}.");
}
```
Use `var role = CanonicalizeMappingRole(cmd.Role);` in both create/update handlers and store `role`. Keep the invalid-role rejection tests green (message unchanged).
4. PASS + full RoleMappingValidationTests run. 5. Doc + commit: `git commit -m "fix(management): canonicalize role-mapping casing at write path, closing UI/actor case split (arch-review C5)"`
### Task 13: `GetRequiredRoles` matrix test — freeze the authorization table
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (waits on Tasks 10, 11, 22, 23 — final gate table)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj` (add `<ItemGroup><InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.ManagementService.Tests" /></ItemGroup>`)
- Create: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs`
1. Write the test (it IS the deliverable — no implementation change beyond the csproj line):
```csharp
using System.Runtime.CompilerServices;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Security;
public class RequiredRoleMatrixTests
{
// FROZEN authorization table: every registered management command MUST have an
// entry here. A new command without one fails EveryRegisteredCommand_IsInTheFrozenTable,
// forcing a deliberate authz decision (arch-review UA2; would have caught C2/C4).
private static readonly Dictionary<string, string[]?> Expected = new(StringComparer.OrdinalIgnoreCase)
{
["CreateSite"] = [Roles.Administrator],
["CreateTemplate"] = [Roles.Designer],
["MgmtDeployArtifacts"] = [Roles.Deployer],
["SubmitSecuredWrite"] = [Roles.Operator],
["ApproveSecuredWrite"] = [Roles.Verifier],
["ListSecuredWrites"] = [Roles.Operator, Roles.Verifier, Roles.Administrator],
["CreateArea"] = [Roles.Designer, Roles.Deployer],
["ListTemplates"] = null, // read-only: any authenticated user
// ... generate the FULL ~150-entry table: dump
// ManagementCommandRegistry-registered names with their current
// GetRequiredRoles(...) result once (scratch console or temp test),
// eyeball it against Component-ManagementService.md §Authorization,
// then paste it here verbatim and never regenerate mechanically.
};
public static IEnumerable<object[]> AllRegisteredCommands()
=> typeof(ManagementEnvelope).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
&& t.Name.EndsWith("Command", StringComparison.Ordinal) && !t.IsAbstract)
.Select(t => new object[] { t });
[Theory, MemberData(nameof(AllRegisteredCommands))]
public void EveryRegisteredCommand_IsInTheFrozenTable(Type commandType)
{
var name = commandType.Name[..^"Command".Length];
Assert.True(Expected.ContainsKey(name),
$"Command '{name}' has no entry in the frozen authorization table — add one deliberately.");
var instance = RuntimeHelpers.GetUninitializedObject(commandType);
var actual = ManagementActor.GetRequiredRoles(instance);
var expected = Expected[name];
if (expected is null) Assert.Null(actual);
else Assert.Equal(expected.OrderBy(x => x), actual!.OrderBy(x => x));
}
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter RequiredRoleMatrixTests` — FAIL until the table is complete and `InternalsVisibleTo` + `internal` visibility (Task 10) are in place.
3. Fill the table to green. Any surprise found while filling (a command gated differently than `Component-ManagementService.md` documents) gets fixed in this task — doc or gate, whichever is wrong — and noted in the commit message.
4. Commit: `git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs && git commit -m "test(management): frozen GetRequiredRoles matrix over every registered command (arch-review UA2)"`
### Task 14: SecuredWrite repository — `TryMarkExpiredAsync` + `CountAsync`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 1, 3, 4, 6, 19, 21, 26, 29
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/` — extend the existing SecuredWriteRepository test file (locate via `grep -rl SecuredWriteRepository tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests`; mirror its harness — in-memory/SQLite or MSSQL fixture)
1. Failing tests:
```csharp
[Fact]
public async Task TryMarkExpiredAsync_PendingRow_FlipsToExpired_Once()
{
var id = await _repo.AddAsync(NewPendingWrite());
Assert.True(await _repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
Assert.False(await _repo.TryMarkExpiredAsync(id, DateTime.UtcNow)); // CAS: second caller loses
var row = await _repo.GetAsync(id);
Assert.Equal("Expired", row!.Status);
Assert.NotNull(row.DecidedAtUtc);
Assert.Null(row.VerifierUser); // system decision, no verifier
}
[Fact]
public async Task TryMarkExpiredAsync_DecidedRow_ReturnsFalse()
{
var id = await _repo.AddAsync(NewPendingWrite());
await _repo.TryMarkApprovedAsync(id, "v", null, DateTime.UtcNow);
Assert.False(await _repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
}
[Fact]
public async Task CountAsync_FiltersLikeQueryAsync()
{
await _repo.AddAsync(NewPendingWrite());
await _repo.AddAsync(NewPendingWrite());
Assert.Equal(2, await _repo.CountAsync(status: "Pending", siteId: null));
Assert.Equal(0, await _repo.CountAsync(status: "Executed", siteId: null));
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests --filter "TryMarkExpired|CountAsync_Filters"` — FAIL.
3. Add to the interface (xmldoc mirrors `TryMarkApprovedAsync`'s CAS wording — this is the multi-node-safe expiry primitive):
```csharp
Task<bool> TryMarkExpiredAsync(long id, DateTime expiredAtUtc, CancellationToken ct = default);
Task<int> CountAsync(string? status, string? siteId, CancellationToken ct = default);
```
Implement with the same conditional-update pattern `TryMarkApprovedAsync` uses (`WHERE Status = 'Pending'`), setting `Status='Expired'`, `DecidedAtUtc=expiredAtUtc`, leaving `VerifierUser` NULL; `CountAsync` mirrors `QueryAsync`'s filter composition with `CountAsync()`.
4. PASS + full project run. 5. Commit: `git commit -m "feat(config-db): secured-write TryMarkExpiredAsync CAS + CountAsync (arch-review S2/P3)"`
### Task 15: Server-side secured-write TTL — enforce at approve/reject
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (ManagementActor.cs; waits on Task 14)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementServiceOptions.cs` (add `SecuredWritePendingTtl`, default 24 h)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs` (additive member `SecuredWriteExpire`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleApproveSecuredWrite` ~1106, `HandleRejectSecuredWrite` ~1255)
- Modify: `docs/requirements/Component-ManagementService.md`, `docs/requirements/Component-Security.md` (two-person section: TTL + restate the dual-role Operator+Verifier deployment-configuration hazard), `CLAUDE.md` Security & Auth bullet (append "pending secured writes expire server-side after a configurable TTL, default 24 h")
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs`
1. Failing tests (extend the existing `SecuredWriteHandlerTests` harness; register `IOptions<ManagementServiceOptions>` in its service collection):
```csharp
[Fact]
public void Approve_PendingRowOlderThanTtl_ExpiresInsteadOfExecuting()
{
var row = PendingRow(submittedAtUtc: DateTime.UtcNow.AddHours(-25)); // TTL default 24h
_securedWriteRepo.GetAsync(1).Returns(row);
_securedWriteRepo.TryMarkExpiredAsync(1, Arg.Any<DateTime>()).Returns(true);
var actor = CreateActor();
actor.Tell(Envelope(new ApproveSecuredWriteCommand(1, null), "Verifier"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("expired", resp.Error, StringComparison.OrdinalIgnoreCase);
_securedWriteRepo.DidNotReceive().TryMarkApprovedAsync(
Arg.Any<long>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
_commService.DidNotReceiveWithAnyArgs().WriteTagAsync(default!, default!); // stale value NEVER relayed
}
[Fact]
public void Approve_FreshPendingRow_StillExecutes() { /* submitted 1h ago → existing approve path unchanged */ }
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter SecuredWrite` — new tests FAIL.
3. Implement:
- Options: `/// <summary>Age after which a Pending secured write can no longer be approved; it is transitioned to Expired at the next decision/list touch. Default 24 hours.</summary> public TimeSpan SecuredWritePendingTtl { get; set; } = TimeSpan.FromHours(24);`
- `AuditKind`: add `SecuredWriteExpire` (additive enum member — message contracts are additive-only).
- Shared helper in `ManagementActor`:
```csharp
/// <summary>If the Pending row is older than the configured TTL, CAS it to Expired
/// (multi-node idempotent), audit best-effort, and return true. A stale setpoint
/// must never be approvable days later (arch-review S2).</summary>
private static async Task<bool> TryExpireIfStaleAsync(
IServiceProvider sp, ISecuredWriteRepository repo, PendingSecuredWrite row)
{
var ttl = sp.GetService<IOptions<ManagementServiceOptions>>()?.Value.SecuredWritePendingTtl
?? TimeSpan.FromHours(24);
if (ttl <= TimeSpan.Zero || DateTime.UtcNow - row.SubmittedAtUtc <= ttl) return false;
var expiredAt = DateTime.UtcNow;
if (await repo.TryMarkExpiredAsync(row.Id, expiredAt))
{
row.Status = "Expired";
row.DecidedAtUtc = expiredAt;
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteExpire, AuditStatus.Discarded, row, actor: "system");
}
return true;
}
```
- In `HandleApproveSecuredWrite` and `HandleRejectSecuredWrite`, immediately after the `"Pending"` status check: `if (await TryExpireIfStaleAsync(sp, repo, row)) throw new ManagementCommandException($"Secured write {cmd.Id} expired (pending longer than the configured TTL) and can no longer be approved or rejected.");`
(Check `AuditStatus` members; if `Discarded` is inappropriate, use the closest terminal status the enum offers and note it.)
4. PASS + full SecuredWriteHandlerTests. 5. Docs + CLAUDE.md, commit: `git commit -m "feat(management): server-side secured-write TTL — stale pending writes expire, never execute (arch-review S2)"`
### Task 16: Opportunistic expiry sweep in `HandleListSecuredWrites`
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (ManagementActor.cs; waits on Task 15)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleListSecuredWrites` ~1280)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs`
1. Failing test: repo returns one fresh Pending + one 25h-old Pending; `ListSecuredWritesCommand` result marks the old one `Expired` and `TryMarkExpiredAsync` was called exactly once (for the stale row only).
2. Run `--filter SecuredWrite` — FAIL.
3. In `HandleListSecuredWrites`, before returning: iterate the queried rows, `await TryExpireIfStaleAsync(sp, repo, row)` for each `Pending` row (helper self-filters by age; CAS makes the multi-node double-run harmless — the ManagementActor runs on every central node by design, so the sweep is deliberately lazy/opportunistic rather than a new timer singleton; document this in a comment).
4. PASS. 5. Commit: `git commit -m "feat(management): opportunistic expiry sweep on secured-write list (arch-review S2)"`
### Task 17: Secured-write list paging — command, handler, CLI
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (ManagementActor.cs; waits on Task 14)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SecuredWriteCommands.cs` (line 59 `ListSecuredWritesCommand`, line 83 `SecuredWriteListResult`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleListSecuredWrites`)
- Modify: CLI secured-write list command (locate: `grep -rl ListSecuredWritesCommand src/ZB.MOM.WW.ScadaBridge.CLI`) — add `--skip/--take`
- Modify: `docs/requirements/Component-ManagementService.md`, `docs/requirements/Component-CLI.md`
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests` (parse test)
1. Failing test: `ListSecuredWritesCommand(null, null, Skip: 10, Take: 25)` → repo `QueryAsync(null, null, 10, 25)` received; result JSON contains `totalCount` sourced from `CountAsync`.
2. Run `--filter SecuredWrite` — FAIL (record has no such params).
3. Implement (additive, defaults preserve today's wire behavior):
```csharp
public record ListSecuredWritesCommand(string? Status, string? SiteId, int Skip = 0, int Take = 200);
public record SecuredWriteListResult(IReadOnlyList<SecuredWriteDto> Items, int TotalCount);
```
Handler: clamp `var take = Math.Clamp(cmd.Take, 1, 500); var skip = Math.Max(0, cmd.Skip);`, `QueryAsync(cmd.Status, cmd.SiteId, skip, take)` + `CountAsync(cmd.Status, cmd.SiteId)`. Update the one UI construction site (`SecuredWriteService`) for the new result shape (compile will point at it). CLI: add the two int options.
4. PASS; `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests` (direct — not in slnx yet).
5. Commit: `git commit -m "feat(management): secured-write list paging with TotalCount (arch-review P3)"`
### Task 18: SecuredWrites page — history pager + age in the approve dialog + docs
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 19, 21, 26, 28, 29 (waits on Task 17)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor` (RefreshListsAsync ~line 303; confirm dialog ~line 342; history table)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ISecuredWriteService.cs` + `SecuredWriteService.cs` (paging params + TotalCount pass-through)
- Modify: `docs/requirements/Component-CentralUI.md` §Secured Writes (Expired is real + TTL + paging)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/` (bUnit — follow an existing page test's auth/service-fake setup)
1. Failing bUnit test: render the page with a fake `ISecuredWriteService` returning 60 terminal rows + `TotalCount = 120`; assert a "Next" pager button exists and clicking it requests the second page (`ListAsync(..., skip: 50, ...)` received). Second test: the approve confirmation dialog for a row submitted 26 hours ago renders text matching `Submitted .* ago` (age surfaced next to the value — the reviewer's stale-setpoint scenario).
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter SecuredWrites` — FAIL.
3. Implement: page-size 50 history pager (`_historyPage`, Prev/Next buttons disabled at bounds using TotalCount); dialog gains a `Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})` line. Pending list stays unpaged (bounded by TTL now).
4. PASS. 5. Doc + commit: `git commit -m "feat(ui): secured-write history paging + submission age in approve dialog (arch-review S2/P3)"`
### Task 19: Per-command long-running Ask timeout
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 1, 3, 4, 6, 14, 21, 26, 29
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementServiceOptions.cs` (add `LongRunningCommandTimeout`, default 5 min)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs` (lines 1833 `ResolveAskTimeout`; line 101 call site)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementEndpointsTests.cs`
1. Failing tests:
```csharp
[Theory]
[InlineData(typeof(ImportBundleCommand))]
[InlineData(typeof(ExportBundleCommand))]
[InlineData(typeof(PreviewBundleCommand))]
[InlineData(typeof(MgmtDeployArtifactsCommand))]
[InlineData(typeof(MgmtDeployInstanceCommand))]
public void ResolveAskTimeout_LongRunningCommand_UsesLongTimeout(Type cmd)
=> Assert.Equal(TimeSpan.FromMinutes(5), ManagementEndpoints.ResolveAskTimeout(null, cmd));
[Fact]
public void ResolveAskTimeout_OrdinaryCommand_UsesDefault()
=> Assert.Equal(TimeSpan.FromSeconds(30), ManagementEndpoints.ResolveAskTimeout(null, typeof(ListTemplatesCommand)));
[Fact]
public void ResolveAskTimeout_ConfiguredLongTimeout_Honored()
=> Assert.Equal(TimeSpan.FromMinutes(10), ManagementEndpoints.ResolveAskTimeout(
new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromMinutes(10) },
typeof(ImportBundleCommand)));
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ResolveAskTimeout` — FAIL.
3. Implement:
```csharp
private static readonly TimeSpan DefaultLongRunningTimeout = TimeSpan.FromMinutes(5);
/// <summary>Transport/deploy commands legitimately run for minutes (the CLI already
/// uses a 5-minute client timeout for bundles); a 30 s server-side Ask 504s while the
/// piped ProcessCommand keeps applying — the caller then retries and double-applies
/// (arch-review S1). These command types get LongRunningCommandTimeout instead.</summary>
private static readonly HashSet<Type> LongRunningCommands =
[
typeof(ImportBundleCommand), typeof(PreviewBundleCommand), typeof(ExportBundleCommand),
typeof(MgmtDeployArtifactsCommand), typeof(MgmtDeployInstanceCommand),
];
public static TimeSpan ResolveAskTimeout(ManagementServiceOptions? options, Type commandType)
=> LongRunningCommands.Contains(commandType)
? (options is { LongRunningCommandTimeout.Ticks: > 0 } o ? o.LongRunningCommandTimeout : DefaultLongRunningTimeout)
: ResolveAskTimeout(options);
```
Call site: `ResolveAskTimeout(options, command.GetType())`. Keep the existing single-arg overload (used by other callers/tests).
4. PASS + full ManagementEndpointsTests. 5. Commit: `git commit -m "fix(management): 5-min Ask timeout for transport/deploy commands (arch-review S1)"`
### Task 20: Ship `ManagementService` timeout config + doc
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** 18, 21, 26, 28, 29 (waits on Task 19)
**Files:**
- Modify: `docker/central-node-a/appsettings.Central.json`, `docker/central-node-b/appsettings.Central.json`, the two `docker-env2` central-node appsettings (glob `docker-env2/central-node-*/appsettings.Central.json`), `deploy/wonder-app-vd03/appsettings.Central.json`
- Modify: `docs/requirements/Component-ManagementService.md` §Configuration (~line 247)
1. Add under the `"ScadaBridge"` object in each central-node config:
```json
"ManagementService": {
"CommandTimeout": "00:00:30",
"LongRunningCommandTimeout": "00:05:00"
}
```
2. Extend the doc's configuration table row with `LongRunningCommandTimeout` (TimeSpan, default 5 min, applied to ImportBundle/PreviewBundle/ExportBundle/MgmtDeployArtifacts/MgmtDeployInstance).
3. Validate each edited JSON (`python3 -c ...` as in Task 3). Cluster-runtime change: rebuild with `bash docker/deploy.sh` when convenient (config-only; note in commit).
4. Commit: `git commit -m "chore(deploy): ship ManagementService command-timeout config to all central-node topologies (arch-review S1)"`
### Task 21: Browse/Verify/Cert commands gain optional `SiteIdentifier`
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** 1, 3, 4, 6, 14, 19, 26, 29
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs` (`BrowseNodeCommand` :24, `SearchAddressSpaceCommand` :62), `VerifyEndpointCommands.cs` (`VerifyEndpointCommand` :102), `CertTrustCommands.cs` (`TrustServerCertCommand` :192, `RemoveServerCertCommand` :199, `ListServerCertsCommand` :206)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/` (serialization round-trip)
1. Failing test (Commons.Tests): deserialize legacy JSON without the field → `SiteIdentifier == null`; round-trip with the field set survives. (Use `System.Text.Json` with `PropertyNameCaseInsensitive = true`, matching `ManagementEndpoints.ParseCommand`.)
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter SiteIdentifier` — FAIL.
3. Append an optional trailing positional param to each of the six records, e.g.:
```csharp
public record BrowseNodeCommand(
string ConnectionName, string? ParentNodeId, string? ContinuationToken,
string? SiteIdentifier = null);
```
xmldoc: "Target site identifier — REQUIRED when the command is invoked via the management HTTP API/CLI (the actor must route it to a site); ignored on the site side, where routing already happened. Additive per the message-contract evolution rules." Existing positional construction sites (UI services, Communication) compile unchanged.
4. PASS + `dotnet build ZB.MOM.WW.ScadaBridge.slnx`. 5. Commit: `git commit -m "feat(commons): additive SiteIdentifier on browse/verify/cert-trust commands for management-API routing (arch-review C4)"`
### Task 22: Actor handlers — BrowseNode / SearchAddressSpace / VerifyEndpoint
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (ManagementActor.cs; waits on Tasks 10, 21)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (dispatch switch ~:398 "Remote Queries" region; `GetRequiredRoles` Designer arm; new handlers near `HandleQueryEventLogs` ~:2753)
- Modify: `docs/requirements/Component-ManagementService.md` §Remote Queries (note the commands are now actor-dispatched — the doc's existing description at lines 204207 becomes true)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`
1. Failing tests:
```csharp
[Fact]
public void BrowseNode_WithoutSiteIdentifier_ReturnsError()
{
var actor = CreateActor();
actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null), "Designer"));
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("SiteIdentifier", resp.Error);
}
[Fact]
public void BrowseNode_Designer_RelaysToSite()
{
var actor = CreateActor(); // CommunicationService fake per SecuredWriteHandlerTests' pattern
actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_commService.Received(1).BrowseNodeAsync("SITE1", Arg.Any<BrowseNodeCommand>(), Arg.Any<CancellationToken>());
}
[Fact]
public void BrowseNode_ViewerOnly_Unauthorized() { /* → ManagementUnauthorized */ }
```
2. Run `--filter BrowseNode` — FAIL (falls through to NotSupportedException today).
3. Implement three handlers following `HandleQueryEventLogs`'s shape:
```csharp
private static async Task<object?> HandleBrowseNode(IServiceProvider sp, BrowseNodeCommand cmd, AuthenticatedUser user)
{
var site = cmd.SiteIdentifier
?? throw new ManagementCommandException("SiteIdentifier is required when browsing via the management API.");
await EnforceSiteScopeForIdentifier(sp, user, site);
var comm = sp.GetRequiredService<CommunicationService>();
return await comm.BrowseNodeAsync(site, cmd);
}
```
(same for `HandleSearchAddressSpace``SearchAddressSpaceAsync`, `HandleVerifyEndpoint``VerifyEndpointAsync`; match the real `CommunicationService` signatures at :366/:388/:410). Dispatch arms in the Remote Queries region; `GetRequiredRoles`: add the three commands to the Designer arm (doc line 204/206 already says Design). Update the Task 13 matrix table if it already exists.
4. PASS + full run. 5. Doc + commit: `git commit -m "feat(management): actor dispatch for BrowseNode/SearchAddressSpace/VerifyEndpoint — CLI parity (arch-review C4)"`
### Task 23: Actor handlers — TrustServerCert / RemoveServerCert / ListServerCerts (Admin)
**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** none (ManagementActor.cs; waits on Tasks 10, 21)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (dispatch + `GetRequiredRoles` Admin arm + three handlers)
- Modify: `docs/requirements/Component-ManagementService.md` (line 218's Admin gating is now enforced at the actor)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`
1. Failing tests mirroring Task 22: Designer-only caller gets `ManagementUnauthorized` for `TrustServerCertCommand`; Admin with `SiteIdentifier` relays via `CommunicationService.TrustServerCertAsync`; missing `SiteIdentifier` → curated error.
2. Run `--filter ServerCert` — FAIL.
3. Implement three handlers (delegate to `TrustServerCertAsync`/`RemoveServerCertAsync`/`ListServerCertsAsync` at CommunicationService :435/:477/:456); `GetRequiredRoles`: add the three cert commands to the Administrator arm. Comment: "Mutates the site's trusted-peer PKI store — Admin, matching the Admin-gated UI cert page. Site-side reconcile semantics are owned by the site CertStoreActor (plan 03); this handler only relays."
4. PASS + full run. 5. Doc + commit: `git commit -m "feat(management): Admin-gated actor dispatch for cert-trust commands (arch-review C4)"`
### Task 24: Dispatch-coverage test — every registered command reaches a handler
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 18, 20, 25, 26, 28, 29 (waits on Tasks 22, 23)
**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs`
1. Write the test: for every `ManagementCommandRegistry`-registered type (except a documented exclusion list: `ResolveRolesCommand` — intentionally undispatched, see ManagementActor :418), send an envelope carrying `RuntimeHelpers.GetUninitializedObject(type)` with ALL roles to an actor whose `IServiceProvider` is a substitute wired so `CreateScope()` yields a scoped provider that throws a `SentinelException` from `GetService` (mock `IServiceScopeFactory`/`IServiceScope`). Capture the exception MapFault logs via a recording `ILogger<ManagementActor>` (small test logger that stores `LogError` exceptions keyed by correlation id). Assert: for no command is the captured exception a `NotSupportedException` (which is the "no dispatch arm" signature). Commands that succeed without touching the provider (e.g. `GetHealthSummaryCommand`) simply produce no captured exception — also a pass.
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DispatchCoverageTests` — should PASS immediately after Tasks 22/23; if it fails, the failure list IS the remaining C4-style gap — add the missing arms or (with justification) the exclusion entry.
3. Commit: `git commit -m "test(management): dispatch-coverage test — every registered command has a handler (arch-review UA2)"`
### Task 25: CLI parity — browse / search / verify-endpoint / certs commands
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 18, 20, 24, 26, 28, 29 (waits on Tasks 22, 23)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/ConnectionBrowseCommands.cs`
- Modify: CLI `Program.cs` (register the group), `src/ZB.MOM.WW.ScadaBridge.CLI/README.md`, `docs/requirements/Component-CLI.md`
- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/` (new `ConnectionBrowseCommandsTests.cs`)
1. Failing parse tests (mirror an existing CLI.Tests command-parse test): `data-connection browse --site SITE1 --connection conn1 --node-id ns=2;s=X` parses and builds a `BrowseNodeCommand` with `SiteIdentifier == "SITE1"`; same for `search` (`--query --max-depth --max-results`), `verify-endpoint` (`--protocol --config-file <path>` reading ConfigJson from the file), `certs list`, `certs trust --der-file <path> --thumbprint <tp>` (base64-encodes the file), `certs remove --thumbprint`.
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests --filter ConnectionBrowse` — FAIL.
3. Implement the command group with `CommandHelpers.ExecuteCommandAsync` like `SiteCommands.BuildDeployArtifacts`; wire under the existing `data-connection` group. Update README + Component-CLI.md command tables.
4. PASS: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests` (direct — remember the slnx gotcha until Task 33).
5. Commit: `git commit -m "feat(cli): browse/search/verify-endpoint/cert-trust commands — actor parity (arch-review C4)"`
### Task 26: `LoginThrottle` — fixed-window LDAP-bind failure lockout
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** 1, 3, 4, 6, 14, 19, 21, 29
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Security/LoginThrottle.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs` (three additive settings + validator rules)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs` (`services.AddSingleton<LoginThrottle>();` in `AddSecurity`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs` (create)
1. Failing tests (drive time with a fake `TimeProvider` — the codebase already injects `TimeProvider` for `CookieSessionValidator`; reuse that pattern):
```csharp
[Fact]
public void FiveFailuresInWindow_LocksOut_ThenWindowExpiryUnlocks()
{
var clock = new FakeTimeProvider(); // Microsoft.Extensions.TimeProvider.Testing, already used by Security.Tests — verify, else hand-roll
var throttle = new LoginThrottle(clock, Options.Create(new SecurityOptions()));
for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1");
Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
Assert.False(throttle.IsLockedOut("alice", "10.0.0.2")); // per username+IP key
Assert.False(throttle.IsLockedOut("bob", "10.0.0.1"));
clock.Advance(TimeSpan.FromMinutes(6)); // lockout window passed
Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
}
[Fact]
public void SuccessResets() { /* 4 failures + RecordSuccess → not locked after a 5th failure */ }
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter LoginThrottleTests` — FAIL.
3. Implement: `ConcurrentDictionary<string, (int Count, DateTimeOffset WindowStart, DateTimeOffset? LockedUntil)>` keyed `{username.ToLowerInvariant()}|{ip}`; fixed window; opportunistic pruning of expired entries on write; bounded (drop-oldest above e.g. 10 000 keys to cap memory under spray). `SecurityOptions` additive: `MaxLoginFailuresPerWindow = 5`, `LoginFailureWindowMinutes = 5`, `LoginLockoutMinutes = 5` (xmldoc: "0 disables throttling"); extend `SecurityOptionsValidator` (non-negative). Register singleton in `AddSecurity`.
4. PASS + full Security.Tests. 5. Commit: `git commit -m "feat(security): LoginThrottle — per-username+IP LDAP-bind failure lockout (arch-review P1)"`
### Task 27: Wire the throttle — ManagementAuthenticator + auth endpoints + doc
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (waits on Task 26)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementAuthenticator.cs` (`AuthenticateAsync`, lines 101115)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs` (`/auth/login` + `/auth/token`, before the LDAP bind)
- Modify: `docs/requirements/Component-Security.md` (new "Login throttling" subsection: scope = every LDAP-bind surface — `/auth/login`, `/auth/token`, `POST /management`, audit REST, debug hub, the last three via ManagementAuthenticator)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorTests.cs`
1. Failing test (existing harness builds a `DefaultHttpContext` with `RequestServices`; add `LoginThrottle` + real `TimeProvider` to its service collection):
```csharp
[Fact]
public async Task AuthenticateAsync_LockedOutKey_Returns429WithoutLdapBind()
{
var throttle = GetThrottleFromServices();
for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "127.0.0.1");
var context = BasicAuthContext("alice", "wrong"); // helper: sets Authorization + Connection.RemoteIpAddress
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
Assert.Null(outcome.User);
// execute outcome.Failure and assert 429 + code AUTH_THROTTLED
await _ldapAuth.DidNotReceiveWithAnyArgs().AuthenticateAsync(default!, default!, default);
}
[Fact]
public async Task AuthenticateAsync_FailedBind_RecordsFailure() { /* bind fails → next 4 failures lock out */ }
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ManagementAuthenticator` — FAIL.
3. Implement in `AuthenticateAsync` after username/password extraction, before the bind:
```csharp
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
if (throttle.IsLockedOut(username, remoteIp))
{
return new AuthOutcome(null, Results.Json(
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
statusCode: 429));
}
```
After the bind: `if (!authResult.Succeeded) { throttle.RecordFailure(username, remoteIp); ...existing 401... }` else `throttle.RecordSuccess(username, remoteIp);`. Same three lines in `/auth/login` (locked → `Redirect("/login?error=Too+many+failed+attempts.+Try+again+later.")`) and `/auth/token` (429 JSON). The DisableLogin bypass path stays above the throttle (dev only, guarded by Task 1).
4. PASS + full ManagementService.Tests + CentralUI.Tests (auth endpoint tests if present).
5. Doc + commit: `git commit -m "feat(security): wire LoginThrottle into management Basic-Auth and /auth/login|token — password-spray guard (arch-review P1/UA5)"`
### Task 28: Additive paging for ListTemplates / ListInstances
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 18, 20, 24, 25, 29 (touches ManagementActor.cs — serialize with 5, 713, 1517, 22, 23, 30)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/` (the file declaring `ListTemplatesCommand` / `ListInstancesCommand` — locate via grep; add `int Skip = 0, int? Take = null`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleListTemplates` :507, `HandleListInstances` :718)
- Modify: CLI `template list` / `instance list` commands (add `--skip/--take`), `docs/requirements/Component-CLI.md`, `Component-ManagementService.md`
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests`
1. Failing test: repo returns 30 templates; `ListTemplatesCommand(Skip: 10, Take: 5)` yields exactly items 1115 (post-sort order preserved); `ListInstancesCommand` paging applies AFTER the site-scope filter (scoped user with 3 permitted-site instances and `Take: 2` sees 2 of *their* instances, never an out-of-scope one).
2. Run `--filter Paging` — FAIL.
3. Implement: `Take = null` ⇒ unlimited (today's behavior, so existing callers/UI are untouched); apply `.Skip(Math.Max(0, cmd.Skip)).Take(cmd.Take is > 0 and <= 1000 ? cmd.Take.Value : int.MaxValue)` after any in-memory scope filtering. CLI options pass through. Note in `Component-ManagementService.md` that `QueryDeployments`/`ExportBundle` full-table internals remain a logged scale follow-up (arch-review P2, deferred).
4. PASS (both test projects — CLI directly). 5. Commit: `git commit -m "feat(management): additive Skip/Take paging on template/instance lists (arch-review P2)"`
### Task 29: `PollGate` — non-reentrant poll timers on Health + Alarm Summary
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** 1, 3, 4, 6, 14, 19, 21, 26
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/PollGate.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor` (`StartTimer` ~line 280), `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor` (timer ~line 521)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/PollGateTests.cs` (create)
1. Failing tests:
```csharp
public class PollGateTests
{
[Fact]
public void TryEnter_SecondCallWhileHeld_ReturnsFalse()
{
var gate = new PollGate();
Assert.True(gate.TryEnter());
Assert.False(gate.TryEnter());
gate.Exit();
Assert.True(gate.TryEnter());
}
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter PollGateTests` — FAIL.
3. Implement:
```csharp
/// <summary>Reentrancy guard for poll timers: a fixed-period Timer whose refresh outlives
/// the period stacks overlapping fan-outs against an already-degraded site (arch-review S3).
/// Callers TryEnter at tick start and skip the tick when the previous refresh is in flight.</summary>
public sealed class PollGate
{
private int _inFlight;
public bool TryEnter() => Interlocked.CompareExchange(ref _inFlight, 1, 0) == 0;
public void Exit() => Volatile.Write(ref _inFlight, 0);
}
```
Wire both timers: `if (!_pollGate.TryEnter()) return;` at the top of the callback lambda, `try { await RefreshAsync(); StateHasChanged(); } finally { _pollGate.Exit(); }` inside the `InvokeAsync`.
4. PASS + `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests` (no page-test regressions).
5. Commit: `git commit -m "fix(ui): reentrancy guard on Health/AlarmSummary poll timers (arch-review S3)"`
### Task 30: Transport single-flight + base64 copy elimination
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (ManagementActor.cs)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleExportBundle` ~29022905, `HandlePreviewBundle`, `HandleImportBundle` ~2949)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/` (new `TransportGateTests.cs`)
1. Failing test: substitute `IBundleImporter` whose `LoadAsync` awaits a `TaskCompletionSource`; send two `ImportBundleCommand` envelopes; assert `LoadAsync` received exactly 1 call while the TCS is unset, then complete it and assert the second proceeds (both eventually answer).
2. Run `--filter TransportGate` — FAIL.
3. Implement:
```csharp
/// <summary>Single-flight gate for transport bundle commands: a 200 MB import holds
/// ~4 concurrent copies (base64 string, byte[], MemoryStream, JSON envelope), so two
/// concurrent imports on one central node are a realistic OOM path (arch-review S4).
/// Commands queue here instead of running concurrently on the thread pool.</summary>
private static readonly SemaphoreSlim TransportGate = new(1, 1);
```
Wrap the bodies of the three handlers in `await TransportGate.WaitAsync(); try { ... } finally { TransportGate.Release(); }`. In `HandleExportBundle`, replace `var bytes = ms.ToArray(); ... Convert.ToBase64String(bytes)` with `var base64 = Convert.ToBase64String(ms.GetBuffer().AsSpan(0, (int)ms.Length)); return new ExportBundleResult(base64, (int)ms.Length);` (one whole-artifact copy eliminated). Leave a comment that streaming multipart (like the audit export path, `AuditEndpoints.cs:183+`) is the deferred long-term shape.
4. PASS + full ManagementService.Tests + `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests` (no behavioral change expected).
5. Commit: `git commit -m "fix(management): single-flight transport bundle commands + drop redundant export copy (arch-review S4)"`
### Task 31: Low cleanup A — hub send fault logging (S5), event-log filter comment (C7), stale CLAUDE.md note (C8)
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** 32, 33 (and any non-overlapping task)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs` (~lines 204221)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (line 2762 comment), `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs` (xmldoc for `InstanceId`)
- Modify: `CLAUDE.md` (Security & Auth section, the SecuredWrite `SourceNode` parenthetical)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs`
1. **S5**: change the fire-and-forget send to log failures (keep fire-and-forget semantics — deliberate):
```csharp
_ = hubClients.Client(connectionId).SendAsync(method, payload).ContinueWith(
t => logger.LogWarning(t.Exception?.GetBaseException(),
"Debug stream push to connection {ConnectionId} failed", connectionId),
CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default);
```
(adapt to the hub's actual logger access; add a bUnit-free unit test in `DebugStreamHubTests` if the harness exposes the push path — otherwise verify by build + existing hub tests green).
2. **C7** (verified: the site event-log `instance_id` column stores the instance **UniqueName**`InstanceActor.LogLifecycleEvent` passes `_instanceUniqueName`, `EventLogQueryService.cs:107-111` matches on it): change the `ManagementActor.cs:2762` comment to `cmd.InstanceName, // instance unique-name filter — the site event log's instance_id column stores UniqueName (see InstanceActor.LogLifecycleEvent)`; add matching `<param>` xmldoc on `EventLogQueryRequest.InstanceId`.
3. **C8** (verified: `EmitSecuredWriteAuditAsync` routes through `ICentralAuditWriter` which stamps `SourceNode` — confirm at `ManagementActor.cs:999-1013` before editing): replace the CLAUDE.md parenthetical "(SecuredWrite audit rows currently leave `SourceNode` NULL — a logged follow-up.)" with "(SecuredWrite audit rows stamp `SourceNode` via `ICentralAuditWriter`/`INodeIdentityProvider`.)".
4. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` + `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests`.
5. Commit: `git commit -m "chore(management): log debug-stream push failures; fix event-log filter docs; refresh stale SourceNode note (arch-review S5/C7/C8)"`
### Task 32: Low cleanup B — Alarm Summary memoization (P4) + sortable-header a11y (UA6)
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** 31, 33
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor` (render body ~line 170; header markup ~lines 176179)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/` (extend the existing AlarmSummary page test if present, else create `AlarmSummaryRenderTests.cs`)
1. Failing bUnit test: rendered sortable `<th>` elements carry `tabindex="0"` and `aria-sort` (`ascending`/`descending` on the active column, `none` otherwise); pressing Enter on a header changes the sort (assert row order flips, mirroring the click test).
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary` — FAIL.
3. Implement: (a) **P4** — replace the in-render `FilteredRows().ToList()` with a cached `_visibleRows` field recomputed in `RefreshAsync` and in each filter/sort setter (one `RecomputeVisibleRows()` helper); (b) **UA6** — sortable `<th>`: add `tabindex="0"`, `aria-sort="@AriaSortFor(col)"`, `@onkeydown` handler toggling sort on `Enter`/`Space` (`@onkeydown:preventDefault` for Space). Leave a note that the same pattern applies to the other custom grids (deferred fleet-wide sweep, logged).
4. PASS. 5. Commit: `git commit -m "chore(ui): memoize AlarmSummary rows + keyboard/aria-sort on sortable headers (arch-review P4/UA6)"`
### Task 33: Add CLI.Tests to the slnx + retire the gotcha
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** 31, 32
**Files:**
- Modify: `ZB.MOM.WW.ScadaBridge.slnx` (tests ItemGroup, after line 59)
- Modify: `CLAUDE.md` (Editing Rules bullet about CLI.Tests exclusion; CLI Quick Reference untouched)
1. Add: `<Project Path="tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ZB.MOM.WW.ScadaBridge.CLI.Tests.csproj" />`
2. Verify: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` then `dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter FullyQualifiedName~CLI.Tests` — CLI tests now discovered and green. If the solution build breaks (an undocumented reason for the historical exclusion), revert and record the reason in CLAUDE.md instead — do not leave the solution red.
3. Update the CLAUDE.md Editing Rules bullet: CLI.Tests is now in the slnx; the "silently skipped" gotcha is retired (keep one sentence of history so old memory notes make sense). The user-memory note `cli-tests-not-in-slnx.md` becomes stale — flag it in your final report.
4. Commit: `git add ZB.MOM.WW.ScadaBridge.slnx CLAUDE.md && git commit -m "chore(build): include CLI.Tests in the solution — retire the silent-skip gotcha (arch-review UA3)"`
---
## Dependencies on other plans
- **Plan 01 (Cluster/Host/Failover):** also flags the `DisableLogin` artifact — **this plan owns the fix** (Tasks 13); plan 01 should not duplicate. General active-node/`/health/active` semantics stay with plan 01.
- **Plan 03 (Site Runtime & DCL):** cert-trust **site-side reconcile** (central persistence of trust, PKI-store consistency on failover) is plan 03's; Tasks 21/23 here only add the central relay + Admin gate and do not change site `CertStoreActor` behavior.
- **Plan 05 (Templates/Deployment/Transport):** bundle-import **idempotency on a client-supplied import id** (the other half of S1) belongs to the Transport apply path plan 05 is rewriting — coordinate so `HandleImportBundle` merges cleanly (this plan touches it only to add the Task 30 single-flight gate).
- **Plan 08 (Conventions/Tests):** UA4 (single deferred-work tracking list) is deferred to plan 08's consolidated inventory; Task 33 (slnx) is also listed in the overall report's P2-13 housekeeping — this plan does it, plan 08 should skip it.
## Execution order
**P0 (do first, in order):** Task 1 → 2 → 3 (Critical C1 — the auth-disabled artifact). Then the security-correctness wave: 4 → 5 (C2), 6 → 7 → 8 → 9 (C3), 10 (gate refactor) → 11, 12.
**Ordering constraints:** 2←1; 5←4; 7←6; 11←10; 13←(10,11,22,23); 15←14; 16←15; 17←14; 18←17; 20←19; 22←(10,21); 23←(10,21); 24←(22,23); 25←(22,23); 27←26.
**Serialize every task that edits `ManagementActor.cs`** (5, 7, 8, 9, 10, 11, 12, 15, 16, 17, 22, 23, 28, 30, 31) — do not run these concurrently even where logically independent. Tasks 1/3/4/6/14/19/21/26/29 are safe to parallelize early; 31/32/33 can land any time.
**Milestone verification:** after the P0 wave and again at plan completion, run `dotnet build ZB.MOM.WW.ScadaBridge.slnx`, `dotnet test ZB.MOM.WW.ScadaBridge.slnx`, **plus** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests` (until Task 33), and rebuild the docker cluster (`bash docker/deploy.sh`) for a CLI smoke (`--username multi-role --password password`: `deploy artifacts --site-id`, `data-connection get` secret elision, secured-write list gating, a browse call).
---
## Post-plan known issues
- **✅ RESOLVED 2026-07-09 — SecuredWrites bUnit render tests fixed (`IBrowseService` now registered in the test harness).** Fix applied: `SecuredWritesTests` now registers a `Substitute.For<IBrowseService>()` singleton in its constructor (mirroring the sibling `NodeBrowserDialogSearchTests`), so the embedded `NodeBrowserDialog` resolves its injected service at render time. `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter FullyQualifiedName~SecuredWritesTests`**7/7 passed, 0 failed**. Original report retained below for history.
~~Three `CentralUI.Tests/Components/SecuredWritesTests` tests fail at render:~~
- `Operator_SeesSubmitForm`
- `Operator_Submit_CallsServiceWithEnteredValues`
- `Verifier_OwnSubmission_HasApproveAndRejectDisabled`
Each throws `System.InvalidOperationException: Cannot provide a value for property 'BrowseService' on type 'ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs.NodeBrowserDialog'. There is no registered service of type 'ZB.MOM.WW.ScadaBridge.CentralUI.Services.IBrowseService'.`
**Root cause:** the Secured Writes page (`src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor`) renders `Components/Dialogs/NodeBrowserDialog.razor`, which `@inject`s `IBrowseService`. `SecuredWritesTests` registers the page's own collaborators (`_service`, `_siteRepo`, `_dialog`, auth) but not a fake `IBrowseService`, so rendering the embedded dialog throws. This is a **test-harness DI gap**, not a product defect — the page works at runtime where `IBrowseService` is registered.
**Not a PLAN-03 regression:** discovered during PLAN-03's milestone `dotnet test ZB.MOM.WW.ScadaBridge.slnx` but the failing files were last touched outside that session (`1a7e7351` page/test, `9cff87fe` dialog); PLAN-03 touches only Site Runtime / DCL. Cross-referenced in `00-MASTER-TRACKER.md` → "Pre-existing failure discovered during Wave 1 PLAN-03 execution".
**Fix (PLAN-07):** register a stub `IBrowseService` in the `SecuredWritesTests` `Services` collection before `RenderPage()` (mirror how `_service`/`_dialog` are added), or add it to the shared bUnit fixture so any page embedding `NodeBrowserDialog` renders. Then re-run `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter FullyQualifiedName~SecuredWritesTests` → green.