# MxAccessGateway — Secrets Adoption (G-4, G-5, G-6) Implementation Plan > **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`](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`](../../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. ```bash 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 1–3. Task 5 (optional Galaxy/pepper) depends on 4. Task 6 (UI mount) depends only on 1–3 and is **parallelizable with 4/5**. Task 7 (master-key operator config) is docs/appsettings only, parallelizable with 4–6. 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 2–8) **Files:** - Modify: `~/Desktop/MxAccessGateway/nuget.config:24` (add two `` 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 `PackageReference`s) **Steps:** 1. In `nuget.config`, under the `dohertj2-gitea` `` mapping block (after the existing `ZB.MOM.WW.GalaxyRepository` pattern at line 24), add: ```xml ``` (The second pattern covers `.Abstractions` and `.Ui`.) 2. In `ZB.MOM.WW.MxGateway.Server.csproj`, add to the existing `ZB.MOM.WW.*` `PackageReference` `ItemGroup`: ```xml ``` (`.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: ```bash 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 3–8) 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` 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.) ```bash 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`. ```csharp #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() .MigrateAsync(default).GetAwaiter().GetResult(); var resolver = secretsProvider.GetRequiredService(); 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): ```json "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: ```bash 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 4–8) 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`): ```csharp builder.Services.AddZbSecrets(builder.Configuration, "Secrets"); ``` 2. Build: ```bash 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 1–3) 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:61` → `public 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: ```json "ServiceAccountPassword": "${secret:ldap/mxgateway/bind}", ``` 2. Blank the hardcoded default at `LdapOptions.cs:61`: ```csharp 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`): ```bash export ZB_SECRETS_MASTER_KEY="$(openssl rand -base64 32)" # dev only; box uses the operator-delivered key dotnet run --project /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: ```bash 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: ```bash 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`: ```json "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: ```bash 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 1–3) 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().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` — `` (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: ```csharp builder.Services.Configure(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` form keeps the change out of the dashboard extension. 2. **Endpoint side** — `DashboardEndpointRouteBuilderExtensions.cs:133-135`: ```csharp endpoints.MapRazorComponents() .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 side** — `Routes.razor:1`: ```razor ``` (Both registrations are required — endpoint-side for render-mode discovery, Router-side for client-side routing — or `/admin/secrets` 404s. Design §3d.) 4. Build: ```bash 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 1–3) 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 AppEnvironmentExtra ZB_SECRETS_MASTER_KEY=` 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:** ```bash 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-secrets` → `main`, push to Gitea. ```bash 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-secrets` → `main`, push to `origin` (mxaccessgw), as auth/theme/audit were rolled out. ## Deferred / out of scope Per [`components/secrets/GAPS.md`](../../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-8** — `RewrapAll` 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).