6be5f746d5
Shared design (two resolution layers, wiring template, master-key/clustering fork) + per-repo executable plans with code-verified anchors and co-located .tasks.json: - mxaccessgw (G-4/G-5/G-6, 8 tasks) - otopcua (G-2/G-4/G-5/G-6, 10 tasks, 2 slices) - scadabridge (G-3/G-4/G-5/G-6, 10 tasks, 2 slices) Linked from components/secrets/GAPS.md.
337 lines
27 KiB
Markdown
337 lines
27 KiB
Markdown
# 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 `<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 `PackageReference`s)
|
||
|
||
**Steps:**
|
||
1. In `nuget.config`, under the `dohertj2-gitea` `<packageSource>` mapping block (after the existing `ZB.MOM.WW.GalaxyRepository` pattern at line 24), add:
|
||
```xml
|
||
<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`:
|
||
```xml
|
||
<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:
|
||
```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<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.)
|
||
```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<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):
|
||
```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 <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:
|
||
```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<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:
|
||
```csharp
|
||
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 side** — `DashboardEndpointRouteBuilderExtensions.cs:133-135`:
|
||
```csharp
|
||
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 side** — `Routes.razor:1`:
|
||
```razor
|
||
<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:
|
||
```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 <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:**
|
||
```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).
|