Files
scadaproj/docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md
Joseph Doherty 62a96e5f22 docs(secrets): reconcile 0.2.1 adoption - what was proven vs merely built
- All 4 apps on 0.2.1 (local branches, unpushed).
- Records the corrected security story: the version bump closed NO advisory (all four
  repos already resolved patched 2.1.12); the real live vulnerability was in ScadaBridge,
  masked by a NuGetAuditSuppress, and was fixed by separate work.
- Records the upstream 0.2.0 inert-Akka-replicator defect, its root cause (DI extensions
  with no container-building test - third instance of that class), and the 0.2.1 fix.
- Marks clustered topology WIRED-but-default-OFF and explicitly NOT live-validated;
  Task 9 remains open and the topology is not 'adopted' until it passes.
2026-07-18 12:21:31 -04:00

28 KiB
Raw Permalink Blame History

MxAccessGateway — Secrets Adoption (G-4, G-5, G-6) Implementation Plan

EXECUTED 2026-07-16 — merged to origin/main @ e088dfa, box-verified fail-closed and encrypt-at-rest. This document is the historical record; the 0.1.2 pins are what was landed then, not what to use now.

Follow-on (not yet executed): a version bump only2026-07-18-secrets-0.2.0-upgrade-and-clustering.md Task 2. This app is a single Windows/NSSM box with no Akka clustering, so no replication package and no topology apply. The bump still matters: 0.2.0 carries a high-severity transitive SQLitePCLRaw advisory fix that 0.1.2 lacks.

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. Shared context: docs/plans/2026-07-16-secrets-adoption-design.md.

Goal: Adopt the already-built, already-published ZB.MOM.WW.Secrets library (0.1.2, Gitea feed, reference-consumer-proven in HistorianGateway) into MxAccessGateway — retiring the plaintext LDAP bind password (and any password-bearing config) at rest behind pre-host ${secret:} expansion, adding a runtime ISecretResolver, and mounting the /admin/secrets Blazor UI. Covers gaps G-4 (pre-host config-secret expansion), G-5 (master-key provider), and G-6 (mount UI). There is no G-2/G-3 for this app — it has no database-resident driver/connection secrets.

Architecture: No library construction. Every task is per-app wiring of an existing NuGet package, following the HistorianGateway template verbatim (see design §3). All wiring lands in the single x64 gateway project ZB.MOM.WW.MxGateway.Server; the x86 .Worker project needs no secrets (it has no IConfiguration at all). MxAccessGateway is a single Windows/NSSM box, no Akka/clustering — so G-5 takes the simplest posture (Environment master-key provider + local SQLite store), and there is no shared-KEK / cross-node-replication concern (that fork in design §5 applies only to the two clustered apps).

Tech Stack: .NET 10, C#, ZB.MOM.WW.Secrets{,.Abstractions,.Ui} @ 0.1.2 from the dohertj2-gitea NuGet feed, AES-256-GCM envelope crypto, SQLite store, ASP.NET Core authorization + Blazor RCL. Build via dotnet build src/MxGateway.sln; tests via dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj.

Shared design: docs/plans/2026-07-16-secrets-adoption-design.md — API surface §1, two resolution layers §2 (this app is Layer A only), wiring template §3, G-4 §4, G-5 §5 (mxaccessgw sub-section), G-6 §6, cross-cutting gotchas §8. Baseline: components/secrets/current-state/mxaccessgw/CURRENT-STATE.md.


Branch & sequencing

Work on branch feat/adopt-zb-secrets off origin/main in ~/Desktop/MxAccessGateway, mirroring how auth / theme / audit were adopted across the family: commit each task + review, then fast-forward-merge into main and push to Gitea (origin = mxaccessgw). Keep the feat/* branch locally as history.

cd ~/Desktop/MxAccessGateway
git fetch origin && git switch -c feat/adopt-zb-secrets origin/main

Task ordering / parallelism. Tasks 1→2→3 are a strict chain (packages → pre-host expander → runtime registration) because the expander code will not compile until the packages restore, and the runtime registration shares usings/insertion neighbourhood with the expander. Task 4 (switch LDAP password + seed) depends on 13. Task 5 (optional Galaxy/pepper) depends on 4. Task 6 (UI mount) depends only on 13 and is parallelizable with 4/5. Task 7 (master-key operator config) is docs/appsettings only, parallelizable with 46. Task 8 is the final verification gate.

Gotcha carried from design §8 / the recon: Data Protection is never explicitly configured in this app — the only DP consumer is Dashboard/HubTokenService.cs:43 (SignalR hub tokens on the framework-default key ring). Secrets envelope crypto is independent of Data Protection. Do NOT add/reconfigure AddDataProtection anywhere in this work, or you risk rotating the hub-token key ring and breaking live SignalR sessions. Task 8 explicitly re-verifies SignalR is unaffected.


Task 1 — Package references + nuget.config packageSourceMapping

Classification: small Estimated implement time: ~4 min Parallelizable with: none (gates 28)

Files:

  • Modify: ~/Desktop/MxAccessGateway/nuget.config:24 (add two <package pattern> rows after the last existing ZB pattern; the file has explicit packageSourceMapping at :10-26, source key dohertj2-gitea at :6, no ZB.MOM.WW.* wildcard — restore fails without the new patterns)
  • Modify: ~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj (add three PackageReferences)

Steps:

  1. In nuget.config, under the dohertj2-gitea <packageSource> mapping block (after the existing ZB.MOM.WW.GalaxyRepository pattern at line 24), add:
    <package pattern="ZB.MOM.WW.Secrets" />
    <package pattern="ZB.MOM.WW.Secrets.*" />
    
    (The second pattern covers .Abstractions and .Ui.)
  2. In ZB.MOM.WW.MxGateway.Server.csproj, add to the existing ZB.MOM.WW.* PackageReference ItemGroup:
    <PackageReference Include="ZB.MOM.WW.Secrets" Version="0.1.2" />
    <PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.1.2" />
    <PackageReference Include="ZB.MOM.WW.Secrets.Ui" Version="0.1.2" />
    
    (.Ui transitively pulls ZB.MOM.WW.Theme + ZB.MOM.WW.Auth.AspNetCore + ZB.MOM.WW.Audit, all of which this app already references — no version conflict expected; verify in step 3.)
  3. Restore + build to prove the feed resolves the three packages:
    dotnet restore src/MxGateway.sln
    dotnet build src/MxGateway.sln -warnaserror
    
    Expect: clean restore (packages pulled from dohertj2-gitea), 0 warnings, 0 errors. If restore 404s, re-check the two nuget.config patterns and that $GITEA_TOKEN/feed creds are present.
  4. Commit: git add -A && git commit -m "build(secrets): add ZB.MOM.WW.Secrets 0.1.2 package refs + nuget source mapping".

Task 2 — Pre-host ${secret:} expander in GatewayApplication.CreateBuilder (G-4 mechanism)

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (depends on 1; gates 38)

Wire the throwaway-provider pre-host expansion (design §3a) so every downstream config consumer sees resolved values. This is a pure mechanism task — no config value is switched to a token yet (that is Task 4), so the app must still boot unchanged (the expander is a no-op when no ${secret:} tokens are present).

Files:

  • Modify: ~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs — method CreateBuilder(string[] args) at :58; builder-creation chain :60-64; ConfigureSelfSignedTls(builder) at :67. Insert the expander block between :64 and :67 (after the builder is created, before TLS reads config), so even TLS/Kestrel values can carry tokens.
  • Test: ~/Desktop/MxAccessGateway/src/MxGateway.Tests/… (add PreHostSecretExpansionTests, see step 1)

Why this insertion point (recon-verified): the expander must mutate builder.Configuration before all of: ConfigureSelfSignedTls (:67), AddGatewayConfiguration(builder.Configuration) (:71, which runs AddValidatedOptions<GatewayOptions,GatewayOptionsValidator> with ValidateOnStart), AddZbLdapAuth (DashboardServiceCollectionExtensions.cs:38, binds MxGateway:Ldap directly with its own ValidateOnStart), AddZbGalaxyRepository(...,"MxGateway:Galaxy") (:103), and AddZbApiKeyAuth (AuthStoreServiceCollectionExtensions.cs:70). Because CreateBuilder runs first in the pipeline, placing it right after builder creation satisfies all of these.

Steps:

  1. Write test first. Add PreHostSecretExpansionTests that constructs a config with a ${secret:test/value} token and a seeded fake/temp SQLite store, runs the same SecretReferenceExpander flow, and asserts the token is rewritten to the seeded plaintext; and a second test asserting an unseeded ${secret:missing} throws SecretNotFoundException. (If the test project cannot easily reach a private CreateBuilder, test the SecretReferenceExpander/AddZbSecrets flow directly against a temp SqlitePath — the same code the host runs.)
    dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter "FullyQualifiedName~PreHostSecretExpansion"
    
    Expect: fails to compile / red (expander not yet wired).
  2. Add usings to GatewayApplication.cs: ZB.MOM.WW.Secrets.Abstractions, ZB.MOM.WW.Secrets.Configuration, ZB.MOM.WW.Secrets.DependencyInjection, ZB.MOM.WW.Secrets.Sqlite, plus Microsoft.Extensions.DependencyInjection / Microsoft.Extensions.Configuration if not already present.
  3. Insert the expander block between line 64 and line 67 (the design §3a template, verbatim). Note CreateBuilder is synchronous today — make it async (return Task<…> / ValueTask) or run the expansion synchronously via .GetAwaiter().GetResult(). Preferred: keep the method signature stable if the caller chain is non-trivial by calling .GetAwaiter().GetResult() on the two awaits (pre-host bootstrap, single-shot, no sync-context deadlock risk in a console/host startup). Confirm the actual caller of CreateBuilder before choosing; if it is already in an async context, prefer real await.
    #pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons
    using (var secretsProvider = new ServiceCollection()
        .AddZbSecrets(builder.Configuration, "Secrets")
        .BuildServiceProvider())
    #pragma warning restore ASP0000
    {
        secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>()
            .MigrateAsync(default).GetAwaiter().GetResult();
        var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
        new SecretReferenceExpander(resolver)
            .ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default)
            .GetAwaiter().GetResult();
    }
    
    (Use await using + await if you made the method async — either is fine; keep the #pragma.)
  4. Add the Secrets block to src/ZB.MOM.WW.MxGateway.Server/appsettings.json (design §1 shape):
    "Secrets": {
      "SqlitePath": "mxgateway-secrets.db",
      "MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
      "RunMigrationsOnStartup": true,
      "ResolveCacheTtl": "00:00:30"
    }
    
  5. Build + run the two tests:
    dotnet build src/MxGateway.sln -warnaserror
    dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter "FullyQualifiedName~PreHostSecretExpansion"
    
    Expect: 0 warnings; both tests green (rewrite works, missing-ref throws).
  6. Commit: git commit -am "feat(secrets): pre-host \${secret:} config expansion in GatewayApplication.CreateBuilder (G-4 mechanism)".

Task 3 — Runtime AddZbSecrets registration on the host container

Classification: trivial Estimated implement time: ~2 min Parallelizable with: none (depends on 2; gates 48)

The pre-host throwaway provider (Task 2) only expands config once. The runtime resolver + SecretsMigrationHostedService are needed by the UI (Task 6) and keep the store schema current (design §3b, gotcha §8.3).

Files:

  • Modify: ~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs — service-registration region (alongside AddGatewayConfiguration at :71 / AddGatewayDashboard at :98).

Steps:

  1. Add, on the real host's builder.Services (after AddGatewayConfiguration, before/near AddGatewayDashboard):
    builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
    
  2. Build:
    dotnet build src/MxGateway.sln -warnaserror
    
    Expect: 0 warnings, 0 errors.
  3. Commit: git commit -am "feat(secrets): register runtime AddZbSecrets on the gateway host".

Task 4 — Switch LDAP ServiceAccountPassword to ${secret:} + seed + remove hardcoded defaults (G-4)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 6, Task 7 (depends on 13)

Retire the plaintext LDAP bind password. It appears in two places (both must be addressed, or the blanked appsettings falls back to a leaked default):

  • appsettings.json:29"ServiceAccountPassword": "serviceaccount123"
  • Configuration/LdapOptions.cs:61public string ServiceAccountPassword { get; init; } = "serviceaccount123";

Expanding MxGateway:Ldap:ServiceAccountPassword covers both the shadow GatewayOptionsValidator (requires non-blank, GatewayOptionsValidator.cs:134-137) and the real runtime AddZbLdapAuth bind (DashboardServiceCollectionExtensions.cs:38, own ValidateOnStart), because the expander mutates builder.Configuration before both.

Files:

  • Modify: ~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/appsettings.json:29 (token instead of plaintext)
  • Modify: ~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs:61 (blank the hardcoded default)

Steps:

  1. Change appsettings.json:29 to:
    "ServiceAccountPassword": "${secret:ldap/mxgateway/bind}",
    
  2. Blank the hardcoded default at LdapOptions.cs:61:
    public string ServiceAccountPassword { get; init; } = string.Empty;
    
    (Non-blank enforcement now lives entirely in the validator + fail-closed expansion; the leaked literal is gone.)
  3. Seed the secret into the local store via the secret CLI (ZB.MOM.WW.Secrets.Cli) before booting — the dev LDAP bind password is the current dev value (serviceaccount123 for the shared GLAuth, or the box's real value). Set ZB_SECRETS_MASTER_KEY (base64 32 bytes) and point the CLI at the same SqlitePath the app uses (mxgateway-secrets.db):
    export ZB_SECRETS_MASTER_KEY="$(openssl rand -base64 32)"   # dev only; box uses the operator-delivered key
    dotnet run --project <path-to>/ZB.MOM.WW.Secrets.Cli -- set \
      --sqlite ./src/ZB.MOM.WW.MxGateway.Server/mxgateway-secrets.db \
      --name ldap/mxgateway/bind --value 'serviceaccount123'
    
    (Confirm the exact CLI verb/flags from the ZB.MOM.WW.Secrets.Cli help — design §1 references a set/seed verb; the store path and ZB_SECRETS_MASTER_KEY must match what the app resolves with.)
  4. Boot smoke — start the gateway with the key present and the secret seeded:
    dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
    
    Expect: clean startup — GatewayOptionsValidator + AddZbLdapAuth ValidateOnStart both pass (password non-blank because the token expanded to the seeded value). Dashboard reachable; LDAP login works against the shared GLAuth.
  5. Negative control (fail-closed proof, design §10 / §8.1): stop the app, remove/rename the seeded secret (or unset ZB_SECRETS_MASTER_KEY), boot again. Expect: SecretNotFoundException (missing secret) or a decrypt failure (missing key) at pre-host expansion → the app refuses to start. Re-seed / restore the key to recover.
  6. Confirm no plaintext serviceaccount123 remains in the repo:
    grep -rn "serviceaccount123" ~/Desktop/MxAccessGateway/src || echo "clean"
    
    Expect: clean (both the appsettings literal and the LdapOptions default are gone).
  7. Commit: git commit -am "feat(secrets): source LDAP bind password via \${secret:ldap/mxgateway/bind}; drop plaintext defaults (G-4)".

Task 5 — (Optional) Galaxy connstr + API-key pepper to ${secret:}

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 6, Task 7 (depends on 4)

Two lower-priority Layer-A targets. Do these only if the operator wants them under management now; each is independently revertible.

Files:

  • Modify: ~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/appsettings.json:76 (Galaxy connstr, section MxGateway:Galaxy, bound by AddZbGalaxyRepository(...,"MxGateway:Galaxy") at GatewayApplication.cs:103)
  • (Pepper) no appsettings change — the pepper is env-supplied today via config-key name MxGateway:ApiKeyPepper (AuthStoreServiceCollectionExtensions.cs:36, resolved :70); optionally add an appsettings token so it flows through the expander.

Steps:

  1. Galaxy connstr: the current value is Integrated Security=True (no password today), but it is a raw connstr that would carry Password= if the box switches to SQL auth. If the box uses SQL auth, move the whole connstr (or just the password) behind a token, e.g. set appsettings.json:76 to "${secret:sql/mxgateway/galaxy}" and seed sql/mxgateway/galaxy with the full connection string. If the box stays on Integrated Security, skip — leave it as-is and note it in the operator doc. The expander already runs before AddZbGalaxyRepository (:103), so no ordering work is needed.
  2. API-key pepper (SEC-10): to close the "no dedicated env reader" gap while keeping the pepper out of appsettings, add under MxGateway:
    "ApiKeyPepper": "${secret:apikey-pepper/mxgateway}",
    
    then seed apikey-pepper/mxgateway. Because AddZbApiKeyAuth resolves the pepper by config-key name (:70) after the expander runs, the token resolves transparently. Caveat: if the box already supplies MxGateway__ApiKeyPepper via env, that env value overrides appsettings — decide one source and document it (env-supplied stays valid and needs no change; the token path is the alternative). Do not set both to different values.
  3. Seed whichever secrets you introduced (as in Task 4 step 3), then boot-smoke + negative-control the same way.
  4. Build + confirm clean:
    dotnet build src/MxGateway.sln -warnaserror
    
  5. Commit: git commit -am "feat(secrets): (optional) Galaxy connstr + api-key pepper via \${secret:} (G-4)".

Task 6 — AddSecretsAuthorization + mount /admin/secrets in Router & endpoint (G-6)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 4, Task 5, Task 7 (depends on 13)

Mount the RCL page (design §3d/§6). The library's SecretsPage route is /admin/secrets under policy secrets:manage. Because the library AdminRole == "Administrator" matches DashboardRoles.Admin (DashboardRoles.cs:14) verbatim, and the dashboard already gates on role claims (no claim-type mismatch here, unlike ScadaBridge), Administrators satisfy secrets:manage/secrets:reveal automatically and Viewer (DashboardRoles.cs:19) does not.

Files:

  • Modify: ~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs:133-135 — the endpoints.MapRazorComponents<App>().AddInteractiveServerRenderMode().RequireAuthorization(ViewerPolicy) chain (inside MapGatewayDashboard at :56, invoked from GatewayApplication.cs:225). Add .AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly).
  • Modify: ~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Routes.razor:1<Router AppAssembly="@typeof(Routes).Assembly"> (no AdditionalAssemblies today). Add the RCL assembly.
  • Modify: ~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs — register AddSecretsAuthorization() near :98 (after AddGatewayDashboard).

Steps:

  1. Register the authorization policies. In GatewayApplication.cs after AddGatewayDashboard (:98), add:
    builder.Services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
    
    (Additive — composes with the existing services.AddAuthorization(...) block in DashboardServiceCollectionExtensions.cs:144-159. Add using ZB.MOM.WW.Secrets.Ui;.) Both approaches in the brief are valid; the Configure<AuthorizationOptions> form keeps the change out of the dashboard extension.
  2. Endpoint sideDashboardEndpointRouteBuilderExtensions.cs:133-135:
    endpoints.MapRazorComponents<App>()
        .AddInteractiveServerRenderMode()
        .AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly)
        .RequireAuthorization(ViewerPolicy);
    
    (Preserve the existing .RequireAuthorization(ViewerPolicy) — the page itself carries [Authorize(Policy = ManagePolicy)], so viewers reaching the assembly still get 403 on the page.)
  3. Router sideRoutes.razor:1:
    <Router AppAssembly="@typeof(Routes).Assembly"
            AdditionalAssemblies="new[]{ typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly }">
    
    (Both registrations are required — endpoint-side for render-mode discovery, Router-side for client-side routing — or /admin/secrets 404s. Design §3d.)
  4. Build:
    dotnet build src/MxGateway.sln -warnaserror
    
    Expect: 0 warnings, 0 errors.
  5. Browser verify (defer full check to Task 8, quick smoke here): run the gateway, navigate to /admin/secrets unauthenticated → expect redirect to /login; log in as an Administrator (e.g. multi-role) → expect the secrets metadata list renders.
  6. Commit: git commit -am "feat(secrets): mount /admin/secrets + AddSecretsAuthorization in the dashboard (G-6)".

Task 7 — Master-key provider config for the NSSM box (G-5) + operator note

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 4, Task 5, Task 6 (depends on 13)

MxAccessGateway is a single Windows/NSSM box, no clustering (recon grep clean — no Akka), so G-5 is the simple posture from design §5 (mxaccessgw sub-section): Environment master-key provider, delivering ZB_SECRETS_MASTER_KEY via the NSSM service environment exactly how MxGateway__Ldap__ServiceAccountPassword is already delivered (docs/GatewayConfiguration.md:248). The local SQLite store (default SqlitePath) is fine — single node, no shared-KEK requirement. DPAPI is a documented machine-bound alternative (stronger at-rest binding, but ties the store to one machine and complicates DR restore).

Files:

  • Modify: ~/Desktop/MxAccessGateway/docs/GatewayConfiguration.md (add a "Secrets master key" operator section near the existing service-account-password env note at :248)
  • (No code change — Secrets.MasterKey.Source=Environment + EnvVarName=ZB_SECRETS_MASTER_KEY was already set in appsettings in Task 2 step 4.)

Steps:

  1. Add an operator note to docs/GatewayConfiguration.md documenting:
    • The gateway now resolves ${secret:} tokens at startup from an encrypted local SQLite store (Secrets:SqlitePath, default mxgateway-secrets.db under the app dir / C:\ProgramData\MxGateway\...).
    • The master key is delivered out-of-band via the NSSM service env var ZB_SECRETS_MASTER_KEY (base64 32 bytes) — never committed, same discipline as MxGateway__Ldap__ServiceAccountPassword. Provide the nssm set <svc> AppEnvironmentExtra ZB_SECRETS_MASTER_KEY=<base64> pattern.
    • How to seed a secret on the box with the secret CLI (name → value), pointing at the same SqlitePath and with the same ZB_SECRETS_MASTER_KEY present.
    • Alternative: MasterKey.Source=Dpapi (FilePath = machine-bound key file) if a machine-bound key is preferred — note the DR-restore caveat.
    • Rotation / DR note: losing the KEK makes the store unrecoverable; back up the KEK in the org secret vault. (Full KEK rotation RewrapAll is G-8, out of scope — see Deferred.)
  2. No build needed (docs only). Confirm the appsettings Secrets block from Task 2 already names ZB_SECRETS_MASTER_KEY.
  3. Commit: git commit -am "docs(secrets): G-5 master-key operator note (Environment provider via NSSM env)".

Task 8 — Verification gate: 0-warning build, UI reveal+audit, SignalR unaffected

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (final gate; depends on all)

Files:

  • Test/verify only — no source change (fix-forward into the relevant earlier task if something fails).

Steps:

  1. Full build + test, 0 warnings:
    dotnet build src/MxGateway.sln -warnaserror
    dotnet test  src/MxGateway.Tests/MxGateway.Tests.csproj
    
    Expect: 0 warnings; all tests green (incl. the new PreHostSecretExpansion tests).
  2. Boot with real config (secret seeded, ZB_SECRETS_MASTER_KEY set): gateway starts clean; LDAP login works (proves the expanded LDAP password reaches the real AddZbLdapAuth bind, not just the shadow validator).
  3. UI reveal + audit browser check (design §10):
    • Unauthenticated GET /admin/secrets → redirect to /login (policy enforced).
    • Log in as Administrator → metadata-only secret list renders; reveal a secret → plaintext shown in the UI; confirm an audit row was written (via the app's ZB.MOM.WW.Audit writer) carrying no plaintext (the value must not appear in the audit event — HistorianGateway proved this; re-confirm here).
    • Log in as a Viewer-only user → /admin/secrets returns 403 (policy denies non-admins).
  4. SignalR / HubTokenService regression check (design §8.5 / recon surprise): confirm the dashboard's SignalR-backed live surfaces still work after this change (hub connect succeeds, live tiles update). This proves Data Protection was not disturbed — HubTokenService.cs:43 shares the framework-default DP key ring, and nothing in this plan touched DP.
  5. Repo-clean grep: grep -rn "serviceaccount123" src → nothing; no other plaintext credential introduced.
  6. If all green, this is the merge point: FF-merge feat/adopt-zb-secretsmain, push to Gitea.
    git switch main && git merge --ff-only feat/adopt-zb-secrets && git push origin main
    

Testing & rollout

  • Offline / CI: dotnet build src/MxGateway.sln -warnaserror + dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj — 0 warnings, all green including PreHostSecretExpansion (rewrite works; missing ref fails closed).
  • Boot smoke + negative control (Task 4/8): seeded secret + KEK → clean boot & working LDAP login; unseed the secret or drop the KEK → SecretNotFoundException/decrypt-fail at startup (fail-closed proof, mirrors HistorianGateway).
  • UI (Task 8): /admin/secrets — login redirect when anonymous, admin sees metadata + reveal + a plaintext-free audit row, viewer gets 403.
  • SignalR (Task 8): dashboard live surfaces unaffected → confirms DP key ring untouched.
  • Box rollout: on the NSSM box, set ZB_SECRETS_MASTER_KEY via the service env, seed ldap/mxgateway/bind (and any optional Task-5 secrets) with the secret CLI, deploy the new binaries. Rollback path: reverting a ${secret:} token back to the env-var/plaintext override still boots (the env value still binds), so the cutover is reversible per-secret.
  • Follow the family pattern: commit + review each task, FF-merge feat/adopt-zb-secretsmain, push to origin (mxaccessgw), as auth/theme/audit were rolled out.

Deferred / out of scope

Per components/secrets/GAPS.md:

  • G-7 — Akka cluster secret replicator / shared-SQL ISecretStore. N/A for this app (single box, no clustering) beyond the general library gap; no work here.
  • G-8RewrapAll KEK-rotation tooling. Not attempted; the operator note (Task 7) documents KEK backup + the unrecoverable-if-lost caveat only.
  • G-2 / G-3 — Layer-B database-resident driver/connection secrets. This app has none (MXAccess/Wonderware creds are runtime pass-through, never stored — CURRENT-STATE §"MXAccess creds"). No Layer-B work.
  • Kept bespoke as-is (CURRENT-STATE §"Adoption plan" item 5): the peppered-HMAC API-key store (hashed, not reversibly encrypted — already best-practice) and the ACL-protected, null-password TLS PFX. Not migrated.
  • Data Protection is deliberately left on the framework default (SignalR hub tokens only) — not reconfigured (design §8.5).