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).
90 KiB
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
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 (SecurityLoginThrottle), and T29 ↔ T32 (both editAlarmSummary.razor— serialize the pair). - Skip: T33 — CLI.Tests is already in the slnx via PLAN-08 T1 (done); mark
skipped-duplicatein the.tasks.json, don't execute.
See 00-MASTER-TRACKER.md § Parallelization Map 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 |
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(addAllowOutsideDevelopmentproperty) - Test:
tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginGuardTests.cs(create)
- Write the failing test:
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);
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter DisableLoginGuardTests— expect FAIL (type missing). - Implement
DisableLoginGuard.cs:
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:
/// <summary>Explicit second acknowledgement key: permits DisableLogin outside Development. Default false.</summary>
public bool AllowOutsideDevelopment { get; set; }
- Run the filter again — expect PASS. Run full project:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests. - 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 afterdisableLoginis 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
- In
Program.cs, after line 137 (GetValue<bool>(...DisableLogin)), insert:
// 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)));
- Update
Component-Security.md§Dev Disable-Login Flag: document the guard, theAllowOutsideDevelopmentack key, and fix the stale "all four roles" → "all six roles (Administrator, Designer, Deployer, Viewer, Operator, Verifier)" while in the section. dotnet build ZB.MOM.WW.ScadaBridge.slnx— expect clean.- Smoke (docker cluster is unaffected —
docker/docker-compose.ymlsetsASPNETCORE_ENVIRONMENT: Developmentand both central nodes haveDisableLogin: false): optionally run the Host locally withASPNETCORE_ENVIRONMENT=ProductionandScadaBridge__Security__Auth__DisableLogin=true, assert it exits with the guard message. - 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)
- Delete the block:
"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(refactorDeployToAllSitesAsync~lines 227–345) - Test:
tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/— extend the existingArtifactDeploymentService*test file (locate withgrep -rl ArtifactDeploymentService tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests)
- Write failing tests (mirror the existing harness's mocks for
ISiteRepository/CommunicationService):
[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);
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter DeployToSiteAsync— expect FAIL (no such method). - Implement: extract the body of
DeployToAllSitesAsyncafter thesitesfetch intoprivate async Task<Result<ArtifactDeploymentSummary>> DeployCoreAsync(IReadOnlyList<Site> sites, string user, CancellationToken ct)(everything fromdeploymentIdcreation down, unchanged). Then:
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 7–13, 15–18, 22, 23, 30) Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs(HandleDeployArtifactslines 2006–2013; dispatch arm ~line whereMgmtDeployArtifactsCommand cmd =>appears, currently passesuser.Username— passuser) - Modify:
docs/requirements/Component-ManagementService.md(deployment commands section),docs/requirements/Component-CLI.md+src/ZB.MOM.WW.ScadaBridge.CLI/README.md(--site-idsemantics: now honored, and fleet-wide requires system-wide deployment rights) - Test:
tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
- Write failing tests (using the existing
Envelope/ScopedEnvelopehelpers; register a substitutedArtifactDeploymentServicein_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 whateverSecuredWriteHandlerTestsdoes forCommunicationService):
[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);
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DeployArtifacts— expect FAIL. - Implement:
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)
- Failing tests:
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);
}
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ConfigSecretScrubberTests— FAIL. - Implement with
System.Text.Json.Nodes.JsonNoderecursion:
/// <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.
}
- 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 newSecretProjectionTests.cs)
- Failing tests (harness:
ISiteRepositorysubstitute returning aDataConnectionwhosePrimaryConfigurationcontains"Password":"opc-secret"):
[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:
/// <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 + returnDataConnectionPublicShape(conn).HandleUpdateDataConnection: before assignment,cmd = cmd with { }is unnecessary — merge directly:
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)
- Failing tests:
ListExternalSystems/GetExternalSystemresponse must not contain the mockedAuthConfigurationvalue (e.g."apiKey":"es-secret"), must containhasAuthConfiguration;UpdateExternalSystemwithAuthConfiguration = nullmust preserve the stored value; Create/Update audit afterState must not contain the secret. Same test pattern as Task 7. - Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ExternalSystem— FAIL. - Implement
ExternalSystemPublicShape:
/// <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 2526–2559) - Modify:
docs/requirements/Component-ManagementService.md - Test: same file as Tasks 7–8
- Failing tests: List/Get response contains
id/name/hasConnectionStringbut not the mocked connection string ("Server=db;Password=sql-secret"); Update withConnectionString = nullpreserves stored value. CheckUpdateDatabaseConnectionDefCommand.ConnectionStringnullability first — if non-nullable, make itstring?(additive JSON-wire-compatible). dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DatabaseConnection— FAIL.- 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; - PASS + full run. CLI note:
db-connectionlist/get output shape changes (no connection string) — updateComponent-CLI.md/CLI README sample output if shown. - 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(newRequireSecuredWriteAccesspolicy) - 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)
- Failing tests:
[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));
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ListSecuredWrites— FAIL (currently ungated). - 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 newSecuredWriteReaders = [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:
- Rename
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: addRequireSecuredWriteAccess=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).
- PASS + full ManagementService.Tests + Security.Tests runs.
- 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.
- Failing test:
[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 */ }
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter CreateArea— Deployer case FAILS today.- Move
CreateAreaCommand or UpdateAreaCommand or DeleteAreaCommandout of the Designer arm into a new arm=> AreaManagerswhereprivate 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. - 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
- Failing test:
[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
}
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter RoleMapping— FAIL.- Replace
ValidateMappingRolewith:
// 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
- Write the test (it IS the deliverable — no implementation change beyond the csproj line):
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));
}
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter RequiredRoleMatrixTests— FAIL until the table is complete andInternalsVisibleTo+internalvisibility (Task 10) are in place. - Fill the table to green. Any surprise found while filling (a command gated differently than
Component-ManagementService.mddocuments) gets fixed in this task — doc or gate, whichever is wrong — and noted in the commit message. - 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 viagrep -rl SecuredWriteRepository tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; mirror its harness — in-memory/SQLite or MSSQL fixture)
- Failing tests:
[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));
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests --filter "TryMarkExpired|CountAsync_Filters"— FAIL. - Add to the interface (xmldoc mirrors
TryMarkApprovedAsync's CAS wording — this is the multi-node-safe expiry primitive):
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(addSecuredWritePendingTtl, default 24 h) - Modify:
src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs(additive memberSecuredWriteExpire) - 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.mdSecurity & 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
- Failing tests (extend the existing
SecuredWriteHandlerTestsharness; registerIOptions<ManagementServiceOptions>in its service collection):
[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 */ }
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter SecuredWrite— new tests FAIL. - 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: addSecuredWriteExpire(additive enum member — message contracts are additive-only).- Shared helper in
ManagementActor:
- Options:
/// <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
HandleApproveSecuredWriteandHandleRejectSecuredWrite, 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.");(CheckAuditStatusmembers; ifDiscardedis inappropriate, use the closest terminal status the enum offers and note it.)
- 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
- Failing test: repo returns one fresh Pending + one 25h-old Pending;
ListSecuredWritesCommandresult marks the old oneExpiredandTryMarkExpiredAsyncwas called exactly once (for the stale row only). - Run
--filter SecuredWrite— FAIL. - In
HandleListSecuredWrites, before returning: iterate the queried rows,await TryExpireIfStaleAsync(sp, repo, row)for eachPendingrow (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). - 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 59ListSecuredWritesCommand, line 83SecuredWriteListResult) - 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)
- Failing test:
ListSecuredWritesCommand(null, null, Skip: 10, Take: 25)→ repoQueryAsync(null, null, 10, 25)received; result JSON containstotalCountsourced fromCountAsync. - Run
--filter SecuredWrite— FAIL (record has no such params). - Implement (additive, defaults preserve today's wire behavior):
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)
- Failing bUnit test: render the page with a fake
ISecuredWriteServicereturning 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 matchingSubmitted .* ago(age surfaced next to the value — the reviewer's stale-setpoint scenario). - Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter SecuredWrites— FAIL. - Implement: page-size 50 history pager (
_historyPage, Prev/Next buttons disabled at bounds using TotalCount); dialog gains aSubmitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})line. Pending list stays unpaged (bounded by TTL now). - 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(addLongRunningCommandTimeout, default 5 min) - Modify:
src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs(lines 18–33ResolveAskTimeout; line 101 call site) - Test:
tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementEndpointsTests.cs
- Failing tests:
[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)));
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ResolveAskTimeout— FAIL. - Implement:
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 twodocker-env2central-node appsettings (globdocker-env2/central-node-*/appsettings.Central.json),deploy/wonder-app-vd03/appsettings.Central.json - Modify:
docs/requirements/Component-ManagementService.md§Configuration (~line 247)
- Add under the
"ScadaBridge"object in each central-node config:
"ManagementService": {
"CommandTimeout": "00:00:30",
"LongRunningCommandTimeout": "00:05:00"
}
- Extend the doc's configuration table row with
LongRunningCommandTimeout(TimeSpan, default 5 min, applied to ImportBundle/PreviewBundle/ExportBundle/MgmtDeployArtifacts/MgmtDeployInstance). - Validate each edited JSON (
python3 -c ...as in Task 3). Cluster-runtime change: rebuild withbash docker/deploy.shwhen convenient (config-only; note in commit). - 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)
- Failing test (Commons.Tests): deserialize legacy JSON without the field →
SiteIdentifier == null; round-trip with the field set survives. (UseSystem.Text.JsonwithPropertyNameCaseInsensitive = true, matchingManagementEndpoints.ParseCommand.) - Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter SiteIdentifier— FAIL. - Append an optional trailing positional param to each of the six records, e.g.:
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;GetRequiredRolesDesigner arm; new handlers nearHandleQueryEventLogs~: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
- Failing tests:
[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 */ }
- Run
--filter BrowseNode— FAIL (falls through to NotSupportedException today). - Implement three handlers following
HandleQueryEventLogs's shape:
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 +GetRequiredRolesAdmin 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
- Failing tests mirroring Task 22: Designer-only caller gets
ManagementUnauthorizedforTrustServerCertCommand; Admin withSiteIdentifierrelays viaCommunicationService.TrustServerCertAsync; missingSiteIdentifier→ curated error. - Run
--filter ServerCert— FAIL. - Implement three handlers (delegate to
TrustServerCertAsync/RemoveServerCertAsync/ListServerCertsAsyncat 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." - 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
- Write the test: for every
ManagementCommandRegistry-registered type (except a documented exclusion list:ResolveRolesCommand— intentionally undispatched, see ManagementActor :418), send an envelope carryingRuntimeHelpers.GetUninitializedObject(type)with ALL roles to an actor whoseIServiceProvideris a substitute wired soCreateScope()yields a scoped provider that throws aSentinelExceptionfromGetService(mockIServiceScopeFactory/IServiceScope). Capture the exception MapFault logs via a recordingILogger<ManagementActor>(small test logger that storesLogErrorexceptions keyed by correlation id). Assert: for no command is the captured exception aNotSupportedException(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. - 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. - 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/(newConnectionBrowseCommandsTests.cs)
- Failing parse tests (mirror an existing CLI.Tests command-parse test):
data-connection browse --site SITE1 --connection conn1 --node-id ns=2;s=Xparses and builds aBrowseNodeCommandwithSiteIdentifier == "SITE1"; same forsearch(--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. - Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests --filter ConnectionBrowse— FAIL. - Implement the command group with
CommandHelpers.ExecuteCommandAsynclikeSiteCommands.BuildDeployArtifacts; wire under the existingdata-connectiongroup. Update README + Component-CLI.md command tables. - PASS:
dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests(direct — remember the slnx gotcha until Task 33). - 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>();inAddSecurity) - Test:
tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs(create)
- Failing tests (drive time with a fake
TimeProvider— the codebase already injectsTimeProviderforCookieSessionValidator; reuse that pattern):
[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 */ }
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter LoginThrottleTests— FAIL. - 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).SecurityOptionsadditive:MaxLoginFailuresPerWindow = 5,LoginFailureWindowMinutes = 5,LoginLockoutMinutes = 5(xmldoc: "0 disables throttling"); extendSecurityOptionsValidator(non-negative). Register singleton inAddSecurity. - 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
- Failing test (existing harness builds a
DefaultHttpContextwithRequestServices; addLoginThrottle+ realTimeProviderto its service collection):
[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 */ }
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ManagementAuthenticator— FAIL. - Implement in
AuthenticateAsyncafter username/password extraction, before the bind:
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, 7–13, 15–17, 22, 23, 30) Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/(the file declaringListTemplatesCommand/ListInstancesCommand— locate via grep; addint Skip = 0, int? Take = null) - Modify:
src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs(HandleListTemplates:507,HandleListInstances:718) - Modify: CLI
template list/instance listcommands (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
- Failing test: repo returns 30 templates;
ListTemplatesCommand(Skip: 10, Take: 5)yields exactly items 11–15 (post-sort order preserved);ListInstancesCommandpaging applies AFTER the site-scope filter (scoped user with 3 permitted-site instances andTake: 2sees 2 of their instances, never an out-of-scope one). - Run
--filter Paging— FAIL. - 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 inComponent-ManagementService.mdthatQueryDeployments/ExportBundlefull-table internals remain a logged scale follow-up (arch-review P2, deferred). - 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)
- Failing tests:
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());
}
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter PollGateTests— FAIL. - Implement:
/// <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~2902–2905,HandlePreviewBundle,HandleImportBundle~2949) - Test:
tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/(newTransportGateTests.cs)
- Failing test: substitute
IBundleImporterwhoseLoadAsyncawaits aTaskCompletionSource; send twoImportBundleCommandenvelopes; assertLoadAsyncreceived exactly 1 call while the TCS is unset, then complete it and assert the second proceeds (both eventually answer). - Run
--filter TransportGate— FAIL. - Implement:
/// <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 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 forInstanceId) - Modify:
CLAUDE.md(Security & Auth section, the SecuredWriteSourceNodeparenthetical) - Test:
tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs
- S5: change the fire-and-forget send to log failures (keep fire-and-forget semantics — deliberate):
_ = 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 176–179) - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/(extend the existing AlarmSummary page test if present, else createAlarmSummaryRenderTests.cs)
- Failing bUnit test: rendered sortable
<th>elements carrytabindex="0"andaria-sort(ascending/descendingon the active column,noneotherwise); pressing Enter on a header changes the sort (assert row order flips, mirroring the click test). - Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary— FAIL. - Implement: (a) P4 — replace the in-render
FilteredRows().ToList()with a cached_visibleRowsfield recomputed inRefreshAsyncand in each filter/sort setter (oneRecomputeVisibleRows()helper); (b) UA6 — sortable<th>: addtabindex="0",aria-sort="@AriaSortFor(col)",@onkeydownhandler toggling sort onEnter/Space(@onkeydown:preventDefaultfor Space). Leave a note that the same pattern applies to the other custom grids (deferred fleet-wide sweep, logged). - 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)
- Add:
<Project Path="tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ZB.MOM.WW.ScadaBridge.CLI.Tests.csproj" /> - Verify:
dotnet build ZB.MOM.WW.ScadaBridge.slnxthendotnet 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. - 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.mdbecomes stale — flag it in your final report. - 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
DisableLoginartifact — this plan owns the fix (Tasks 1–3); plan 01 should not duplicate. General active-node//health/activesemantics 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
CertStoreActorbehavior. - 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
HandleImportBundlemerges 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 (
IBrowseServicenow registered in the test harness). Fix applied:SecuredWritesTestsnow registers aSubstitute.For<IBrowseService>()singleton in its constructor (mirroring the siblingNodeBrowserDialogSearchTests), so the embeddedNodeBrowserDialogresolves 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.ThreeCentralUI.Tests/Components/SecuredWritesTeststests fail at render:Operator_SeesSubmitFormOperator_Submit_CallsServiceWithEnteredValuesVerifier_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) rendersComponents/Dialogs/NodeBrowserDialog.razor, which@injectsIBrowseService.SecuredWritesTestsregisters the page's own collaborators (_service,_siteRepo,_dialog, auth) but not a fakeIBrowseService, so rendering the embedded dialog throws. This is a test-harness DI gap, not a product defect — the page works at runtime whereIBrowseServiceis registered.Not a PLAN-03 regression: discovered during PLAN-03's milestone
dotnet test ZB.MOM.WW.ScadaBridge.slnxbut the failing files were last touched outside that session (1a7e7351page/test,9cff87fedialog); PLAN-03 touches only Site Runtime / DCL. Cross-referenced in00-MASTER-TRACKER.md→ "Pre-existing failure discovered during Wave 1 PLAN-03 execution".Fix (PLAN-07): register a stub
IBrowseServicein theSecuredWritesTestsServicescollection beforeRenderPage()(mirror how_service/_dialogare added), or add it to the shared bUnit fixture so any page embeddingNodeBrowserDialogrenders. Then re-rundotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter FullyQualifiedName~SecuredWritesTests→ green.