# 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/` — **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 T1–T3 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, 14–18; 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( () => 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; /// /// Startup guard for the dev/test 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 . /// Called by the Host composition root before AddSecurity(); fail-fast at boot. /// 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 /// Explicit second acknowledgement key: permits DisableLogin outside Development. Default false. 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 130–138, 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(...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(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 39–42 `"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 227–345) - 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(), Arg.Any()); await _communicationService.DidNotReceive().DeployArtifactsAsync( "SITE2", Arg.Any(), Arg.Any()); } [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> DeployCoreAsync(IReadOnlyList sites, string user, CancellationToken ct)` (everything from `deploymentId` creation down, unchanged). Then: ```csharp public async Task> DeployToAllSitesAsync( string user, CancellationToken cancellationToken = default) { var sites = await _siteRepo.GetAllSitesAsync(cancellationToken); if (sites.Count == 0) return Result.Failure("No sites configured."); return await DeployCoreAsync(sites, user, cancellationToken); } /// Deploys system-wide artifacts to a single site (management-API --site-id path). public async Task> DeployToSiteAsync( int siteId, string user, CancellationToken cancellationToken = default) { var site = await _siteRepo.GetSiteByIdAsync(siteId, cancellationToken); if (site is null) return Result.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 7–13, 15–18, 22, 23, 30) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleDeployArtifacts` lines 2006–2013; 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(TimeSpan.FromSeconds(5)); _artifactService.Received(1).DeployToSiteAsync(7, "testuser", Arg.Any()); } [Fact] public void DeployArtifacts_SiteScopedUser_OutOfScopeSite_Unauthorized() { var actor = CreateActor(); actor.Tell(ScopedEnvelope(new MgmtDeployArtifactsCommand(SiteId: 7), ["3"], "Deployer")); ExpectMsg(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(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 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(); 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 /// /// 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. /// 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 1401–1456: `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(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(TimeSpan.FromSeconds(5)); await _siteRepo.Received(1).UpdateDataConnectionAsync( Arg.Is(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(TimeSpan.FromSeconds(5)); // AuditAsync serializes afterState via IAuditService — assert the captured object graph has no "newpw" _auditService.Received().LogAsync(Arg.Any(), "Update", "DataConnection", Arg.Any(), Arg.Any(), Arg.Is(o => !ManagementActor.SerializeResult(o).Contains("newpw")), Arg.Any()); } ``` (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 /// 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. 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 1587–1625) - 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 /// Secret-elided ExternalSystemDefinition projection: AuthConfiguration /// (API keys / Basic credentials) never leaves this boundary. Mirrors SmtpConfigPublicShape. 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 2526–2559) - Modify: `docs/requirements/Component-ManagementService.md` - Test: same file as Tasks 7–8 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 97–242: `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(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(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? 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 1904–1944 — do not run concurrently with other ManagementActor tasks) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (lines 1904–1944: `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(TimeSpan.FromSeconds(5)); await _securityRepo.Received(1).AddMappingAsync( Arg.Is(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 ``) - 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 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 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 TryMarkExpiredAsync(long id, DateTime expiredAtUtc, CancellationToken ct = default); Task 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` 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()).Returns(true); var actor = CreateActor(); actor.Tell(Envelope(new ApproveSecuredWriteCommand(1, null), "Verifier")); var resp = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Contains("expired", resp.Error, StringComparison.OrdinalIgnoreCase); _securedWriteRepo.DidNotReceive().TryMarkApprovedAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); _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: `/// 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. public TimeSpan SecuredWritePendingTtl { get; set; } = TimeSpan.FromHours(24);` - `AuditKind`: add `SecuredWriteExpire` (additive enum member — message contracts are additive-only). - Shared helper in `ManagementActor`: ```csharp /// 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). private static async Task TryExpireIfStaleAsync( IServiceProvider sp, ISecuredWriteRepository repo, PendingSecuredWrite row) { var ttl = sp.GetService>()?.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 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 18–33 `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); /// 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. private static readonly HashSet 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 204–207 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(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(TimeSpan.FromSeconds(5)); _commService.Received(1).BrowseNodeAsync("SITE1", Arg.Any(), Arg.Any()); } [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 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(); 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` (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 ` reading ConfigJson from the file), `certs list`, `certs trust --der-file --thumbprint ` (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();` 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` 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 101–115) - 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(); 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, 7–13, 15–17, 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 11–15 (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 /// 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. 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` ~2902–2905, `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 /// 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. 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 204–221) - 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 `` 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 176–179) - 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 `` 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 ``: 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: `` 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 1–3); 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()` 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.