1263 lines
86 KiB
Markdown
1263 lines
86 KiB
Markdown
# 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.
|
||
|
||
---
|
||
|
||
## 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<InvalidOperationException>(
|
||
() => DisableLoginGuard.Validate(disableLogin: true, environmentName: env, allowOutsideDevelopment: false));
|
||
Assert.Contains("DisableLogin", ex.Message);
|
||
Assert.Contains("Development", ex.Message);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("Development", false)] // dev env: allowed without ack
|
||
[InlineData("development", false)] // case-insensitive env name
|
||
[InlineData("Production", true)] // explicit second ack key
|
||
public void Validate_Allowed_DoesNotThrow(string env, bool ack)
|
||
=> DisableLoginGuard.Validate(true, env, ack);
|
||
|
||
[Fact]
|
||
public void Validate_FlagOff_NeverThrows()
|
||
=> DisableLoginGuard.Validate(false, "Production", false);
|
||
}
|
||
```
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter DisableLoginGuardTests` — expect FAIL (type missing).
|
||
3. Implement `DisableLoginGuard.cs`:
|
||
```csharp
|
||
namespace ZB.MOM.WW.ScadaBridge.Security.Auth;
|
||
|
||
/// <summary>
|
||
/// Startup guard for the dev/test <see cref="AuthDisableLoginOptions.DisableLogin"/> flag.
|
||
/// DisableLogin authenticates EVERY request with ALL roles (including Operator+Verifier,
|
||
/// nullifying the two-person secured-write control) on a SCADA control surface, so it is
|
||
/// refused outside the Development environment unless the operator sets the explicit
|
||
/// second acknowledgement key <see cref="AuthDisableLoginOptions.AllowOutsideDevelopment"/>.
|
||
/// Called by the Host composition root before AddSecurity(); fail-fast at boot.
|
||
/// </summary>
|
||
public static class DisableLoginGuard
|
||
{
|
||
public static void Validate(bool disableLogin, string environmentName, bool allowOutsideDevelopment)
|
||
{
|
||
if (!disableLogin) return;
|
||
if (string.Equals(environmentName, "Development", StringComparison.OrdinalIgnoreCase)) return;
|
||
if (allowOutsideDevelopment) return;
|
||
throw new InvalidOperationException(
|
||
"ScadaBridge:Security:Auth:DisableLogin=true disables ALL authentication on a SCADA control " +
|
||
$"surface and is refused outside the Development environment (current: '{environmentName}'). " +
|
||
"Remove the flag, run with ASPNETCORE_ENVIRONMENT=Development, or — only if you fully accept an " +
|
||
"unauthenticated deployment — additionally set " +
|
||
"ScadaBridge:Security:Auth:AllowOutsideDevelopment=true.");
|
||
}
|
||
}
|
||
```
|
||
Add to `AuthDisableLoginOptions`:
|
||
```csharp
|
||
/// <summary>Explicit second acknowledgement key: permits DisableLogin outside Development. Default false.</summary>
|
||
public bool AllowOutsideDevelopment { get; set; }
|
||
```
|
||
4. Run the filter again — expect PASS. Run full project: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests`.
|
||
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Security/Auth tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginGuardTests.cs && git commit -m "feat(security): DisableLoginGuard refuses DisableLogin outside Development without explicit ack"`
|
||
|
||
### Task 2: Wire the guard at the Host composition root + doc
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (waits on Task 1)
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (~lines 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<bool>(...DisableLogin)`), insert:
|
||
```csharp
|
||
// Fail-fast guard: DisableLogin outside Development requires the explicit
|
||
// AllowOutsideDevelopment acknowledgement key. See DisableLoginGuard.
|
||
ZB.MOM.WW.ScadaBridge.Security.Auth.DisableLoginGuard.Validate(
|
||
disableLogin,
|
||
builder.Environment.EnvironmentName,
|
||
builder.Configuration
|
||
.GetSection(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName)
|
||
.GetValue<bool>(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.AllowOutsideDevelopment)));
|
||
```
|
||
2. Update `Component-Security.md` §Dev Disable-Login Flag: document the guard, the `AllowOutsideDevelopment` ack key, and fix the stale "all four roles" → "all six roles (Administrator, Designer, Deployer, Viewer, Operator, Verifier)" while in the section.
|
||
3. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — expect clean.
|
||
4. Smoke (docker cluster is unaffected — `docker/docker-compose.yml` sets `ASPNETCORE_ENVIRONMENT: Development` and both central nodes have `DisableLogin: false`): optionally run the Host locally with `ASPNETCORE_ENVIRONMENT=Production` and `ScadaBridge__Security__Auth__DisableLogin=true`, assert it exits with the guard message.
|
||
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Host/Program.cs docs/requirements/Component-Security.md && git commit -m "feat(host): fail-fast DisableLogin environment guard at composition root"`
|
||
|
||
### Task 3: Scrub `deploy/wonder-app-vd03` — remove `DisableLogin: true`
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** 1, 4, 6, 14, 19, 21, 26, 29
|
||
**Files:**
|
||
- Modify: `deploy/wonder-app-vd03/appsettings.Central.json` (lines 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<DeployArtifactsCommand>(), Arg.Any<CancellationToken>());
|
||
await _communicationService.DidNotReceive().DeployArtifactsAsync(
|
||
"SITE2", Arg.Any<DeployArtifactsCommand>(), Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task DeployToSiteAsync_UnknownSite_ReturnsFailure()
|
||
{
|
||
var result = await _service.DeployToSiteAsync(999, "tester");
|
||
Assert.False(result.IsSuccess);
|
||
Assert.Contains("999", result.Error);
|
||
}
|
||
```
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter DeployToSiteAsync` — expect FAIL (no such method).
|
||
3. Implement: extract the body of `DeployToAllSitesAsync` after the `sites` fetch into `private async Task<Result<ArtifactDeploymentSummary>> DeployCoreAsync(IReadOnlyList<Site> sites, string user, CancellationToken ct)` (everything from `deploymentId` creation down, unchanged). Then:
|
||
```csharp
|
||
public async Task<Result<ArtifactDeploymentSummary>> DeployToAllSitesAsync(
|
||
string user, CancellationToken cancellationToken = default)
|
||
{
|
||
var sites = await _siteRepo.GetAllSitesAsync(cancellationToken);
|
||
if (sites.Count == 0)
|
||
return Result<ArtifactDeploymentSummary>.Failure("No sites configured.");
|
||
return await DeployCoreAsync(sites, user, cancellationToken);
|
||
}
|
||
|
||
/// <summary>Deploys system-wide artifacts to a single site (management-API --site-id path).</summary>
|
||
public async Task<Result<ArtifactDeploymentSummary>> DeployToSiteAsync(
|
||
int siteId, string user, CancellationToken cancellationToken = default)
|
||
{
|
||
var site = await _siteRepo.GetSiteByIdAsync(siteId, cancellationToken);
|
||
if (site is null)
|
||
return Result<ArtifactDeploymentSummary>.Failure($"Site with ID {siteId} not found.");
|
||
return await DeployCoreAsync([site], user, cancellationToken);
|
||
}
|
||
```
|
||
(Match the actual `ISiteRepository.GetSiteByIdAsync` signature — check whether it takes a CancellationToken.)
|
||
4. Run the filter — expect PASS. Run `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests` (no regression in the all-sites tests).
|
||
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.DeploymentManager tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests && git commit -m "feat(deployment): DeployToSiteAsync for single-site artifact deployment"`
|
||
|
||
### Task 5: `HandleDeployArtifacts` honors `SiteId` + site-scope enforcement + docs
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (waits on Task 4; touches ManagementActor.cs — serialize with 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<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||
_artifactService.Received(1).DeployToSiteAsync(7, "testuser", Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public void DeployArtifacts_SiteScopedUser_OutOfScopeSite_Unauthorized()
|
||
{
|
||
var actor = CreateActor();
|
||
actor.Tell(ScopedEnvelope(new MgmtDeployArtifactsCommand(SiteId: 7), ["3"], "Deployer"));
|
||
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||
}
|
||
|
||
[Fact]
|
||
public void DeployArtifacts_SiteScopedUser_FleetWide_Unauthorized()
|
||
{
|
||
var actor = CreateActor();
|
||
actor.Tell(ScopedEnvelope(new MgmtDeployArtifactsCommand(SiteId: null), ["3"], "Deployer"));
|
||
var resp = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||
Assert.Contains("fleet-wide", resp.Message, StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
```
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DeployArtifacts` — expect FAIL.
|
||
3. Implement:
|
||
```csharp
|
||
private static async Task<object?> HandleDeployArtifacts(
|
||
IServiceProvider sp, MgmtDeployArtifactsCommand cmd, AuthenticatedUser user)
|
||
{
|
||
// A site-scoped Deployer may not trigger a FLEET-WIDE artifact deployment:
|
||
// EnforceSiteScope(null) is a no-op by design, so the fleet-wide case needs
|
||
// its own guard (arch-review C2 authorization gap).
|
||
if (cmd.SiteId is null
|
||
&& user.PermittedSiteIds.Length > 0
|
||
&& !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
||
{
|
||
throw new SiteScopeViolationException(
|
||
"Site-scoped users cannot deploy artifacts fleet-wide; specify a target site id.");
|
||
}
|
||
EnforceSiteScope(user, cmd.SiteId);
|
||
|
||
var svc = sp.GetRequiredService<ArtifactDeploymentService>();
|
||
var result = cmd.SiteId is int siteId
|
||
? await svc.DeployToSiteAsync(siteId, user.Username)
|
||
: await svc.DeployToAllSitesAsync(user.Username);
|
||
return result.IsSuccess
|
||
? result.Value
|
||
: throw new ManagementCommandException(result.Error);
|
||
}
|
||
```
|
||
Change the dispatch arm from `HandleDeployArtifacts(sp, cmd, user.Username)` to `HandleDeployArtifacts(sp, cmd, user)`. (Check `SiteScopeViolationException`'s ctor; match `EnforceSiteScope`'s usage.)
|
||
4. Run the filter — expect PASS; run the full project.
|
||
5. Update the three docs (step Files list) — `--site-id` now deploys to exactly that site; fleet-wide requires system-wide Deployer or Administrator.
|
||
6. Commit: `git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests docs/requirements/Component-ManagementService.md docs/requirements/Component-CLI.md src/ZB.MOM.WW.ScadaBridge.CLI/README.md && git commit -m "fix(management): honor MgmtDeployArtifactsCommand.SiteId + enforce site scope (arch-review C2)"`
|
||
|
||
### Task 6: `ConfigSecretScrubber` — JSON secret elision + sentinel merge
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 1, 3, 4, 14, 19, 21, 26, 29
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs`
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs` (create)
|
||
|
||
1. Failing tests:
|
||
```csharp
|
||
public class ConfigSecretScrubberTests
|
||
{
|
||
[Fact]
|
||
public void Scrub_RedactsPasswordFields_AtAnyDepth()
|
||
{
|
||
var json = """{"Endpoint":"opc.tcp://x","UserIdentity":{"Username":"u","Password":"p"},"CertificatePassword":"cp"}""";
|
||
var (scrubbed, had) = ConfigSecretScrubber.Scrub(json);
|
||
Assert.True(had);
|
||
Assert.DoesNotContain("\"p\"", scrubbed);
|
||
Assert.DoesNotContain("cp", scrubbed);
|
||
Assert.Contains(ConfigSecretScrubber.Sentinel, scrubbed);
|
||
Assert.Contains("opc.tcp://x", scrubbed); // non-secrets untouched
|
||
}
|
||
|
||
[Fact]
|
||
public void Scrub_NoSecrets_ReturnsFalseFlag()
|
||
{
|
||
var (_, had) = ConfigSecretScrubber.Scrub("""{"Endpoint":"opc.tcp://x"}""");
|
||
Assert.False(had);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(null)]
|
||
[InlineData("")]
|
||
[InlineData("not-json{")]
|
||
public void Scrub_NullEmptyOrMalformed_PassesThrough(string? input)
|
||
{
|
||
var (scrubbed, had) = ConfigSecretScrubber.Scrub(input);
|
||
Assert.Equal(input, scrubbed);
|
||
Assert.False(had);
|
||
}
|
||
|
||
[Fact]
|
||
public void MergeSentinels_RestoresStoredSecrets_KeepsEdits()
|
||
{
|
||
var stored = """{"Endpoint":"old","UserIdentity":{"Password":"real"}}""";
|
||
var incoming = $$"""{"Endpoint":"new","UserIdentity":{"Password":"{{ConfigSecretScrubber.Sentinel}}"}}""";
|
||
var merged = ConfigSecretScrubber.MergeSentinels(incoming, stored)!;
|
||
Assert.Contains("\"new\"", merged);
|
||
Assert.Contains("\"real\"", merged);
|
||
Assert.DoesNotContain(ConfigSecretScrubber.Sentinel, merged);
|
||
}
|
||
}
|
||
```
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ConfigSecretScrubberTests` — FAIL.
|
||
3. Implement with `System.Text.Json.Nodes.JsonNode` recursion:
|
||
```csharp
|
||
/// <summary>
|
||
/// Elides secret-bearing values inside opaque connection-config JSON blobs before
|
||
/// they leave the management boundary (responses AND audit afterState), mirroring
|
||
/// the SmtpConfigPublicShape/SmsConfigPublicShape pattern for structured entities.
|
||
/// A property is secret-bearing when its name contains one of the fragments below
|
||
/// (case-insensitive) and its value is a JSON string. MergeSentinels lets a client
|
||
/// round-trip a scrubbed config through update without wiping stored secrets.
|
||
/// </summary>
|
||
internal static class ConfigSecretScrubber
|
||
{
|
||
public const string Sentinel = "***REDACTED***";
|
||
private static readonly string[] SecretNameFragments =
|
||
["password", "secret", "token", "apikey", "credential", "passphrase"];
|
||
// Scrub: parse (return input unchanged on parse failure), walk objects/arrays,
|
||
// replace matching string values with Sentinel, return (json, hadSecrets).
|
||
// MergeSentinels: walk incoming; wherever value == Sentinel, substitute the value
|
||
// at the same path in storedJson (if absent, leave Sentinel — update will store it
|
||
// verbatim, which is inert). Null/empty inputs pass through.
|
||
}
|
||
```
|
||
4. Run — PASS. 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs && git commit -m "feat(management): ConfigSecretScrubber for connection-config secret elision"`
|
||
|
||
### Task 7: DataConnection List/Get/audit secret elision + sentinel-preserving update
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (ManagementActor.cs; waits on Task 6)
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (lines 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<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||
Assert.DoesNotContain("opc-secret", resp.JsonData);
|
||
Assert.Contains("hasSecrets", resp.JsonData); // camelCase serializer
|
||
}
|
||
|
||
[Fact]
|
||
public void UpdateDataConnection_SentinelConfig_PreservesStoredSecret()
|
||
{
|
||
// stored config has Password "opc-secret"; incoming config carries the sentinel
|
||
var actor = CreateActor();
|
||
var incoming = """{"Endpoint":"opc.tcp://new","UserIdentity":{"Password":"***REDACTED***"}}""";
|
||
actor.Tell(Envelope(new UpdateDataConnectionCommand(1, "c1", "OpcUa", incoming, null, 3), "Designer"));
|
||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||
await _siteRepo.Received(1).UpdateDataConnectionAsync(
|
||
Arg.Is<DataConnection>(c => c.PrimaryConfiguration!.Contains("opc-secret")
|
||
&& !c.PrimaryConfiguration.Contains("***REDACTED***")));
|
||
}
|
||
|
||
[Fact]
|
||
public void UpdateDataConnection_AuditAfterState_OmitsPasswords()
|
||
{
|
||
var actor = CreateActor();
|
||
actor.Tell(Envelope(new UpdateDataConnectionCommand(1, "c1", "OpcUa", "{\"Password\":\"newpw\"}", null, 3), "Designer"));
|
||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||
// AuditAsync serializes afterState via IAuditService — assert the captured object graph has no "newpw"
|
||
_auditService.Received().LogAsync(Arg.Any<string>(), "Update", "DataConnection",
|
||
Arg.Any<string>(), Arg.Any<string>(),
|
||
Arg.Is<object>(o => !ManagementActor.SerializeResult(o).Contains("newpw")), Arg.Any<CancellationToken>());
|
||
}
|
||
```
|
||
(Match `UpdateDataConnectionCommand`'s and `IAuditService.LogAsync`'s real signatures — read them first.)
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DataConnection` — new tests FAIL.
|
||
3. Implement in `ManagementActor.cs`:
|
||
```csharp
|
||
/// <summary>Secret-elided projection of a DataConnection (arch-review C3): the raw
|
||
/// PrimaryConfiguration/BackupConfiguration JSON can embed OPC UA user + certificate
|
||
/// passwords, and List/Get are readable by any authenticated user. Mirrors
|
||
/// SmtpConfigPublicShape. Non-config fields are projected verbatim.</summary>
|
||
private static object DataConnectionPublicShape(DataConnection c)
|
||
{
|
||
var (primary, s1) = ConfigSecretScrubber.Scrub(c.PrimaryConfiguration);
|
||
var (backup, s2) = ConfigSecretScrubber.Scrub(c.BackupConfiguration);
|
||
return new
|
||
{
|
||
c.Id, c.Name, c.Protocol, c.SiteId, c.FailoverRetryCount,
|
||
PrimaryConfiguration = primary, BackupConfiguration = backup,
|
||
HasSecrets = s1 || s2,
|
||
};
|
||
// NOTE: project every additional simple property present on the entity
|
||
// (read Commons/Entities .../DataConnection.cs and include all non-config fields).
|
||
}
|
||
```
|
||
- `HandleListDataConnections`: `return (await ...).Select(DataConnectionPublicShape).ToList();` (both branches).
|
||
- `HandleGetDataConnection`: `return conn is null ? null : DataConnectionPublicShape(conn);` (after the scope check).
|
||
- `HandleCreateDataConnection`: audit + return `DataConnectionPublicShape(conn)`.
|
||
- `HandleUpdateDataConnection`: before assignment, `cmd = cmd with { }` is unnecessary — merge directly:
|
||
```csharp
|
||
conn.PrimaryConfiguration = ConfigSecretScrubber.MergeSentinels(cmd.PrimaryConfiguration, conn.PrimaryConfiguration);
|
||
conn.BackupConfiguration = ConfigSecretScrubber.MergeSentinels(cmd.BackupConfiguration, conn.BackupConfiguration);
|
||
```
|
||
(assign Name/Protocol/FailoverRetryCount as today); audit + return the public shape.
|
||
4. Run filter — PASS; full project run. Note: the Central UI data-connection editor talks to repositories directly (not the actor), so UI round-tripping is unaffected; CLI round-trips are protected by the sentinel merge.
|
||
5. Update `Component-ManagementService.md`. Commit: `git add -A src/ZB.MOM.WW.ScadaBridge.ManagementService tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests docs/requirements/Component-ManagementService.md && git commit -m "fix(management): elide DataConnection config secrets from responses and audit (arch-review C3)"`
|
||
|
||
### Task 8: ExternalSystem `AuthConfiguration` elision + preserve-if-null update
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (ManagementActor.cs)
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (lines 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
|
||
/// <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
|
||
|
||
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<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("Operator")]
|
||
[InlineData("Verifier")]
|
||
[InlineData("Administrator")]
|
||
public void ListSecuredWrites_SecuredWriteRoles_Allowed(string role)
|
||
{
|
||
var actor = CreateActor(); // ISecuredWriteRepository substitute returns empty list
|
||
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), role));
|
||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||
}
|
||
```
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ListSecuredWrites` — FAIL (currently ungated).
|
||
3. Implement:
|
||
- Rename `GetRequiredRole` → `internal static IReadOnlyList<string>? GetRequiredRoles(object command)` (internal for Task 13's matrix test). Wrap the existing arms with static readonly arrays: `private static readonly string[] AdminOnly = [Roles.Administrator];`, `DesignerOnly`, `DeployerOnly`, `OperatorOnly`, `VerifierOnly`, and new `SecuredWriteReaders = [Roles.Operator, Roles.Verifier, Roles.Administrator];`. Add arm: `ListSecuredWritesCommand => SecuredWriteReaders,` with a comment (tag values on the history table are process-sensitive — arch-review UA1).
|
||
- `HandleEnvelope`:
|
||
```csharp
|
||
var requiredRoles = GetRequiredRoles(envelope.Command);
|
||
if (requiredRoles is not null
|
||
&& !requiredRoles.Any(r => user.Roles.Contains(r, StringComparer.OrdinalIgnoreCase)))
|
||
{
|
||
sender.Tell(new ManagementUnauthorized(correlationId,
|
||
$"One of roles [{string.Join(", ", requiredRoles)}] required for {envelope.Command.GetType().Name}"));
|
||
return;
|
||
}
|
||
```
|
||
(The existing `CreateSiteCommand_WithDesignRole_ReturnsUnauthorized` asserts `Contains("Administrator")` — still passes.)
|
||
- `AuthorizationPolicies.cs`: add `RequireSecuredWriteAccess` = `RequireClaim(JwtTokenService.RoleClaimType, Roles.Operator, Roles.Verifier, Roles.Administrator)` and apply to the SecuredWrites page attribute (check the page's current attribute; per Component-CentralUI it may already be operator-gated — align page, service, and actor).
|
||
4. PASS + full ManagementService.Tests + Security.Tests runs.
|
||
5. Docs, then commit: `git commit -m "feat(management): any-of role gates; restrict ListSecuredWrites to Operator/Verifier/Admin (arch-review UA1)"`
|
||
|
||
### Task 11: Reconcile the area-management role gate (code ⇄ three docs)
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (ManagementActor.cs; waits on Task 10)
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (Area command arms, lines 186/207)
|
||
- Modify: `docs/requirements/Component-ManagementService.md` §Authorization (~line 213), `docs/requirements/Component-Security.md` §Administrator (~line 88), `docs/requirements/Component-CentralUI.md` §Area Management (~line 106)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`
|
||
|
||
**Design decision** (record in the commit message): the Central UI creates areas inside the Deployment Topology workflow (`Topology.razor` is `RequireDeployment`), and the actor gates them Designer — the three docs' "Admin" assignment is stale. Reconcile on **any-of [Designer, Deployer]** (covers both real surfaces; Administrator is not implicitly included in the actor gate, matching current behavior for other Designer commands), and update all three docs to match.
|
||
|
||
1. Failing test:
|
||
```csharp
|
||
[Theory]
|
||
[InlineData("Designer")]
|
||
[InlineData("Deployer")]
|
||
public void CreateArea_DesignerOrDeployer_Allowed(string role) { /* Envelope(new CreateAreaCommand(...), role) → not ManagementUnauthorized */ }
|
||
|
||
[Fact]
|
||
public void CreateArea_ViewerOnly_Unauthorized() { /* → ManagementUnauthorized */ }
|
||
```
|
||
2. `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter CreateArea` — Deployer case FAILS today.
|
||
3. Move `CreateAreaCommand or UpdateAreaCommand or DeleteAreaCommand` out of the Designer arm into a new arm `=> AreaManagers` where `private static readonly string[] AreaManagers = [Roles.Designer, Roles.Deployer];` with a comment citing the reconciliation. Update the three docs: area management = Designer or Deployer (management API), Central UI exposes it inside the Deployment Topology workflow (`RequireDeployment`); remove "area management" from the Admin lists.
|
||
4. PASS. 5. Commit: `git commit -m "fix(management): reconcile area role gate to Designer|Deployer across actor and all three docs (arch-review C6)"`
|
||
|
||
### Task 12: Canonicalize role-mapping casing at the write path
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** 14, 19, 21, 26, 29 (ManagementActor.cs region 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<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||
await _securityRepo.Received(1).AddMappingAsync(
|
||
Arg.Is<LdapGroupMapping>(m => m.Role == "Deployer")); // canonical, not verbatim
|
||
}
|
||
```
|
||
2. `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter RoleMapping` — FAIL.
|
||
3. Replace `ValidateMappingRole` with:
|
||
```csharp
|
||
// The stored Role must be CANONICAL casing (Roles.All): ASP.NET RequireClaim policies
|
||
// compare ordinal case-sensitively while the actor compares OrdinalIgnoreCase, so a
|
||
// row stored as "deployer" passes every actor gate but fails every UI policy — a
|
||
// CLI-works/UI-403s split-brain. Canonicalizing at this single write path closes the
|
||
// asymmetry (arch-review C5; closes the deferral formerly documented here).
|
||
private static string CanonicalizeMappingRole(string role)
|
||
{
|
||
var canonical = Roles.All.FirstOrDefault(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
|
||
return canonical ?? throw new ManagementCommandException(
|
||
$"Role '{role}' is not a recognized role. Valid roles are: {string.Join(", ", Roles.All)}.");
|
||
}
|
||
```
|
||
Use `var role = CanonicalizeMappingRole(cmd.Role);` in both create/update handlers and store `role`. Keep the invalid-role rejection tests green (message unchanged).
|
||
4. PASS + full RoleMappingValidationTests run. 5. Doc + commit: `git commit -m "fix(management): canonicalize role-mapping casing at write path, closing UI/actor case split (arch-review C5)"`
|
||
|
||
### Task 13: `GetRequiredRoles` matrix test — freeze the authorization table
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (waits on Tasks 10, 11, 22, 23 — final gate table)
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj` (add `<ItemGroup><InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.ManagementService.Tests" /></ItemGroup>`)
|
||
- Create: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs`
|
||
|
||
1. Write the test (it IS the deliverable — no implementation change beyond the csproj line):
|
||
```csharp
|
||
using System.Runtime.CompilerServices;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||
using ZB.MOM.WW.ScadaBridge.Security;
|
||
|
||
public class RequiredRoleMatrixTests
|
||
{
|
||
// FROZEN authorization table: every registered management command MUST have an
|
||
// entry here. A new command without one fails EveryRegisteredCommand_IsInTheFrozenTable,
|
||
// forcing a deliberate authz decision (arch-review UA2; would have caught C2/C4).
|
||
private static readonly Dictionary<string, string[]?> Expected = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["CreateSite"] = [Roles.Administrator],
|
||
["CreateTemplate"] = [Roles.Designer],
|
||
["MgmtDeployArtifacts"] = [Roles.Deployer],
|
||
["SubmitSecuredWrite"] = [Roles.Operator],
|
||
["ApproveSecuredWrite"] = [Roles.Verifier],
|
||
["ListSecuredWrites"] = [Roles.Operator, Roles.Verifier, Roles.Administrator],
|
||
["CreateArea"] = [Roles.Designer, Roles.Deployer],
|
||
["ListTemplates"] = null, // read-only: any authenticated user
|
||
// ... generate the FULL ~150-entry table: dump
|
||
// ManagementCommandRegistry-registered names with their current
|
||
// GetRequiredRoles(...) result once (scratch console or temp test),
|
||
// eyeball it against Component-ManagementService.md §Authorization,
|
||
// then paste it here verbatim and never regenerate mechanically.
|
||
};
|
||
|
||
public static IEnumerable<object[]> AllRegisteredCommands()
|
||
=> typeof(ManagementEnvelope).Assembly.GetTypes()
|
||
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
|
||
&& t.Name.EndsWith("Command", StringComparison.Ordinal) && !t.IsAbstract)
|
||
.Select(t => new object[] { t });
|
||
|
||
[Theory, MemberData(nameof(AllRegisteredCommands))]
|
||
public void EveryRegisteredCommand_IsInTheFrozenTable(Type commandType)
|
||
{
|
||
var name = commandType.Name[..^"Command".Length];
|
||
Assert.True(Expected.ContainsKey(name),
|
||
$"Command '{name}' has no entry in the frozen authorization table — add one deliberately.");
|
||
var instance = RuntimeHelpers.GetUninitializedObject(commandType);
|
||
var actual = ManagementActor.GetRequiredRoles(instance);
|
||
var expected = Expected[name];
|
||
if (expected is null) Assert.Null(actual);
|
||
else Assert.Equal(expected.OrderBy(x => x), actual!.OrderBy(x => x));
|
||
}
|
||
}
|
||
```
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter RequiredRoleMatrixTests` — FAIL until the table is complete and `InternalsVisibleTo` + `internal` visibility (Task 10) are in place.
|
||
3. Fill the table to green. Any surprise found while filling (a command gated differently than `Component-ManagementService.md` documents) gets fixed in this task — doc or gate, whichever is wrong — and noted in the commit message.
|
||
4. Commit: `git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs && git commit -m "test(management): frozen GetRequiredRoles matrix over every registered command (arch-review UA2)"`
|
||
|
||
### Task 14: SecuredWrite repository — `TryMarkExpiredAsync` + `CountAsync`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 1, 3, 4, 6, 19, 21, 26, 29
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs`
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/` — extend the existing SecuredWriteRepository test file (locate via `grep -rl SecuredWriteRepository tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests`; mirror its harness — in-memory/SQLite or MSSQL fixture)
|
||
|
||
1. Failing tests:
|
||
```csharp
|
||
[Fact]
|
||
public async Task TryMarkExpiredAsync_PendingRow_FlipsToExpired_Once()
|
||
{
|
||
var id = await _repo.AddAsync(NewPendingWrite());
|
||
Assert.True(await _repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
|
||
Assert.False(await _repo.TryMarkExpiredAsync(id, DateTime.UtcNow)); // CAS: second caller loses
|
||
var row = await _repo.GetAsync(id);
|
||
Assert.Equal("Expired", row!.Status);
|
||
Assert.NotNull(row.DecidedAtUtc);
|
||
Assert.Null(row.VerifierUser); // system decision, no verifier
|
||
}
|
||
|
||
[Fact]
|
||
public async Task TryMarkExpiredAsync_DecidedRow_ReturnsFalse()
|
||
{
|
||
var id = await _repo.AddAsync(NewPendingWrite());
|
||
await _repo.TryMarkApprovedAsync(id, "v", null, DateTime.UtcNow);
|
||
Assert.False(await _repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task CountAsync_FiltersLikeQueryAsync()
|
||
{
|
||
await _repo.AddAsync(NewPendingWrite());
|
||
await _repo.AddAsync(NewPendingWrite());
|
||
Assert.Equal(2, await _repo.CountAsync(status: "Pending", siteId: null));
|
||
Assert.Equal(0, await _repo.CountAsync(status: "Executed", siteId: null));
|
||
}
|
||
```
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests --filter "TryMarkExpired|CountAsync_Filters"` — FAIL.
|
||
3. Add to the interface (xmldoc mirrors `TryMarkApprovedAsync`'s CAS wording — this is the multi-node-safe expiry primitive):
|
||
```csharp
|
||
Task<bool> TryMarkExpiredAsync(long id, DateTime expiredAtUtc, CancellationToken ct = default);
|
||
Task<int> CountAsync(string? status, string? siteId, CancellationToken ct = default);
|
||
```
|
||
Implement with the same conditional-update pattern `TryMarkApprovedAsync` uses (`WHERE Status = 'Pending'`), setting `Status='Expired'`, `DecidedAtUtc=expiredAtUtc`, leaving `VerifierUser` NULL; `CountAsync` mirrors `QueryAsync`'s filter composition with `CountAsync()`.
|
||
4. PASS + full project run. 5. Commit: `git commit -m "feat(config-db): secured-write TryMarkExpiredAsync CAS + CountAsync (arch-review S2/P3)"`
|
||
|
||
### Task 15: Server-side secured-write TTL — enforce at approve/reject
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (ManagementActor.cs; waits on Task 14)
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementServiceOptions.cs` (add `SecuredWritePendingTtl`, default 24 h)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs` (additive member `SecuredWriteExpire`)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleApproveSecuredWrite` ~1106, `HandleRejectSecuredWrite` ~1255)
|
||
- Modify: `docs/requirements/Component-ManagementService.md`, `docs/requirements/Component-Security.md` (two-person section: TTL + restate the dual-role Operator+Verifier deployment-configuration hazard), `CLAUDE.md` Security & Auth bullet (append "pending secured writes expire server-side after a configurable TTL, default 24 h")
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs`
|
||
|
||
1. Failing tests (extend the existing `SecuredWriteHandlerTests` harness; register `IOptions<ManagementServiceOptions>` in its service collection):
|
||
```csharp
|
||
[Fact]
|
||
public void Approve_PendingRowOlderThanTtl_ExpiresInsteadOfExecuting()
|
||
{
|
||
var row = PendingRow(submittedAtUtc: DateTime.UtcNow.AddHours(-25)); // TTL default 24h
|
||
_securedWriteRepo.GetAsync(1).Returns(row);
|
||
_securedWriteRepo.TryMarkExpiredAsync(1, Arg.Any<DateTime>()).Returns(true);
|
||
|
||
var actor = CreateActor();
|
||
actor.Tell(Envelope(new ApproveSecuredWriteCommand(1, null), "Verifier"));
|
||
|
||
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||
Assert.Contains("expired", resp.Error, StringComparison.OrdinalIgnoreCase);
|
||
_securedWriteRepo.DidNotReceive().TryMarkApprovedAsync(
|
||
Arg.Any<long>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||
_commService.DidNotReceiveWithAnyArgs().WriteTagAsync(default!, default!); // stale value NEVER relayed
|
||
}
|
||
|
||
[Fact]
|
||
public void Approve_FreshPendingRow_StillExecutes() { /* submitted 1h ago → existing approve path unchanged */ }
|
||
```
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter SecuredWrite` — new tests FAIL.
|
||
3. Implement:
|
||
- Options: `/// <summary>Age after which a Pending secured write can no longer be approved; it is transitioned to Expired at the next decision/list touch. Default 24 hours.</summary> public TimeSpan SecuredWritePendingTtl { get; set; } = TimeSpan.FromHours(24);`
|
||
- `AuditKind`: add `SecuredWriteExpire` (additive enum member — message contracts are additive-only).
|
||
- Shared helper in `ManagementActor`:
|
||
```csharp
|
||
/// <summary>If the Pending row is older than the configured TTL, CAS it to Expired
|
||
/// (multi-node idempotent), audit best-effort, and return true. A stale setpoint
|
||
/// must never be approvable days later (arch-review S2).</summary>
|
||
private static async Task<bool> TryExpireIfStaleAsync(
|
||
IServiceProvider sp, ISecuredWriteRepository repo, PendingSecuredWrite row)
|
||
{
|
||
var ttl = sp.GetService<IOptions<ManagementServiceOptions>>()?.Value.SecuredWritePendingTtl
|
||
?? TimeSpan.FromHours(24);
|
||
if (ttl <= TimeSpan.Zero || DateTime.UtcNow - row.SubmittedAtUtc <= ttl) return false;
|
||
var expiredAt = DateTime.UtcNow;
|
||
if (await repo.TryMarkExpiredAsync(row.Id, expiredAt))
|
||
{
|
||
row.Status = "Expired";
|
||
row.DecidedAtUtc = expiredAt;
|
||
await EmitSecuredWriteAuditAsync(
|
||
sp, AuditKind.SecuredWriteExpire, AuditStatus.Discarded, row, actor: "system");
|
||
}
|
||
return true;
|
||
}
|
||
```
|
||
- In `HandleApproveSecuredWrite` and `HandleRejectSecuredWrite`, immediately after the `"Pending"` status check: `if (await TryExpireIfStaleAsync(sp, repo, row)) throw new ManagementCommandException($"Secured write {cmd.Id} expired (pending longer than the configured TTL) and can no longer be approved or rejected.");`
|
||
(Check `AuditStatus` members; if `Discarded` is inappropriate, use the closest terminal status the enum offers and note it.)
|
||
4. PASS + full SecuredWriteHandlerTests. 5. Docs + CLAUDE.md, commit: `git commit -m "feat(management): server-side secured-write TTL — stale pending writes expire, never execute (arch-review S2)"`
|
||
|
||
### Task 16: Opportunistic expiry sweep in `HandleListSecuredWrites`
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (ManagementActor.cs; waits on Task 15)
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleListSecuredWrites` ~1280)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs`
|
||
|
||
1. Failing test: repo returns one fresh Pending + one 25h-old Pending; `ListSecuredWritesCommand` result marks the old one `Expired` and `TryMarkExpiredAsync` was called exactly once (for the stale row only).
|
||
2. Run `--filter SecuredWrite` — FAIL.
|
||
3. In `HandleListSecuredWrites`, before returning: iterate the queried rows, `await TryExpireIfStaleAsync(sp, repo, row)` for each `Pending` row (helper self-filters by age; CAS makes the multi-node double-run harmless — the ManagementActor runs on every central node by design, so the sweep is deliberately lazy/opportunistic rather than a new timer singleton; document this in a comment).
|
||
4. PASS. 5. Commit: `git commit -m "feat(management): opportunistic expiry sweep on secured-write list (arch-review S2)"`
|
||
|
||
### Task 17: Secured-write list paging — command, handler, CLI
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (ManagementActor.cs; waits on Task 14)
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SecuredWriteCommands.cs` (line 59 `ListSecuredWritesCommand`, line 83 `SecuredWriteListResult`)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleListSecuredWrites`)
|
||
- Modify: CLI secured-write list command (locate: `grep -rl ListSecuredWritesCommand src/ZB.MOM.WW.ScadaBridge.CLI`) — add `--skip/--take`
|
||
- Modify: `docs/requirements/Component-ManagementService.md`, `docs/requirements/Component-CLI.md`
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests` (parse test)
|
||
|
||
1. Failing test: `ListSecuredWritesCommand(null, null, Skip: 10, Take: 25)` → repo `QueryAsync(null, null, 10, 25)` received; result JSON contains `totalCount` sourced from `CountAsync`.
|
||
2. Run `--filter SecuredWrite` — FAIL (record has no such params).
|
||
3. Implement (additive, defaults preserve today's wire behavior):
|
||
```csharp
|
||
public record ListSecuredWritesCommand(string? Status, string? SiteId, int Skip = 0, int Take = 200);
|
||
public record SecuredWriteListResult(IReadOnlyList<SecuredWriteDto> Items, int TotalCount);
|
||
```
|
||
Handler: clamp `var take = Math.Clamp(cmd.Take, 1, 500); var skip = Math.Max(0, cmd.Skip);`, `QueryAsync(cmd.Status, cmd.SiteId, skip, take)` + `CountAsync(cmd.Status, cmd.SiteId)`. Update the one UI construction site (`SecuredWriteService`) for the new result shape (compile will point at it). CLI: add the two int options.
|
||
4. PASS; `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests` (direct — not in slnx yet).
|
||
5. Commit: `git commit -m "feat(management): secured-write list paging with TotalCount (arch-review P3)"`
|
||
|
||
### Task 18: SecuredWrites page — history pager + age in the approve dialog + docs
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 19, 21, 26, 28, 29 (waits on Task 17)
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor` (RefreshListsAsync ~line 303; confirm dialog ~line 342; history table)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ISecuredWriteService.cs` + `SecuredWriteService.cs` (paging params + TotalCount pass-through)
|
||
- Modify: `docs/requirements/Component-CentralUI.md` §Secured Writes (Expired is real + TTL + paging)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/` (bUnit — follow an existing page test's auth/service-fake setup)
|
||
|
||
1. Failing bUnit test: render the page with a fake `ISecuredWriteService` returning 60 terminal rows + `TotalCount = 120`; assert a "Next" pager button exists and clicking it requests the second page (`ListAsync(..., skip: 50, ...)` received). Second test: the approve confirmation dialog for a row submitted 26 hours ago renders text matching `Submitted .* ago` (age surfaced next to the value — the reviewer's stale-setpoint scenario).
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter SecuredWrites` — FAIL.
|
||
3. Implement: page-size 50 history pager (`_historyPage`, Prev/Next buttons disabled at bounds using TotalCount); dialog gains a `Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})` line. Pending list stays unpaged (bounded by TTL now).
|
||
4. PASS. 5. Doc + commit: `git commit -m "feat(ui): secured-write history paging + submission age in approve dialog (arch-review S2/P3)"`
|
||
|
||
### Task 19: Per-command long-running Ask timeout
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 1, 3, 4, 6, 14, 21, 26, 29
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementServiceOptions.cs` (add `LongRunningCommandTimeout`, default 5 min)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs` (lines 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);
|
||
|
||
/// <summary>Transport/deploy commands legitimately run for minutes (the CLI already
|
||
/// uses a 5-minute client timeout for bundles); a 30 s server-side Ask 504s while the
|
||
/// piped ProcessCommand keeps applying — the caller then retries and double-applies
|
||
/// (arch-review S1). These command types get LongRunningCommandTimeout instead.</summary>
|
||
private static readonly HashSet<Type> LongRunningCommands =
|
||
[
|
||
typeof(ImportBundleCommand), typeof(PreviewBundleCommand), typeof(ExportBundleCommand),
|
||
typeof(MgmtDeployArtifactsCommand), typeof(MgmtDeployInstanceCommand),
|
||
];
|
||
|
||
public static TimeSpan ResolveAskTimeout(ManagementServiceOptions? options, Type commandType)
|
||
=> LongRunningCommands.Contains(commandType)
|
||
? (options is { LongRunningCommandTimeout.Ticks: > 0 } o ? o.LongRunningCommandTimeout : DefaultLongRunningTimeout)
|
||
: ResolveAskTimeout(options);
|
||
```
|
||
Call site: `ResolveAskTimeout(options, command.GetType())`. Keep the existing single-arg overload (used by other callers/tests).
|
||
4. PASS + full ManagementEndpointsTests. 5. Commit: `git commit -m "fix(management): 5-min Ask timeout for transport/deploy commands (arch-review S1)"`
|
||
|
||
### Task 20: Ship `ManagementService` timeout config + doc
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** 18, 21, 26, 28, 29 (waits on Task 19)
|
||
**Files:**
|
||
- Modify: `docker/central-node-a/appsettings.Central.json`, `docker/central-node-b/appsettings.Central.json`, the two `docker-env2` central-node appsettings (glob `docker-env2/central-node-*/appsettings.Central.json`), `deploy/wonder-app-vd03/appsettings.Central.json`
|
||
- Modify: `docs/requirements/Component-ManagementService.md` §Configuration (~line 247)
|
||
|
||
1. Add under the `"ScadaBridge"` object in each central-node config:
|
||
```json
|
||
"ManagementService": {
|
||
"CommandTimeout": "00:00:30",
|
||
"LongRunningCommandTimeout": "00:05:00"
|
||
}
|
||
```
|
||
2. Extend the doc's configuration table row with `LongRunningCommandTimeout` (TimeSpan, default 5 min, applied to ImportBundle/PreviewBundle/ExportBundle/MgmtDeployArtifacts/MgmtDeployInstance).
|
||
3. Validate each edited JSON (`python3 -c ...` as in Task 3). Cluster-runtime change: rebuild with `bash docker/deploy.sh` when convenient (config-only; note in commit).
|
||
4. Commit: `git commit -m "chore(deploy): ship ManagementService command-timeout config to all central-node topologies (arch-review S1)"`
|
||
|
||
### Task 21: Browse/Verify/Cert commands gain optional `SiteIdentifier`
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** 1, 3, 4, 6, 14, 19, 26, 29
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs` (`BrowseNodeCommand` :24, `SearchAddressSpaceCommand` :62), `VerifyEndpointCommands.cs` (`VerifyEndpointCommand` :102), `CertTrustCommands.cs` (`TrustServerCertCommand` :192, `RemoveServerCertCommand` :199, `ListServerCertsCommand` :206)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/` (serialization round-trip)
|
||
|
||
1. Failing test (Commons.Tests): deserialize legacy JSON without the field → `SiteIdentifier == null`; round-trip with the field set survives. (Use `System.Text.Json` with `PropertyNameCaseInsensitive = true`, matching `ManagementEndpoints.ParseCommand`.)
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter SiteIdentifier` — FAIL.
|
||
3. Append an optional trailing positional param to each of the six records, e.g.:
|
||
```csharp
|
||
public record BrowseNodeCommand(
|
||
string ConnectionName, string? ParentNodeId, string? ContinuationToken,
|
||
string? SiteIdentifier = null);
|
||
```
|
||
xmldoc: "Target site identifier — REQUIRED when the command is invoked via the management HTTP API/CLI (the actor must route it to a site); ignored on the site side, where routing already happened. Additive per the message-contract evolution rules." Existing positional construction sites (UI services, Communication) compile unchanged.
|
||
4. PASS + `dotnet build ZB.MOM.WW.ScadaBridge.slnx`. 5. Commit: `git commit -m "feat(commons): additive SiteIdentifier on browse/verify/cert-trust commands for management-API routing (arch-review C4)"`
|
||
|
||
### Task 22: Actor handlers — BrowseNode / SearchAddressSpace / VerifyEndpoint
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (ManagementActor.cs; waits on Tasks 10, 21)
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (dispatch switch ~:398 "Remote Queries" region; `GetRequiredRoles` Designer arm; new handlers near `HandleQueryEventLogs` ~:2753)
|
||
- Modify: `docs/requirements/Component-ManagementService.md` §Remote Queries (note the commands are now actor-dispatched — the doc's existing description at lines 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<ManagementError>(TimeSpan.FromSeconds(5));
|
||
Assert.Contains("SiteIdentifier", resp.Error);
|
||
}
|
||
|
||
[Fact]
|
||
public void BrowseNode_Designer_RelaysToSite()
|
||
{
|
||
var actor = CreateActor(); // CommunicationService fake per SecuredWriteHandlerTests' pattern
|
||
actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Designer"));
|
||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||
_commService.Received(1).BrowseNodeAsync("SITE1", Arg.Any<BrowseNodeCommand>(), Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public void BrowseNode_ViewerOnly_Unauthorized() { /* → ManagementUnauthorized */ }
|
||
```
|
||
2. Run `--filter BrowseNode` — FAIL (falls through to NotSupportedException today).
|
||
3. Implement three handlers following `HandleQueryEventLogs`'s shape:
|
||
```csharp
|
||
private static async Task<object?> HandleBrowseNode(IServiceProvider sp, BrowseNodeCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var site = cmd.SiteIdentifier
|
||
?? throw new ManagementCommandException("SiteIdentifier is required when browsing via the management API.");
|
||
await EnforceSiteScopeForIdentifier(sp, user, site);
|
||
var comm = sp.GetRequiredService<CommunicationService>();
|
||
return await comm.BrowseNodeAsync(site, cmd);
|
||
}
|
||
```
|
||
(same for `HandleSearchAddressSpace` → `SearchAddressSpaceAsync`, `HandleVerifyEndpoint` → `VerifyEndpointAsync`; match the real `CommunicationService` signatures at :366/:388/:410). Dispatch arms in the Remote Queries region; `GetRequiredRoles`: add the three commands to the Designer arm (doc line 204/206 already says Design). Update the Task 13 matrix table if it already exists.
|
||
4. PASS + full run. 5. Doc + commit: `git commit -m "feat(management): actor dispatch for BrowseNode/SearchAddressSpace/VerifyEndpoint — CLI parity (arch-review C4)"`
|
||
|
||
### Task 23: Actor handlers — TrustServerCert / RemoveServerCert / ListServerCerts (Admin)
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (ManagementActor.cs; waits on Tasks 10, 21)
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (dispatch + `GetRequiredRoles` Admin arm + three handlers)
|
||
- Modify: `docs/requirements/Component-ManagementService.md` (line 218's Admin gating is now enforced at the actor)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`
|
||
|
||
1. Failing tests mirroring Task 22: Designer-only caller gets `ManagementUnauthorized` for `TrustServerCertCommand`; Admin with `SiteIdentifier` relays via `CommunicationService.TrustServerCertAsync`; missing `SiteIdentifier` → curated error.
|
||
2. Run `--filter ServerCert` — FAIL.
|
||
3. Implement three handlers (delegate to `TrustServerCertAsync`/`RemoveServerCertAsync`/`ListServerCertsAsync` at CommunicationService :435/:477/:456); `GetRequiredRoles`: add the three cert commands to the Administrator arm. Comment: "Mutates the site's trusted-peer PKI store — Admin, matching the Admin-gated UI cert page. Site-side reconcile semantics are owned by the site CertStoreActor (plan 03); this handler only relays."
|
||
4. PASS + full run. 5. Doc + commit: `git commit -m "feat(management): Admin-gated actor dispatch for cert-trust commands (arch-review C4)"`
|
||
|
||
### Task 24: Dispatch-coverage test — every registered command reaches a handler
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 18, 20, 25, 26, 28, 29 (waits on Tasks 22, 23)
|
||
**Files:**
|
||
- Create: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs`
|
||
|
||
1. Write the test: for every `ManagementCommandRegistry`-registered type (except a documented exclusion list: `ResolveRolesCommand` — intentionally undispatched, see ManagementActor :418), send an envelope carrying `RuntimeHelpers.GetUninitializedObject(type)` with ALL roles to an actor whose `IServiceProvider` is a substitute wired so `CreateScope()` yields a scoped provider that throws a `SentinelException` from `GetService` (mock `IServiceScopeFactory`/`IServiceScope`). Capture the exception MapFault logs via a recording `ILogger<ManagementActor>` (small test logger that stores `LogError` exceptions keyed by correlation id). Assert: for no command is the captured exception a `NotSupportedException` (which is the "no dispatch arm" signature). Commands that succeed without touching the provider (e.g. `GetHealthSummaryCommand`) simply produce no captured exception — also a pass.
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DispatchCoverageTests` — should PASS immediately after Tasks 22/23; if it fails, the failure list IS the remaining C4-style gap — add the missing arms or (with justification) the exclusion entry.
|
||
3. Commit: `git commit -m "test(management): dispatch-coverage test — every registered command has a handler (arch-review UA2)"`
|
||
|
||
### Task 25: CLI parity — browse / search / verify-endpoint / certs commands
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 18, 20, 24, 26, 28, 29 (waits on Tasks 22, 23)
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/ConnectionBrowseCommands.cs`
|
||
- Modify: CLI `Program.cs` (register the group), `src/ZB.MOM.WW.ScadaBridge.CLI/README.md`, `docs/requirements/Component-CLI.md`
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/` (new `ConnectionBrowseCommandsTests.cs`)
|
||
|
||
1. Failing parse tests (mirror an existing CLI.Tests command-parse test): `data-connection browse --site SITE1 --connection conn1 --node-id ns=2;s=X` parses and builds a `BrowseNodeCommand` with `SiteIdentifier == "SITE1"`; same for `search` (`--query --max-depth --max-results`), `verify-endpoint` (`--protocol --config-file <path>` reading ConfigJson from the file), `certs list`, `certs trust --der-file <path> --thumbprint <tp>` (base64-encodes the file), `certs remove --thumbprint`.
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests --filter ConnectionBrowse` — FAIL.
|
||
3. Implement the command group with `CommandHelpers.ExecuteCommandAsync` like `SiteCommands.BuildDeployArtifacts`; wire under the existing `data-connection` group. Update README + Component-CLI.md command tables.
|
||
4. PASS: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests` (direct — remember the slnx gotcha until Task 33).
|
||
5. Commit: `git commit -m "feat(cli): browse/search/verify-endpoint/cert-trust commands — actor parity (arch-review C4)"`
|
||
|
||
### Task 26: `LoginThrottle` — fixed-window LDAP-bind failure lockout
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 1, 3, 4, 6, 14, 19, 21, 29
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.Security/LoginThrottle.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs` (three additive settings + validator rules)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs` (`services.AddSingleton<LoginThrottle>();` in `AddSecurity`)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs` (create)
|
||
|
||
1. Failing tests (drive time with a fake `TimeProvider` — the codebase already injects `TimeProvider` for `CookieSessionValidator`; reuse that pattern):
|
||
```csharp
|
||
[Fact]
|
||
public void FiveFailuresInWindow_LocksOut_ThenWindowExpiryUnlocks()
|
||
{
|
||
var clock = new FakeTimeProvider(); // Microsoft.Extensions.TimeProvider.Testing, already used by Security.Tests — verify, else hand-roll
|
||
var throttle = new LoginThrottle(clock, Options.Create(new SecurityOptions()));
|
||
for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1");
|
||
Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
|
||
Assert.False(throttle.IsLockedOut("alice", "10.0.0.2")); // per username+IP key
|
||
Assert.False(throttle.IsLockedOut("bob", "10.0.0.1"));
|
||
clock.Advance(TimeSpan.FromMinutes(6)); // lockout window passed
|
||
Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
|
||
}
|
||
|
||
[Fact]
|
||
public void SuccessResets() { /* 4 failures + RecordSuccess → not locked after a 5th failure */ }
|
||
```
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter LoginThrottleTests` — FAIL.
|
||
3. Implement: `ConcurrentDictionary<string, (int Count, DateTimeOffset WindowStart, DateTimeOffset? LockedUntil)>` keyed `{username.ToLowerInvariant()}|{ip}`; fixed window; opportunistic pruning of expired entries on write; bounded (drop-oldest above e.g. 10 000 keys to cap memory under spray). `SecurityOptions` additive: `MaxLoginFailuresPerWindow = 5`, `LoginFailureWindowMinutes = 5`, `LoginLockoutMinutes = 5` (xmldoc: "0 disables throttling"); extend `SecurityOptionsValidator` (non-negative). Register singleton in `AddSecurity`.
|
||
4. PASS + full Security.Tests. 5. Commit: `git commit -m "feat(security): LoginThrottle — per-username+IP LDAP-bind failure lockout (arch-review P1)"`
|
||
|
||
### Task 27: Wire the throttle — ManagementAuthenticator + auth endpoints + doc
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (waits on Task 26)
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementAuthenticator.cs` (`AuthenticateAsync`, lines 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<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 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
|
||
/// <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/` (new `TransportGateTests.cs`)
|
||
|
||
1. Failing test: substitute `IBundleImporter` whose `LoadAsync` awaits a `TaskCompletionSource`; send two `ImportBundleCommand` envelopes; assert `LoadAsync` received exactly 1 call while the TCS is unset, then complete it and assert the second proceeds (both eventually answer).
|
||
2. Run `--filter TransportGate` — FAIL.
|
||
3. Implement:
|
||
```csharp
|
||
/// <summary>Single-flight gate for transport bundle commands: a 200 MB import holds
|
||
/// ~4 concurrent copies (base64 string, byte[], MemoryStream, JSON envelope), so two
|
||
/// concurrent imports on one central node are a realistic OOM path (arch-review S4).
|
||
/// Commands queue here instead of running concurrently on the thread pool.</summary>
|
||
private static readonly SemaphoreSlim TransportGate = new(1, 1);
|
||
```
|
||
Wrap the bodies of the three handlers in `await TransportGate.WaitAsync(); try { ... } finally { TransportGate.Release(); }`. In `HandleExportBundle`, replace `var bytes = ms.ToArray(); ... Convert.ToBase64String(bytes)` with `var base64 = Convert.ToBase64String(ms.GetBuffer().AsSpan(0, (int)ms.Length)); return new ExportBundleResult(base64, (int)ms.Length);` (one whole-artifact copy eliminated). Leave a comment that streaming multipart (like the audit export path, `AuditEndpoints.cs:183+`) is the deferred long-term shape.
|
||
4. PASS + full ManagementService.Tests + `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests` (no behavioral change expected).
|
||
5. Commit: `git commit -m "fix(management): single-flight transport bundle commands + drop redundant export copy (arch-review S4)"`
|
||
|
||
### Task 31: Low cleanup A — hub send fault logging (S5), event-log filter comment (C7), stale CLAUDE.md note (C8)
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 32, 33 (and any non-overlapping task)
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs` (~lines 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 `<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 create `AlarmSummaryRenderTests.cs`)
|
||
|
||
1. Failing bUnit test: rendered sortable `<th>` elements carry `tabindex="0"` and `aria-sort` (`ascending`/`descending` on the active column, `none` otherwise); pressing Enter on a header changes the sort (assert row order flips, mirroring the click test).
|
||
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary` — FAIL.
|
||
3. Implement: (a) **P4** — replace the in-render `FilteredRows().ToList()` with a cached `_visibleRows` field recomputed in `RefreshAsync` and in each filter/sort setter (one `RecomputeVisibleRows()` helper); (b) **UA6** — sortable `<th>`: add `tabindex="0"`, `aria-sort="@AriaSortFor(col)"`, `@onkeydown` handler toggling sort on `Enter`/`Space` (`@onkeydown:preventDefault` for Space). Leave a note that the same pattern applies to the other custom grids (deferred fleet-wide sweep, logged).
|
||
4. PASS. 5. Commit: `git commit -m "chore(ui): memoize AlarmSummary rows + keyboard/aria-sort on sortable headers (arch-review P4/UA6)"`
|
||
|
||
### Task 33: Add CLI.Tests to the slnx + retire the gotcha
|
||
|
||
**Classification:** trivial
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** 31, 32
|
||
**Files:**
|
||
- Modify: `ZB.MOM.WW.ScadaBridge.slnx` (tests ItemGroup, after line 59)
|
||
- Modify: `CLAUDE.md` (Editing Rules bullet about CLI.Tests exclusion; CLI Quick Reference untouched)
|
||
|
||
1. Add: `<Project Path="tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ZB.MOM.WW.ScadaBridge.CLI.Tests.csproj" />`
|
||
2. Verify: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` then `dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter FullyQualifiedName~CLI.Tests` — CLI tests now discovered and green. If the solution build breaks (an undocumented reason for the historical exclusion), revert and record the reason in CLAUDE.md instead — do not leave the solution red.
|
||
3. Update the CLAUDE.md Editing Rules bullet: CLI.Tests is now in the slnx; the "silently skipped" gotcha is retired (keep one sentence of history so old memory notes make sense). The user-memory note `cli-tests-not-in-slnx.md` becomes stale — flag it in your final report.
|
||
4. Commit: `git add ZB.MOM.WW.ScadaBridge.slnx CLAUDE.md && git commit -m "chore(build): include CLI.Tests in the solution — retire the silent-skip gotcha (arch-review UA3)"`
|
||
|
||
---
|
||
|
||
## Dependencies on other plans
|
||
|
||
- **Plan 01 (Cluster/Host/Failover):** also flags the `DisableLogin` artifact — **this plan owns the fix** (Tasks 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).
|