docs(secrets): G-2..G-6 adoption design + 3 per-repo plans

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.
This commit is contained in:
Joseph Doherty
2026-07-16 09:19:33 -04:00
parent e347286f28
commit 6be5f746d5
8 changed files with 1392 additions and 0 deletions
+17
View File
@@ -5,6 +5,23 @@ The delta between each project's [`current-state`](current-state/) and the
reference-consumer-proven**; everything here is per-app adoption of an existing lib (not lib
construction).
## Design + implementation plans (2026-07-16)
G-2 … G-6 are now planned. Shared design + three per-repo executable plans (task-metadata'd,
code-verified anchors, co-located `.tasks.json`):
- [`docs/plans/2026-07-16-secrets-adoption-design.md`](../../docs/plans/2026-07-16-secrets-adoption-design.md)
— shared design: library API, the two resolution layers (pre-host `${secret:}` config
expander vs runtime `ISecretResolver`), wiring template, master-key/clustering fork.
- [`docs/plans/2026-07-16-secrets-adoption-otopcua.md`](../../docs/plans/2026-07-16-secrets-adoption-otopcua.md)
**G-2 + G-4 + G-5 + G-6** (10 tasks, 2 slices; Layer-A config + Layer-B driver secrets).
- [`docs/plans/2026-07-16-secrets-adoption-scadabridge.md`](../../docs/plans/2026-07-16-secrets-adoption-scadabridge.md)
**G-3 + G-4 + G-5 + G-6** (10 tasks; incl. claim-type verification + committed-plaintext cleanup).
- [`docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md`](../../docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md)
**G-4 + G-5 + G-6** (8 tasks; single-box, no Layer B).
G-7/G-8 remain design-only/deferred (below).
## Cross-cutting observations
- **No app has `${secret:}` resolution today.** All three assemble config with the stock
@@ -0,0 +1,320 @@
# Secrets Adoption (G-2 … G-6) — Shared Design
> **For Claude:** This is the shared design that the three per-repo implementation plans
> depend on. It is NOT itself executed. The executable plans are:
> - [`2026-07-16-secrets-adoption-otopcua.md`](2026-07-16-secrets-adoption-otopcua.md) — G-2, G-4, G-5, G-6
> - [`2026-07-16-secrets-adoption-scadabridge.md`](2026-07-16-secrets-adoption-scadabridge.md) — G-3, G-4, G-5, G-6
> - [`2026-07-16-secrets-adoption-mxaccessgw.md`](2026-07-16-secrets-adoption-mxaccessgw.md) — G-4, G-5, G-6
**Goal:** Adopt the already-built, already-published `ZB.MOM.WW.Secrets` library
(envelope-encrypted secrets manager + `${secret:}` config expander + runtime `ISecretResolver`
+ `/admin/secrets` Blazor UI) into the three remaining family apps — OtOpcUa, ScadaBridge,
MxAccessGateway — retiring plaintext credentials at rest.
**Architecture:** No library construction — the lib is built, published to the Gitea feed at
`0.1.2`, and **reference-consumer-proven** in HistorianGateway (live wonder end-to-end,
2026-07-16). Every task below is *per-app wiring* of an existing package, following the
HistorianGateway template verbatim. The gaps map to two distinct resolution layers (below).
**Tech Stack:** .NET 10, C#, `ZB.MOM.WW.Secrets{,.Abstractions,.Ui}` @ `0.1.2` from the Gitea
NuGet feed, AES-256-GCM envelope crypto, SQLite store, ASP.NET Core authorization + Blazor RCL.
**Backlog source:** [`components/secrets/GAPS.md`](../../components/secrets/GAPS.md). Per-app
baselines: [`components/secrets/current-state/`](../../components/secrets/current-state/).
---
## 1. What is being adopted — the library API surface
Verified against `scadaproj/ZB.MOM.WW.Secrets/` (the source of truth). These are the exact
identifiers every plan calls; do not guess variants.
| Concern | Type / member | Notes |
|---|---|---|
| DI registration | `SecretsServiceCollectionExtensions.AddZbSecrets(this IServiceCollection, IConfiguration config, string sectionPath)` | **One overload only.** Both the pre-host throwaway provider and the runtime registration call this identically, section `"Secrets"`. |
| Pre-host expander | `SecretReferenceExpander(ISecretResolver)``Task ExpandConfigurationAsync(IConfigurationRoot config, CancellationToken ct)` | Rewrites `${secret:name}` values **in place** via the `IConfigurationRoot` indexer. Fail-closed: unknown ref throws `SecretNotFoundException`. **Skips** any key whose leaf segment (after the last `:`) starts with `_` (the `_comment` convention). |
| Store migrator | `SqliteSecretsStoreMigrator.MigrateAsync(CancellationToken)` | Idempotent (`CREATE … IF NOT EXISTS`). `AddZbSecrets` also registers `SecretsMigrationHostedService` to run it at startup, but the **pre-host** path must call `MigrateAsync` explicitly before the first resolve. |
| Runtime resolver | `ISecretResolver.GetAsync(SecretName name, CancellationToken ct)``Task<string?>` | **`GetAsync`, not `ResolveAsync`.** Returns decrypted plaintext, or `null` if absent/tombstoned. This is the seam G-2/G-3 driver-secret resolution calls. |
| Secret key type | `readonly record struct SecretName``new SecretName("some/name")` | Normalizes `Trim().ToLowerInvariant()`; validates allow-list `[a-z0-9._/-]`, no `..`, no leading/trailing/`//`. Implicit conversion **to** `string` only. |
| Authorization | `SecretsAuthorization.AddSecretsAuthorization(this AuthorizationOptions)`**in the `.Ui` package** | Adds policies `secrets:manage` (`ManagePolicy`) + `secrets:reveal` (`RevealPolicy`). `AdminRole = nameof(CanonicalRole.Administrator)` == `"Administrator"`, so an existing Administrator satisfies both. Additive — composes with existing `Configure<AuthorizationOptions>` callbacks. |
| UI page | `ZB.MOM.WW.Secrets.Ui.SecretsPage``@page "/admin/secrets"`, `[Authorize(Policy = ManagePolicy)]` | Mount handle: `typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly`. RCL depends on `ZB.MOM.WW.Theme` + `ZB.MOM.WW.Auth.AspNetCore` + `ZB.MOM.WW.Audit`. |
| Master key | `IMasterKeyProvider` via `MasterKeyProviderFactory.Create(MasterKeyOptions)` | `MasterKeySource { Environment, File, Dpapi }`; default `Environment`, env var `ZB_SECRETS_MASTER_KEY` (base64 32 bytes). File/DPAPI use `FilePath`; optional explicit `KekId`. |
| Options | `SecretsOptions` bound from the `"Secrets"` section | `SqlitePath` (default `secrets.db`), `MasterKey`, `RunMigrationsOnStartup` (default true), `ResolveCacheTtl` (default 30 s). |
The `Secrets` appsettings block (matches HistorianGateway):
```json
"Secrets": {
"SqlitePath": "<app>-secrets.db",
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
}
```
---
## 2. The two resolution layers — the load-bearing distinction
The gaps split cleanly across **two different layers** that must not be conflated. This is the
single most important design point, surfaced by the OtOpcUa recon (driver secrets are *not*
`IConfiguration`).
### Layer A — pre-host config expansion (`${secret:}`) — covers G-4 (all apps) + parts of G-2/G-3
Secrets that live in `IConfiguration` (appsettings / env): LDAP passwords, SQL connection
strings, JWT signing keys, deploy/API keys, peppers. These are rewritten **once, before the
host is built**, by a throwaway bootstrap provider — the HistorianGateway template (§3). A
config value becomes the literal token `"${secret:sql/<app>/db-password}"`; the expander
replaces it with the decrypted value before any options validator binds.
### Layer B — runtime resolution (`ISecretResolver.GetAsync`) — covers G-2, G-3
Secrets that live **in a database row as data**, not in config: OtOpcUa's per-driver
`DriverConfig` JSON (Galaxy API key, OpcUaClient `Password`/`UserCertificatePassword`), and
ScadaBridge's MxGateway per-endpoint `ApiKey` inside the `DataConnection` JSON blob. The
pre-host expander never sees these — they are read at driver-instantiation / connection time.
The stored value becomes a `secret:<name>` reference; the **consuming code** calls
`ISecretResolver.GetAsync(new SecretName(name), ct)` at the point of use.
> **Rule of thumb:** if the secret is in appsettings/env → Layer A. If it is a column/JSON
> field in the app's own database → Layer B. G-4 is entirely Layer A. G-2 and G-3 are Layer B.
> G-6 (UI) and G-5 (master key) underpin both.
---
## 3. The proven wiring template (from HistorianGateway `Program.cs`)
Every app reuses these four pieces. Insertion points differ per app (see each plan); the code
shape is identical.
**(a) Pre-host `${secret:}` expansion** — before `builder.Build()` / before any options bind:
```csharp
#pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons
await using (var secretsProvider = new ServiceCollection()
.AddZbSecrets(builder.Configuration, "Secrets")
.BuildServiceProvider())
#pragma warning restore ASP0000
{
await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default);
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
await new SecretReferenceExpander(resolver)
.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default);
}
```
Usings: `ZB.MOM.WW.Secrets.Abstractions`, `.Configuration`, `.DependencyInjection`, `.Sqlite`.
Note the cast to `IConfigurationRoot`. **ScadaBridge differs** — it assembles config in a bare
`ConfigurationBuilder` (not `WebApplication.CreateBuilder`) *before* the host exists, so its
expander runs against that `IConfigurationRoot` local, still via the same throwaway provider.
**(b) Runtime registration** — on the real host container (backs Layer B + the UI):
```csharp
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
```
**(c) Authorization** — additive:
```csharp
builder.Services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
```
**(d) UI mount** — register the RCL assembly in **both** places or `/admin/secrets` 404s:
```csharp
// Router.razor / Routes.razor:
// AdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })"
endpoints.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly);
```
**Package plumbing (every app):** add `ZB.MOM.WW.Secrets`, `.Abstractions`, `.Ui` as
`PackageReference`s at `0.1.2`; add nuget.config `packageSourceMapping` patterns
`ZB.MOM.WW.Secrets` and `ZB.MOM.WW.Secrets.*` under the `dohertj2-gitea` source (none of the
three has a `ZB.MOM.WW.*` wildcard — the mapping is explicit, so restore fails without these).
ScadaBridge additionally needs `<PackageVersion>` rows in `Directory.Packages.props` (CPM).
---
## 4. G-4 — pre-host config-secret expansion (all three apps)
Same Layer-A mechanism (§3a); per-app insertion point and target keys:
| App | Insert expander at | Runs before | Config-secret targets |
|---|---|---|---|
| **OtOpcUa** | after `Host/Program.cs:73` (role overlay done, env/cmdline re-appended) | `AddOtOpcUaConfigDb` (`:94`) + first `ValidateOnStart` (`:102`) | `Security:Jwt:SigningKey`, `Security:Ldap:ServiceAccountPassword`, `Security:DeployApiKey`, `ConfigDb` connstr, `ServerHistorian:ApiKey` |
| **ScadaBridge** | between `Host/Program.cs:43` (`.Build()`) and `:46` (`StartupValidator.Validate`) | `StartupValidator`/`ConfigPreflight` (`:46`) | `Database:ConfigurationDb`, `Database:MachineDataDb`, `Security:Ldap:ServiceAccountPassword`, `Security:JwtSigningKey`, `InboundApi:ApiKeyPepper`**plus** the docker-per-node committed plaintext (see its plan) |
| **mxaccessgw** | after `GatewayApplication.cs:64` (`CreateBuilder`), before `:67` (TLS) | `AddGatewayConfiguration` (`:71`), `AddZbGalaxyRepository` (`:103`), `AddZbApiKeyAuth` | `MxGateway:Ldap:ServiceAccountPassword`, `MxGateway:Galaxy:ConnectionString`, `MxGateway:ApiKeyPepper` |
Each plan also **deletes the committed dev plaintext** (and the loose `*_login.txt` files in
ScadaBridge) and, where a hardcoded default exists in an options class (mxaccessgw
`LdapOptions.cs:61`, `serviceaccount123`), removes it so a blanked appsettings can't fall back
to a leaked default.
---
## 5. G-5 — master-key provider & clustering (the real fork)
The KEK never lives in the store — it is supplied out-of-band, base64 32 bytes, and **never
committed**. The clustering posture differs sharply between the single-box gateway and the two
Akka-clustered apps.
### mxaccessgw — single box, no clustering
- **Recommended: `Environment` provider** (`ZB_SECRETS_MASTER_KEY` delivered via the NSSM
service env, exactly how `MxGateway__Ldap__ServiceAccountPassword` is already delivered).
Consistent with HistorianGateway; simplest operationally.
*Best-practice fit:* container/NSSM env delivery is the standard 12-factor secret-bootstrap;
matches the app's existing out-of-band password convention.
- **Alternative: `Dpapi` provider** (machine-bound key file). Stronger at-rest binding (the key
is unusable off the box) but ties the store to one machine and complicates DR restore.
### OtOpcUa & ScadaBridge — Akka-clustered → every node needs the *same* KEK and the *same* rows
Both apps resolve secrets on multiple nodes (OtOpcUa driver-role nodes resolve Layer-B driver
secrets; both central nodes of a ScadaBridge pair resolve Layer-A config secrets at boot). Two
constraints follow: (1) identical KEK on every node, (2) identical store contents on every node.
The library today ships a **SQLite-only store** with a **`NoOpSecretReplicator`** — there is no
built-in shared-SQL store and no cross-node replication. So the near-term (G-5) posture is:
- **Recommended interim: `File` provider with a shared mounted key** (`MasterKey.Source=File`,
`FilePath` = a read-only mounted secret file identical on every node) **+ a single shared
SQLite store on a shared/replicated volume** (`SqlitePath` → the shared mount). Secrets are
written rarely, from one node's UI/CLI; every node reads the same file.
*Best-practice fit:* satisfies the library's documented hard requirement (same KEK per node)
with zero new code; acceptable because secret **writes** are rare and human-driven while
**reads** dominate. *Caveat:* SQLite over a network share has known multi-writer locking
limits — fine for read-mostly, single-writer; not a substitute for real replication.
- **Alternative (more integrated, more work): a ConfigDb-backed `ISecretStore`** mirroring each
app's existing `AddDataProtection().PersistKeysToDbContext<…>()` pattern (OtOpcUa persists its
DP key ring to ConfigDb precisely to share key material across nodes). This is the "right"
long-term shape but requires building a custom `ISecretStore` — it **overlaps G-7** and is
deferred there, not attempted in this cut.
> **Decision to confirm at execution time:** for the two clustered apps, take the interim
> File-KEK + shared-SQLite posture now (ships G-5 with no new library code) and track the
> ConfigDb/`ISecretStore` integration under G-7 — **unless** you want the integrated store
> built first, which enlarges G-5 into library work. Each clustered plan is written for the
> interim posture and flags the G-7 hand-off.
---
## 6. G-6 — mount `/admin/secrets` (all three apps)
Same mechanism (§3d) in each dashboard:
| App | Dashboard project | Register RCL assembly in |
|---|---|---|
| OtOpcUa | `…OtOpcUa.AdminUI` | `EndpointRouteBuilderExtensions.cs:31` (`MapRazorComponents<TApp>()` — add `.AddAdditionalAssemblies`) **and** thread `AdditionalAssemblies` from `App.razor:22` into `<Routes/>` (param already plumbed at `Routes.razor:8,37-38`) |
| ScadaBridge | `…ScadaBridge.CentralUI` | `EndpointExtensions.cs:28` `.AddAdditionalAssemblies(...)` **and** `Host/Components/Routes.razor:3` `AdditionalAssemblies` |
| mxaccessgw | `…MxGateway.Server` (Dashboard) | `DashboardEndpointRouteBuilderExtensions.cs:133-135` (add `.AddAdditionalAssemblies`) **and** `Dashboard/Components/Routes.razor:1` (add `AdditionalAssemblies`) |
All three already have an `Administrator` canonical role, so `AddSecretsAuthorization()` grants
secrets access to admins with no new role mapping — **except** verify the claim-type match
below.
**Claim-type verification (ScadaBridge especially):** the library's policies use
`RequireRole(...)` (reads `ClaimsIdentity.RoleClaimType` / `ZbClaimTypes.Role`). OtOpcUa and
mxaccessgw already gate on role claims, so they match. ScadaBridge gates on
`RequireClaim(JwtTokenService.RoleClaimType, …)` — its plan includes a task to confirm
`JwtTokenService.RoleClaimType` equals the principal's role-claim type the library reads, or an
Administrator won't satisfy `secrets:manage`/`secrets:reveal` even though ScadaBridge's own
`RequireAdmin` works.
---
## 7. G-2 / G-3 — Layer-B database-resident secrets
### G-2 — OtOpcUa driver secrets (highest value; its own plan, full detail)
Three call sites read `DriverConfig`/API-key JSON and must resolve `secret:` refs at runtime:
`OpcUaClientDriverFactoryExtensions.CreateInstance` (`:54`), `OpcUaClientDriverProbe`
(`:38`, AdminUI Test-Connect), and `GalaxySecretRef.ResolveApiKey` (`:43`, currently a static
method with `env:`/`file:`/`dev:`/literal arms — add a `secret:` arm). Structural gotchas: the
`Drivers` project does not reference `…Security`, so `ISecretResolver` must be threaded in as a
dependency (and `GalaxySecretRef` needs a signature change off `static`); the resolver must be
registered **unconditionally** (driver-role nodes have no auth/Data-Protection/AdminUI); the
AdminUI editors must round-trip `secret:` refs rather than resolving-then-resaving cleartext.
### G-3 — ScadaBridge MxGateway `ApiKey` (its own plan)
The `ApiKey` is a field inside the `DataConnection.PrimaryConfiguration`/`BackupConfiguration`
JSON blob (`MxGatewayEndpointConfig.ApiKey`), consumed at `MxGatewayDataConnection.cs:96`.
Because it is a field *within* a JSON column (not its own column), the existing
`EncryptedStringConverter` cannot target it directly. Two options:
- **Recommended: store a `secret:<name>` ref in the `ApiKey` field and resolve via
`ISecretResolver.GetAsync` at consumption** (`MxGatewayDataConnection.cs:96`). Consistent with
G-2's Layer-B pattern; leaves the JSON blob human-readable; no schema change.
*Best-practice fit:* same indirection model as OtOpcUa's driver secrets — one mental model
across the family.
- **Alternative: encrypt the whole `PrimaryConfiguration`/`BackupConfiguration` column** with
`EncryptedStringConverter`. Fewer code touch-points but encrypts a mixed blob (the scrubber
and any display/edit path must decrypt first), and the column is currently read in several
places — higher blast radius.
---
## 8. Cross-cutting gotchas (apply to every plan)
1. **Fail-closed at boot.** A `${secret:missing}` throws `SecretNotFoundException` during the
pre-host expansion — the app won't start. Seed every referenced secret (via the `secret` CLI
or the UI) before switching a config value to a token. Plans stage this: add the token *and*
seed the secret in the same task, and keep a rollback (the env-var override still works if
the token is reverted).
2. **`_`-prefixed comment keys are skipped** — keep documentation example tokens under a
`_secretsComment`-style key (the family already uses `_secrets`/`_nodeName`); they won't be
resolved. Confirmed safe in every app's appsettings.
3. **Register `AddZbSecrets` on the runtime host too** (§3b) even for apps that only need
Layer A today — the UI (G-6) and any future Layer-B use need the runtime resolver, and
`SecretsMigrationHostedService` keeps the store schema current.
4. **Audit is automatic.** `AddZbSecrets` lazily binds `IAuditWriter` — all three apps have
`ZB.MOM.WW.Audit` registered, so reveals/resolves audit through the app's existing writer
with no ordering constraint (falls back to no-op otherwise). The reveal audit event was
proven to carry **no plaintext** in HistorianGateway.
5. **Do not disturb existing Data Protection key rings.** OtOpcUa/ScadaBridge persist DP keys to
their ConfigDb (`SetApplicationName` on OtOpcUa; none on ScadaBridge); mxaccessgw uses the
framework-default provider for SignalR hub tokens only. Secrets envelope crypto is
independent of Data Protection — do **not** reconfigure DP as part of this work, or you risk
invalidating cookies/hub-tokens.
6. **KEK is never committed** and never printed. Same discipline as `$GITEA_TOKEN`.
---
## 9. Recommended rollout order
1. **mxaccessgw first** — simplest (single box, no clustering, no Layer-B). Proves the
template a second time after HistorianGateway on the least-risky app.
2. **OtOpcUa** — highest security value (retires cleartext-in-DB driver secrets), but the
heaviest (Layer A + Layer B + clustered KEK). Do Layer A (G-4) + UI (G-6) first, then the
Layer-B driver work (G-2) as a separate reviewable slice.
3. **ScadaBridge** — heaviest config-secret surface (incl. docker-committed plaintext) + the
claim-type verification + G-3. Benefits from the OtOpcUa Layer-B pattern being settled first.
Each app is an independent branch (`feat/adopt-zb-secrets` per repo), mirroring how auth /
theme / audit were adopted across the family — commit + review, then FF-merge to the repo
default and push to Gitea.
## 10. Testing strategy (per app)
- **Offline:** unit test the driver/connection resolver hook (G-2/G-3) with a fake
`ISecretResolver`; assert `secret:` refs resolve and unknown refs fail closed. Confirm the
app builds 0-warning with the three new package refs.
- **Boot smoke:** start the app with one config value switched to `${secret:…}` and the secret
seeded; assert clean boot. Negative control: unseed the secret → assert
`SecretNotFoundException` at startup (proves fail-closed, exactly as HistorianGateway did).
- **UI:** browser-verify `/admin/secrets` — unauthenticated → login redirect (policy enforced);
Administrator → metadata-only list + reveal shows plaintext + an audit row with no plaintext.
- **Live (VPN):** for OtOpcUa/ScadaBridge, prove a real driver/connection secret (`secret:` ref)
authenticates real traffic, mirroring the HistorianGateway live proof. Requires the shared
KEK present on the box.
## 11. Task tracking
Each per-repo plan co-locates its `*.tasks.json` beside the `.md`. Deferred items (G-7 Akka
replicator / shared-SQL `ISecretStore`, G-8 `RewrapAll` KEK-rotation) stay in
[`components/secrets/GAPS.md`](../../components/secrets/GAPS.md) and are out of scope here.
@@ -0,0 +1,336 @@
# 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 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 `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 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.)
```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 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`):
```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 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: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 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:
```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 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:**
```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).
@@ -0,0 +1,17 @@
{
"planPath": "docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md",
"design": "docs/plans/2026-07-16-secrets-adoption-design.md",
"app": "MxAccessGateway",
"gaps": ["G-4", "G-5", "G-6"],
"tasks": [
{"id": 1, "subject": "Package references + nuget.config packageSourceMapping", "status": "pending", "classification": "small"},
{"id": 2, "subject": "Pre-host ${secret:} expander in GatewayApplication.CreateBuilder (G-4 mechanism)", "status": "pending", "classification": "standard", "blockedBy": [1]},
{"id": 3, "subject": "Runtime AddZbSecrets registration on the host container", "status": "pending", "classification": "trivial", "blockedBy": [2]},
{"id": 4, "subject": "Switch LDAP ServiceAccountPassword to ${secret:} + seed + remove hardcoded defaults (G-4)", "status": "pending", "classification": "standard", "blockedBy": [3]},
{"id": 5, "subject": "(Optional) Galaxy connstr + API-key pepper to ${secret:}", "status": "pending", "classification": "small", "blockedBy": [4]},
{"id": 6, "subject": "AddSecretsAuthorization + mount /admin/secrets in Router & endpoint (G-6)", "status": "pending", "classification": "standard", "blockedBy": [3]},
{"id": 7, "subject": "Master-key provider config for the NSSM box (G-5, Environment) + operator note", "status": "pending", "classification": "small", "blockedBy": [2]},
{"id": 8, "subject": "Verification gate: 0-warning build, UI reveal+audit, SignalR unaffected", "status": "pending", "classification": "standard", "blockedBy": [1, 2, 3, 4, 5, 6, 7]}
],
"lastUpdated": "2026-07-16"
}
@@ -0,0 +1,312 @@
# OtOpcUa — Secrets Adoption (G-2, 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 **OtOpcUa** — retiring plaintext credentials at rest. Covers **G-4** (pre-host `${secret:}` config-secret expansion), **G-6** (mount `/admin/secrets` UI), **G-5** (clustered master-key posture), and the highest-value gap **G-2** (retire cleartext-in-DB driver secrets via runtime `ISecretResolver`).
**Architecture:** No library construction — every task is per-app *wiring* of an existing package, following the HistorianGateway template (design §3) verbatim. OtOpcUa is the **heaviest** app because it spans **both** resolution layers (design §2):
- **Layer A — pre-host config expansion (`${secret:}`)** — G-4. Config-resident secrets (`Security:Jwt:SigningKey`, `Security:Ldap:ServiceAccountPassword`, `Security:DeployApiKey`, `ConfigDb` connstr, `ServerHistorian:ApiKey`) are rewritten **once, before the host is built**, by a throwaway bootstrap provider. Also underpins G-6 (UI).
- **Layer B — runtime resolution (`ISecretResolver.GetAsync`)** — G-2. Per-driver `DriverConfig` JSON secrets (Galaxy API key, OpcUaClient `Password`/`UserCertificatePassword`) live as **data in a ConfigDb row**, are re-deserialized on every deploy/delta, and are resolved **at the point of use** in driver-factory code. The pre-host expander never sees these.
**Critical clustering fact:** OtOpcUa is **Akka-clustered** (`Program.cs:287-297`). **Layer-B driver secrets resolve on DRIVER-role nodes**, which do NOT register auth / Data-Protection / AdminUI (those are admin-role-gated, `Program.cs:309-324`). Therefore `AddZbSecrets` (the runtime `ISecretResolver` + its KEK access) must be registered **UNCONDITIONALLY**, and every node needs the **same KEK + same store rows** (design §5).
**Tech Stack:** .NET 10, C#, `ZB.MOM.WW.Secrets{,.Abstractions,.Ui}` @ `0.1.2` from the Gitea NuGet feed, AES-256-GCM envelope crypto, SQLite store, ASP.NET Core authorization + Blazor RCL. Solution `ZB.MOM.WW.OtOpcUa.slnx`.
---
## Branch & sequencing
- Branch **`feat/adopt-zb-secrets`** off `origin/master` (OtOpcUa's default branch is **`master`**, not `main` — remote `origin` = `https://gitea.dohertylan.com/dohertj2/lmxopcua`).
- Mirror how auth / theme / audit were adopted across the family: commit + review on the feature branch, then **FF-merge into `master`** and **push to Gitea**. Keep the `feat/*` branch locally as history.
- Do **not** push mid-slice; land Slice 1 reviewed-and-green, then Slice 2.
### Slices
- **Slice 1 — Layer A + UI + plumbing + master-key (lands first, one reviewable slice):**
Task 1 (package plumbing) → Task 2 (pre-host expander, G-4 mechanism) → Task 3 (unconditional runtime `AddZbSecrets`) → Task 4 (switch config secrets to `${secret:}`, G-4) → Task 5 (mount `/admin/secrets`, G-6) → Task 6 (File master-key posture for clustered nodes, G-5). Review + FF-merge + push.
- **Slice 2 — Layer B driver secrets (G-2), separate reviewable slice:**
Task 7 (GalaxySecretRef `secret:` arm) → Task 8 (OpcUaClient factory + probe hooks — both call sites) → Task 9 (AdminUI editor round-trip preserves refs) → Task 10 (verification incl. live Galaxy secret-ref). Review + FF-merge + push.
Slice 2 depends on Slice 1's Task 3 (the runtime `ISecretResolver` registration) being in place. Within a slice, only the noted `Parallelizable with` tasks are independent.
---
## Task 1 — Package references + NuGet.config mapping
**Classification:** small/trivial
**Estimated implement time:** 4 min
**Parallelizable with:** none (gates every other task — restore must succeed first)
Add the three `ZB.MOM.WW.Secrets` packages where they are consumed:
- **Host** (`…OtOpcUa.Host`) — pre-host expander + runtime registration + master-key options: `ZB.MOM.WW.Secrets`, `ZB.MOM.WW.Secrets.Abstractions`.
- **AdminUI** (`…OtOpcUa.AdminUI`) — the `/admin/secrets` UI + authorization: `ZB.MOM.WW.Secrets.Ui` (transitively pulls `.Abstractions`; brings `ZB.MOM.WW.Theme` + `ZB.MOM.WW.Auth.AspNetCore` + `ZB.MOM.WW.Audit`, all already referenced).
- **Drivers projects** — for the Layer-B `ISecretResolver` seam: the `Drivers.Galaxy.Contracts` project (`GalaxySecretRef`) and the `Drivers.OpcUaClient` project (factory/probe) need `ZB.MOM.WW.Secrets.Abstractions` (the `ISecretResolver`/`SecretName` seam lives there — do NOT reference the full `.Secrets` impl or `.Ui` from Drivers).
**Files:**
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` — add `ZB.MOM.WW.Secrets` + `.Abstractions` `PackageReference` @ `0.1.2`
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` — add `ZB.MOM.WW.Secrets.Ui` @ `0.1.2`
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/*.csproj` — add `ZB.MOM.WW.Secrets.Abstractions` @ `0.1.2`
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/*.csproj` — add `ZB.MOM.WW.Secrets.Abstractions` @ `0.1.2`
- `/Users/dohertj2/Desktop/OtOpcUa/NuGet.config:17-27` — the `dohertj2-gitea` `packageSourceMapping` block (explicit patterns, NO wildcard)
**Steps:**
1. Under `dohertj2-gitea` in `NuGet.config` (after line 27, alongside the existing `ZB.MOM.WW.*` explicit patterns) add:
```xml
<package pattern="ZB.MOM.WW.Secrets" />
<package pattern="ZB.MOM.WW.Secrets.*" />
```
Without these, restore 404s (there is no wildcard mapping — verified at NuGet.config:9-29).
2. Add the `PackageReference` rows listed above (OtOpcUa is NOT CPM — versions go inline on each `csproj`, unlike ScadaBridge).
3. `dotnet restore ZB.MOM.WW.OtOpcUa.slnx` → expect all three packages resolve from `dohertj2-gitea`.
4. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → **0 warnings, 0 errors** (packages referenced but not yet used).
---
## Task 2 — Pre-host `${secret:}` expander in Host/Program.cs (G-4 mechanism)
**Classification:** standard
**Estimated implement time:** 5 min
**Parallelizable with:** Task 5, Task 6 (different regions/files)
Insert the Layer-A pre-host expansion (design §3a) into the assembled configuration provider stack. **Insertion point: immediately after `Host/Program.cs:73`** — the role-overlay block (`:59-73`) is complete, and env-vars (`:71`) + command-line (`:72`) have been re-appended to the top of the provider stack, so `${secret:}` tokens supplied by ANY source are visible. It must run **before** `AddOtOpcUaConfigDb(builder.Configuration)` at `:94` (reads the `ConfigDb` connstr) and **before** the first `ValidateOnStart` bind at `:102` (`LdapOptionsValidator`).
This is the mechanism only — no config value is switched to a token yet (Task 4 does that), so this task is behavior-neutral (no `${secret:}` tokens present → expander is a no-op pass).
**Files:**
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:73` (insert after) — before `:94` `AddOtOpcUaConfigDb`, before `:102` first validated bind
- usings: `ZB.MOM.WW.Secrets.Abstractions`, `ZB.MOM.WW.Secrets.Configuration`, `ZB.MOM.WW.Secrets.DependencyInjection`, `ZB.MOM.WW.Secrets.Sqlite`
**Steps:**
1. After `Program.cs:73`, insert the throwaway-provider block (design §3a verbatim):
```csharp
#pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons
await using (var secretsProvider = new ServiceCollection()
.AddZbSecrets(builder.Configuration, "Secrets")
.BuildServiceProvider())
#pragma warning restore ASP0000
{
await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default);
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
await new SecretReferenceExpander(resolver)
.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default);
}
```
Note the cast to `IConfigurationRoot` (a `WebApplicationBuilder.Configuration` is a `ConfigurationManager`, which implements `IConfigurationRoot`).
2. Add a `Secrets` section to `appsettings.json` (design §1 block) — `SqlitePath: "otopcua-secrets.db"`, `MasterKey.Source: "Environment"`, `EnvVarName: "ZB_SECRETS_MASTER_KEY"`, `RunMigrationsOnStartup: true`, `ResolveCacheTtl: "00:00:30"`. (Task 6 revisits `MasterKey` for the clustered posture.)
3. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings.
4. Boot smoke (no tokens present yet, `ZB_SECRETS_MASTER_KEY` seeded with a base64 32-byte dev key): `dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Host` → clean startup, secrets store migrates, expander is a no-op. Expected: identical boot to baseline.
---
## Task 3 — Register `AddZbSecrets` on the runtime host UNCONDITIONALLY (surprise f)
**Classification:** high-risk (cross-node resolution; touches the conditional role-gated registration structure)
**Estimated implement time:** 5 min
**Parallelizable with:** none within Slice 1 (Slice 2's Layer-B tasks depend on this)
Register the runtime `ISecretResolver` on the **real** host container (design §3b). **Registration asymmetry — the biggest structural gotcha (surprise f):** Auth (`AddOtOpcUaAuth`, `Program.cs:312`), Data Protection (`ServiceCollectionExtensions.cs:73-75`), and AdminUI (`Program.cs:313,352`) are all **admin-role-gated** (`Program.cs:309-324` block). But Layer-B driver-secret resolution (Slice 2) runs on **DRIVER-role nodes** (the `hasDriver` block) — a driver-only node has no auth/DP/AdminUI, yet must resolve `DriverConfig` `secret:` refs.
Therefore register `AddZbSecrets(builder.Configuration, "Secrets")` **unconditionally** (outside both the admin-only and driver-only role blocks), so `ISecretResolver` + `SecretsMigrationHostedService` are present on **every** node regardless of role. This is independent of the admin-only DP registration — do NOT reconfigure Data Protection (gotcha §8.5).
**Files:**
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` — add a single `builder.Services.AddZbSecrets(builder.Configuration, "Secrets");` on the unconditional service-registration path (before the role-gated `Program.cs:309-324` admin block and outside the `hasDriver` block); usings from Task 2
**Steps:**
1. Locate the unconditional service-registration region (before the `Program.cs:309-324` admin-only block, before the `hasDriver` block). Add `builder.Services.AddZbSecrets(builder.Configuration, "Secrets");`.
2. Add a code comment: `// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.`
3. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings.
4. Boot smoke, admin+driver node (default): clean boot, `ISecretResolver` resolvable, `SecretsMigrationHostedService` runs once.
5. Boot smoke, driver-only role overlay (`roleSuffix` → driver-only appsettings, no admin block): assert the app boots and `ISecretResolver` is present (no auth/DP registered). Expected: clean boot, resolver available.
---
## Task 4 — G-4: switch config secrets to `${secret:}` + seed + boot smoke + negative control
**Classification:** standard
**Estimated implement time:** 5 min
**Parallelizable with:** Task 5 (different files)
Switch OtOpcUa's Layer-A config-resident secrets to `${secret:}` tokens, seeding each secret first (fail-closed — gotcha §8.1). Targets (design §4 / current-state):
| Config key | Owner (SectionName / const) | Seed name |
|---|---|---|
| `Security:Jwt:SigningKey` | `JwtOptions` `Security/Jwt/JwtOptions.cs:5,9` (MinSigningKeyBytes 32) | `otopcua/jwt/signing-key` |
| `Security:Ldap:ServiceAccountPassword` | `LdapOptions` `Security/Ldap/LdapOptions.cs:22,63` | `otopcua/ldap/service-account-password` |
| `Security:DeployApiKey` | `DeployApiEndpoints` const `AdminUI/Api/DeployApiEndpoints.cs:32` | `otopcua/deploy/api-key` |
| `ConfigDb` connstr | `AddOtOpcUaConfigDb` `Configuration/ServiceCollectionExtensions.cs:10,19-25` | `otopcua/sql/configdb-connstr` (or password-only ref inside the connstr) |
| `ServerHistorian:ApiKey` | `ServerHistorianOptions` SectionName `ServerHistorian` `Runtime/Historian/ServerHistorianOptions.cs:24,46` | `otopcua/historian/api-key` |
**Files:**
- `appsettings.json` / role-overlay appsettings / env-delivery docs — set the five targets to `"${secret:<name>}"` tokens (these are NOT in committed appsettings today per current-state, so the token goes wherever the value is supplied — env or the deployment appsettings; keep a `_secretsComment` example key, skipped per gotcha §8.2)
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs:40-45` — update the "env-var-only, NEVER commit" doc comment to note the value now also accepts a `${secret:}` token resolved pre-host (surprise g)
**Steps:**
1. Seed each secret in the store via the `secret` CLI (or the `/admin/secrets` UI once Task 5 lands) — e.g. `secret set otopcua/jwt/signing-key <value>` against the same `SqlitePath` + KEK the Host uses.
2. Switch each config value to its `${secret:<name>}` token.
3. Update the `ServerHistorianOptions.cs:40-45` doc comment (do not change validation — it stays warn-only).
4. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings.
5. **Boot smoke (positive):** all five secrets seeded → `dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Host` boots clean; `LdapOptionsValidator` (`Program.cs:102`), `ServerHistorianOptionsValidator` (`:127-128`), `OpcUaApplicationHostOptionsValidator` (`:275-276`) all bind resolved plaintext.
6. **Negative control (fail-closed):** unseed one secret (e.g. `otopcua/jwt/signing-key`) → boot **must** throw `SecretNotFoundException` during pre-host expansion, before any validator. Confirms design §8.1. Re-seed to restore.
7. Rollback note: reverting a token to the literal/env value still works (env override path intact) — keep this documented in the task's commit.
---
## Task 5 — G-6: `AddSecretsAuthorization()` + mount `/admin/secrets` (both anchors)
**Classification:** standard
**Estimated implement time:** 5 min
**Parallelizable with:** Task 4 (different files)
Grant admins the secrets policies and mount the UI. OtOpcUa gates on **role claims** already (`FleetAdmin` → role `Administrator` at `Security/ServiceCollectionExtensions.cs:160`), and `AddSecretsAuthorization()` uses `AdminRole = "Administrator"` — so an existing Administrator satisfies `secrets:manage`/`secrets:reveal` with NO new role mapping (GroupToRole/DB grants already emit `"Administrator"`; no claim-type mismatch, unlike ScadaBridge).
The RCL assembly must be registered in **BOTH** places (design §3d / §6) or `/admin/secrets` 404s — SSR discovery via `.AddAdditionalAssemblies` AND interactive routing via the router's `AdditionalAssemblies` param.
**Files:**
- `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:143-173` — inside the `AddAuthorization(...)` block (or right after `AddOtOpcUaAuth` at `Program.cs:312`), add `o.AddSecretsAuthorization();` (the admin-role block is admin-gated, which is correct for the UI — the UI only mounts on admin nodes)
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs:31` — add `.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly)` onto the `MapRazorComponents<TApp>().AddInteractiveServerRenderMode()` chain (`:31-32`)
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor:22` — pass `AdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })"` into `<Routes/>` (the param is already plumbed through `Components/Routes.razor:8,37-38`)
- **Do NOT touch** the stale `Host/Routes.razor` (plain RouteView) — `App.razor`'s `<Routes/>` resolves to the AdminUI one
**Steps:**
1. Add `AddSecretsAuthorization()` in the AdminUI authorization block (via `Configure<AuthorizationOptions>` or directly in the `AddAuthorization` callback at `ServiceCollectionExtensions.cs:143-173`). Confirm it composes additively with the existing `FleetAdmin`/`ConfigEditor`/etc. policies.
2. Add `.AddAdditionalAssemblies(...)` at `EndpointRouteBuilderExtensions.cs:31`.
3. Add the `AdditionalAssemblies` arg to `<Routes/>` at `App.razor:22`.
4. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings.
5. **Browser verify** (AdminUI on `:9000`): unauthenticated `/admin/secrets` → login redirect (policy enforced); Administrator → metadata-only list + **reveal** shows plaintext; confirm an **audit row** is written **with no plaintext** (gotcha §8.4 — reveal audit proven plaintext-free in HistorianGateway). Use `superpowers-extended-cc:executing-plans`' browser step or the multi-role test user (`multi-role`/`password`).
---
## Task 6 — G-5: File master-key posture for clustered nodes (the fork)
**Classification:** small/trivial (config + docs; the interim posture ships no new code)
**Estimated implement time:** 4 min
**Parallelizable with:** Task 4, Task 5
OtOpcUa is Akka-clustered (`Program.cs:287-297`, `AddAkka("otopcua", …)`) → **every node needs the same KEK and the same store rows** (design §5). The library today ships a SQLite-only store + `NoOpSecretReplicator` (no built-in shared-SQL store, no cross-node replication).
**Interim posture (recommended, ships G-5 with no new library code):**
- `MasterKey.Source = File`, `FilePath` = a read-only mounted secret file **identical on every node** (not `Environment`, so all nodes read the same key material without per-node env drift).
- `SqlitePath` → a **single shared SQLite store on a shared/replicated volume** (secrets are written rarely, human-driven, from one node's UI/CLI; reads dominate — acceptable for SQLite's single-writer limits).
**Fork (design §5):** the "right" long-term shape is a **ConfigDb-backed `ISecretStore`** mirroring OtOpcUa's existing `AddDataProtection().PersistKeysToDbContext<OtOpcUaConfigDbContext>()` pattern (`ServiceCollectionExtensions.cs:73-75` — DP key ring is persisted to ConfigDb precisely to share key material across nodes). That requires building a custom `ISecretStore`, **overlaps G-7**, and is **deferred there** — NOT attempted in this cut. Flag the hand-off in the commit.
**Files:**
- `appsettings.json` (or deployment/role-overlay appsettings) — `Secrets.MasterKey.Source: "File"`, `FilePath: "<shared-mount>/otopcua-master.key"`; `Secrets.SqlitePath: "<shared-mount>/otopcua-secrets.db"`
- deployment runbook / docs — document the shared mount + KEK delivery (KEK never committed, gotcha §8.6); note the G-7 hand-off
**Steps:**
1. Set `MasterKey.Source=File` + shared `FilePath` + shared `SqlitePath` in the deployment appsettings (dev can stay `Environment` for single-node testing).
2. Add a doc note: interim File-KEK + shared-SQLite now; ConfigDb-backed `ISecretStore` tracked under G-7.
3. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings.
4. Multi-node smoke (if a 2-node docker-dev rig is available): seed a secret from node A's UI, resolve it on node B → same value (proves shared store + shared KEK). Otherwise document as a live-gate for rollout.
**Slice 1 gate:** review, FF-merge `feat/adopt-zb-secrets` → `master`, push to Gitea. Then proceed to Slice 2.
---
## Task 7 — G-2a: add `secret:` arm to `GalaxySecretRef` (Layer B)
**Classification:** high-risk (signature change on a static method consumed by the driver + AdminUI; touches the driver model)
**Estimated implement time:** 5 min
**Parallelizable with:** none (Task 8 shares the OpcUaClient path but is a separate file; can proceed after this if reviewers want serial)
Add a `secret:` arm to `GalaxySecretRef.ResolveApiKey` (`Driver.Galaxy.Contracts/GalaxySecretRef.cs`), resolving via `ISecretResolver.GetAsync`, replacing the cleartext-in-DB `dev:`/literal path. The class doc (`:23-24`) already anticipates this. **Structural change:** `ResolveApiKey` is `public static string ResolveApiKey(string secretRef, ILogger? logger = null)` (`:43`) with no DI — it must accept an `ISecretResolver` parameter (signature change). Both call sites (`GalaxyDriver` + AdminUI `GalaxyDriverBrowser`, per the remarks at `:26-29`) must be updated to thread the resolver in.
**TDD.** Write the fake-`ISecretResolver` unit test first.
**Files:**
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs:43-88` — add `secret:` arm before the literal fall-through (~`:78`); change signature to accept `ISecretResolver` (and go `async Task<string>` since `GetAsync` is async — or resolve synchronously via the resolver's cache if a sync seam exists; prefer async and propagate)
- call site 1: `GalaxyDriver` (in `…Driver.Galaxy`) — thread `ISecretResolver` (ctor-injected)
- call site 2: AdminUI `GalaxyDriverBrowser` — thread `ISecretResolver` (DI-injected)
- new test: `src/Drivers/…Driver.Galaxy.Tests/GalaxySecretRefTests.cs` (or existing test project)
**Steps:**
1. **Test first:** add `GalaxySecretRefTests` — a fake `ISecretResolver` returning a known value for `secret:galaxy/inst1/apikey`; assert (a) `secret:` ref resolves to the fake value, (b) an unknown `secret:` ref fails closed (resolver returns `null` → method throws/returns fail-closed, not silent literal fall-through), (c) existing `env:`/`file:`/`dev:`/literal arms unchanged. `dotnet test --filter "FullyQualifiedName~GalaxySecretRefTests"` → RED.
2. Add the `secret:` arm: `if (secretRef.StartsWith("secret:")) { var name = secretRef["secret:".Length..]; var val = await resolver.GetAsync(new SecretName(name), ct); return val ?? throw/fail-closed; }` before the literal fall-through.
3. Change the signature to accept `ISecretResolver` (+ `CancellationToken`); make async as needed.
4. Update `GalaxyDriver` + `GalaxyDriverBrowser` call sites to inject + pass the resolver.
5. `dotnet test --filter "FullyQualifiedName~GalaxySecretRefTests"` → GREEN.
6. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings; `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → driver unit suite green.
---
## Task 8 — G-2b: `ISecretResolver` hook in OpcUaClient factory AND probe (both call sites)
**Classification:** high-risk (driver-factory + actor model; TWO call sites must adopt or Test-Connect and runtime diverge)
**Estimated implement time:** 5 min
**Parallelizable with:** Task 7 (separate driver)
Resolve `secret:` refs for the OpcUaClient driver's `Password` (`OpcUaClientDriverOptions.cs:76`) and `UserCertificatePassword` (`:93`) at the **deserialization hook** — where the `DriverConfig` JSON is deserialized into `OpcUaClientDriverOptions`. **Two hook sites, both mandatory** (surprise d — resolve at BOTH or Test-Connect and runtime diverge):
- (a) `OpcUaClientDriverFactoryExtensions.CreateInstance(driverInstanceId, driverConfigJson, ILoggerFactory?)` (`:48-59`) — `JsonSerializer.Deserialize<OpcUaClientDriverOptions>(...)` at `:54` (shared `JsonOptions` `:27-32`). Runtime path (`DriverHostActor.cs:1704` passes `spec.DriverConfig`, re-sent on `InitializeRequested` `:1756` / `ApplyDelta` `:1790` — surprise b).
- (b) `OpcUaClientDriverProbe.cs:38` (AdminUI Test-Connect), same shape, opts at `:24`.
**Structural gotcha (surprise c):** the `Drivers` project has no DI/`IConfiguration` — `ISecretResolver` must be threaded in as a ctor/param dependency. The factory is registered via `AddOtOpcUaDriverFactories()` at `Program.cs:216`; wire the resolver into the factory registration. The resolver is guaranteed present because Task 3 registered `AddZbSecrets` unconditionally.
**TDD.** Fake-`ISecretResolver` unit test first.
**Files:**
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs:48-59` (deserialize at `:54`) — after deserialize, resolve `secret:`-prefixed `Password`/`UserCertificatePassword` via injected `ISecretResolver`
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs:38` (opts at `:24`) — same resolution
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:216` — `AddOtOpcUaDriverFactories()` registration threads `ISecretResolver` into the factory
- `OpcUaClientDriver.cs:376-385,480-495` — consumers (`BuildUserIdentity` `:382`, `BuildCertificateIdentity`/`LoadPkcs12FromFile` `:495`) receive already-resolved plaintext (no change needed if resolution happens at deserialization)
- new test: `…Driver.OpcUaClient.Tests` — factory + probe resolve `secret:` refs
**Steps:**
1. **Test first:** fake `ISecretResolver`; assert factory `CreateInstance` with `DriverConfig` JSON where `Password="secret:opcua/inst/password"` yields options carrying the resolved plaintext; assert unknown ref fails closed; mirror for the probe. `dotnet test --filter "FullyQualifiedName~OpcUaClientDriver"` → RED.
2. Add a shared helper (e.g. `ResolveSecretRefs(OpcUaClientDriverOptions, ISecretResolver, ct)`) that resolves `secret:`-prefixed `Password`/`UserCertificatePassword`; call it from BOTH `CreateInstance:54` and `Probe:38`.
3. Thread `ISecretResolver` into the factory (via `AddOtOpcUaDriverFactories()` at `Program.cs:216`) and into the probe's DI.
4. `dotnet test --filter "FullyQualifiedName~OpcUaClientDriver"` → GREEN.
5. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings; `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → driver suite green.
---
## Task 9 — G-2c: AdminUI driver-config editor round-trip preserves `secret:` refs (surprise e)
**Classification:** standard (verify/guard; risk is silent cleartext-resave)
**Estimated implement time:** 5 min
**Parallelizable with:** none (validates Tasks 7-8)
Driver secrets are stored **cleartext in the ConfigDb** today (`GalaxySecretRef.cs:84-86` warns). The AdminUI driver-config editors emit/read that same `DriverConfig` JSON. **The UI round-trip must PRESERVE the `secret:`/`env:`/`file:` refs — it must NOT resolve-then-resave cleartext** (which would defeat the whole point and re-leak the secret into the DB). Verify the editor persists the raw ref string, and add a guard/test.
**Files:**
- AdminUI driver-config editor components (Galaxy + OpcUaClient driver config edit pages) + their save path — confirm they persist the raw `DriverConfig` JSON (with `secret:` refs intact), not a resolved-plaintext round-trip
- `GalaxyDriverBrowser` (Task 7 call site) — ensure browse/test resolves for the *live call* but does NOT write the resolved value back
- test: editor save round-trip preserves a `secret:` ref verbatim
**Steps:**
1. Trace the AdminUI editor save path for Galaxy + OpcUaClient driver configs; confirm the persisted `DriverConfig` JSON is the user-entered ref, unresolved.
2. If any path resolves-then-saves, fix it to persist the ref; resolution happens only at driver-instantiation/test time (Tasks 7-8).
3. Add a round-trip test: save `{"Password":"secret:opcua/x/pw"}` → reload → assert the stored value is still `secret:opcua/x/pw` (not plaintext).
4. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → green.
5. Browser check: edit a driver config with a `secret:` ref, save, reopen → ref shown, not plaintext; ConfigDb row holds the ref.
---
## Task 10 — Verification (Slice 2 gate)
**Classification:** standard
**Estimated implement time:** 5 min
**Parallelizable with:** none (final gate)
**Steps:**
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → **0 warnings** (repo builds under warnings-as-errors discipline).
2. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → full suite green (driver unit tests incl. the new `GalaxySecretRef` + `OpcUaClient` secret-resolution tests).
3. Boot smoke, admin+driver node: `/admin/secrets` reachable, config secrets resolve, clean boot.
4. Boot smoke, driver-only node: resolves a `DriverConfig` `secret:` ref with no auth/DP/AdminUI present (proves surprise f / Task 3).
5. **Live (VPN, wonder):** configure a Galaxy driver instance with an `ApiKeySecretRef = "secret:galaxy/<inst>/apikey"`, seed the real gateway key, deploy → assert the driver **authenticates real MxAccess traffic** through the resolved key (mirrors HistorianGateway's live proof; requires the shared KEK present on the box). Also verify an OpcUaClient driver with a `secret:` `Password` connects.
6. Review, FF-merge `feat/adopt-zb-secrets` → `master`, push to Gitea.
---
## Testing & rollout
- **Offline (per design §10):** fake-`ISecretResolver` unit tests for `GalaxySecretRef` + OpcUaClient factory/probe (assert `secret:` resolves + unknown fails closed); 0-warning build with the three new package refs.
- **Boot smoke:** one config value switched to `${secret:…}` + seeded → clean boot; **negative control** (unseed → `SecretNotFoundException` at startup, fail-closed).
- **UI:** browser-verify `/admin/secrets` — unauth → login redirect; Administrator → metadata list + reveal shows plaintext + audit row carries NO plaintext.
- **Live (VPN):** real Galaxy + OpcUaClient `secret:` refs authenticate real traffic on wonder, with the shared KEK on the box.
- **Rollout:** Slice 1 (Layer A + UI + master-key) merges first; Slice 2 (Layer B driver secrets) merges after review. Each slice: commit + review → FF-merge to `master` → push to Gitea. Seed all referenced secrets before switching any config value to a token (fail-closed staging, gotcha §8.1).
## Deferred / out of scope (per components/secrets/GAPS.md)
- **G-7 — clustered shared store / replicator.** The interim posture (Task 6) is File-KEK + shared-SQLite on a shared volume. The integrated **ConfigDb-backed `ISecretStore`** (mirroring OtOpcUa's DP `PersistKeysToDbContext` pattern) and the Akka `ZB.MOM.WW.Secrets.Akka` replicator are G-7 — NOT built here. Task 6 flags the hand-off.
- **G-8 — `RewrapAll` KEK rotation.** Rewrapping all envelopes under a new master key is deferred to G-8.
- **Not touched (keep bespoke):** the Data-Protection cookie/JWT key ring (do NOT reconfigure DP — gotcha §8.5), in-memory end-user OPC-UA passwords, and the file-system OPC UA PKI Directory stores.
@@ -0,0 +1,23 @@
{
"planPath": "docs/plans/2026-07-16-secrets-adoption-otopcua.md",
"design": "docs/plans/2026-07-16-secrets-adoption-design.md",
"app": "OtOpcUa",
"gaps": ["G-2", "G-4", "G-5", "G-6"],
"slices": {
"1": {"desc": "Layer A (G-4) + UI (G-6) + plumbing + master-key (G-5)", "tasks": [1, 2, 3, 4, 5, 6]},
"2": {"desc": "Layer B driver secrets (G-2)", "tasks": [7, 8, 9, 10]}
},
"tasks": [
{"id": 1, "subject": "Package references + NuGet.config mapping", "status": "pending", "classification": "small", "slice": 1},
{"id": 2, "subject": "Pre-host ${secret:} expander in Host/Program.cs (G-4 mechanism)", "status": "pending", "classification": "standard", "slice": 1, "blockedBy": [1]},
{"id": 3, "subject": "Register AddZbSecrets on the runtime host UNCONDITIONALLY (surprise f)", "status": "pending", "classification": "high-risk", "slice": 1, "blockedBy": [2]},
{"id": 4, "subject": "G-4: switch config secrets to ${secret:} + seed + boot smoke + negative control", "status": "pending", "classification": "standard", "slice": 1, "blockedBy": [3]},
{"id": 5, "subject": "G-6: AddSecretsAuthorization() + mount /admin/secrets (both anchors)", "status": "pending", "classification": "standard", "slice": 1, "blockedBy": [3]},
{"id": 6, "subject": "G-5: File master-key posture for clustered nodes (the fork)", "status": "pending", "classification": "small", "slice": 1, "blockedBy": [2]},
{"id": 7, "subject": "G-2a: add secret: arm to GalaxySecretRef (Layer B)", "status": "pending", "classification": "high-risk", "slice": 2, "blockedBy": [3]},
{"id": 8, "subject": "G-2b: ISecretResolver hook in OpcUaClient factory AND probe (both call sites)", "status": "pending", "classification": "high-risk", "slice": 2, "blockedBy": [3]},
{"id": 9, "subject": "G-2c: AdminUI driver-config editor round-trip preserves secret: refs (surprise e)", "status": "pending", "classification": "standard", "slice": 2, "blockedBy": [7, 8]},
{"id": 10, "subject": "Verification (Slice 2 gate)", "status": "pending", "classification": "standard", "slice": 2, "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9]}
],
"lastUpdated": "2026-07-16"
}
@@ -0,0 +1,344 @@
# ScadaBridge — Secrets Adoption (G-3, 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`](2026-07-16-secrets-adoption-design.md) — read it first; this plan **references** that design (library API §1, two layers §2, wiring template §3, G-4 §4, G-5 §5, G-6 §6 incl. the ScadaBridge claim-type caveat, G-3 §7, gotchas §8) rather than re-deriving it.
**Goal:** Adopt the already-built, already-published `ZB.MOM.WW.Secrets` library (`0.1.2`, Gitea feed, reference-consumer-proven in HistorianGateway) into **ScadaBridge** — retiring plaintext credentials at rest. Concretely: **G-3** (retire the MxGateway per-endpoint `ApiKey` plaintext-in-ConfigDb via a runtime `ISecretResolver` `secret:` ref), **G-4** (pre-host `${secret:}` config-secret expansion — ScadaBridge is the heaviest config-secret surface in the family), **G-5** (clustered master-key posture for the central pair + site nodes), **G-6** (mount `/admin/secrets` in CentralUI, with a mandatory claim-type verification).
**Architecture:** No library construction — pure per-app wiring of an existing package following the HistorianGateway template (design §3). Two resolution layers (design §2): **Layer A** = pre-host `${secret:}` config expansion (G-4, entirely config-resident); **Layer B** = runtime `ISecretResolver.GetAsync` for database-resident secrets (G-3, the MxGateway `ApiKey` inside a `DataConnection` JSON blob). ScadaBridge is **Akka-clustered** (central pair + N site nodes) → every node that resolves a `${secret:}` needs the **same KEK and the same store rows** (design §5). Two ScadaBridge-specific hazards drive dedicated tasks: (1) **docker per-node appsettings commit real plaintext dev secrets** plus loose `*_login.txt` files at repo root; (2) a **claim-type mismatch risk** — the library's `AddSecretsAuthorization()` uses `RequireRole(...)` while ScadaBridge authz uses `RequireClaim(JwtTokenService.RoleClaimType, …)`, so an Administrator may not satisfy `secrets:manage`/`secrets:reveal` without alignment.
**Tech Stack:** .NET 10, C#, `ZB.MOM.WW.Secrets{,.Abstractions,.Ui}` @ `0.1.2` from the Gitea NuGet feed (`dohertj2-gitea`), AES-256-GCM envelope crypto, SQLite store, ASP.NET Core authorization + Blazor Server RCL. ScadaBridge uses **Central Package Management** (`Directory.Packages.props`). Solution: `ZB.MOM.WW.ScadaBridge.slnx`.
---
## Branch & sequencing
- Branch `feat/adopt-zb-secrets` off `origin/main` in `~/Desktop/ScadaBridge` (mirrors the auth/theme/audit adoption pattern across the family).
- Commit per task with a clear message; request review at the slice boundaries below.
- On completion: FF-merge to the repo default (`main`) and push to Gitea (`origin`).
- Keep the branch buildable at every commit: never switch a config value to a `${secret:}` token without seeding the secret in the same task (fail-closed at boot — design §8.1). The whole-key env override still works if a token is reverted (rollback path).
## Slices
- **Slice 1 — config + UI + clustering (mostly standard/small):** Tasks 18. Package plumbing (CPM + nuget mapping), the Layer-A pre-host `${secret:}` expander (G-4 mechanism), runtime `AddZbSecrets`, the G-4 config-secret migration + committed-plaintext cleanup, the G-6 claim-type verification + `/admin/secrets` mount, and the G-5 clustered master-key posture. No product data-path changes. Review at end of slice.
- **Slice 2 — G-3 Layer-B MxGateway `ApiKey` (high-risk):** Task 9. Injects `ISecretResolver` into the DCL `MxGatewayDataConnection` adapter and resolves a `secret:`-ref `ApiKey` at the point of use. Touches the runtime data path — TDD, separate review. Task 10 is final verification across both slices.
---
## Task 1 — Package references + CPM versions + nuget.config mapping
**Classification:** small
**Estimated implement time:** 4 min
**Parallelizable with:** none (foundation — Tasks 29 depend on restore succeeding)
**Files:**
- `nuget.config` (repo root) — `packageSourceMapping` for `dohertj2-gitea` at `:13-29` (patterns `:18-27`, no `ZB.MOM.WW.*` wildcard)
- `Directory.Packages.props` (repo root) — existing ZB.MOM `<PackageVersion>` block at `:75-88`
- `src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj` — add three `<PackageReference>` (no `Version` attr under CPM)
**Steps:**
1. In `nuget.config`, under the `<packageSource key="dohertj2-gitea">` mapping (after `:27`), add:
```xml
<package pattern="ZB.MOM.WW.Secrets" />
<package pattern="ZB.MOM.WW.Secrets.*" />
```
(The mapping is explicit with **no** wildcard — restore fails without these two patterns.)
2. In `Directory.Packages.props`, in the ZB.MOM block (`:75-88`), add three CPM version rows:
```xml
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.1.2" />
```
3. In `ZB.MOM.WW.ScadaBridge.Host.csproj`, add three version-less `<PackageReference Include="ZB.MOM.WW.Secrets" />`, `…Secrets.Abstractions`, `…Secrets.Ui` (the Host is where the pre-host expander, runtime registration, and CentralUI/App live). The `.Ui` package transitively pulls `ZB.MOM.WW.Theme` + `ZB.MOM.WW.Auth.AspNetCore` + `ZB.MOM.WW.Audit` — all already referenced, so no conflict.
4. Restore: `dotnet restore ZB.MOM.WW.ScadaBridge.slnx`
- **Expected:** restore succeeds, all three packages resolve at `0.1.2` from `dohertj2-gitea`. If restore 404s, re-check the two nuget mapping patterns (Task premise).
5. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
- **Expected:** 0 warnings, 0 errors (nothing consumes the packages yet).
---
## Task 2 — Layer-A pre-host `${secret:}` expander (G-4 mechanism)
**Classification:** standard
**Estimated implement time:** 5 min
**Parallelizable with:** Task 6 (verification-only) after Task 1
**Files:**
- `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:38-43` (`ConfigurationBuilder…Build()` → `var configuration`), insert **between `:43` and `:46`** (`StartupValidator.Validate(configuration)`)
**Context (design §3, ScadaBridge variant):** ScadaBridge assembles config in a **bare `ConfigurationBuilder` before `WebApplication.CreateBuilder` exists** (`:38-43`), producing an `IConfigurationRoot` local named `configuration`. The expander must run against that local via a **throwaway `ServiceCollection` provider** (bootstrap chicken-and-egg — surprise e: no host DI yet). It MUST run **before `:46`** or `StartupValidator`/`ConfigPreflight` validates unexpanded `${…}` tokens. Top-level statements at `Program.cs` are already async, so `await` is legal here.
**Steps:**
1. Add usings at the top of `Program.cs`: `ZB.MOM.WW.Secrets.Abstractions`, `ZB.MOM.WW.Secrets.Configuration`, `ZB.MOM.WW.Secrets.DependencyInjection`, `ZB.MOM.WW.Secrets.Sqlite`.
2. Immediately after `:43` (the `.Build()` assigning `configuration`) and before `:46`, insert the throwaway-provider expander (design §3a):
```csharp
#pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons
await using (var secretsProvider = new ServiceCollection()
.AddZbSecrets(configuration, "Secrets")
.BuildServiceProvider())
#pragma warning restore ASP0000
{
await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default);
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
await new SecretReferenceExpander(resolver)
.ExpandConfigurationAsync(configuration, default);
}
```
Note: `configuration` is already an `IConfigurationRoot` (result of `.Build()`), so no cast is needed here (unlike the `builder.Configuration` case in the shared template). `_`-prefixed keys (e.g. `_secrets`, `_nodeName`) are auto-skipped by the expander (design §8.2).
3. Add a `"Secrets"` block to `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json` (shared across roles) matching the HistorianGateway shape (design §1):
```json
"Secrets": {
"SqlitePath": "scadabridge-secrets.db",
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
}
```
(Task 8 revises `MasterKey`/`SqlitePath` for the clustered posture.)
4. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
- **Expected:** 0 warnings. With no `${secret:…}` tokens yet in config, the expander is a no-op pass; the store migrates and the app still boots on env-override values.
---
## Task 3 — Runtime `AddZbSecrets` on the host container
**Classification:** small
**Estimated implement time:** 3 min
**Parallelizable with:** Task 4
**Files:**
- `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` — on the real host `builder.Services` (the `WebApplicationBuilder` created after `:46`; the DI-registration region alongside `AddSecurity`/`AddCentralUI`, Program.cs:159-160)
**Context (design §3b, §8.3):** even though G-4 is Layer A, the **runtime** resolver is required for the UI (G-6) and the G-3 Layer-B hook (Task 9). `AddZbSecrets` also registers `SecretsMigrationHostedService` (keeps the store schema current) and lazily binds `IAuditWriter` — ScadaBridge already registers `ZB.MOM.WW.Audit`, so secret reveals/resolves audit automatically through the app's writer (design §8.4).
**Steps:**
1. Locate the host `builder` and its service-registration block (near `AddSecurity`/`AddCentralUI`, Program.cs:159-160). Add:
```csharp
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
```
2. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
- **Expected:** 0 warnings. `ISecretResolver` is now injectable on the runtime container.
---
## Task 4 — G-4: formalize `${SCADABRIDGE_*}` placeholders → `${secret:}` tokens
**Classification:** standard
**Estimated implement time:** 5 min
**Parallelizable with:** Task 3
**Files:**
- `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json` — `_secrets` note `:21`; `Database:ConfigurationDb` `:23`; `Security:Ldap:ServiceAccountPassword` `:33`; `Security:JwtSigningKey` `:35`
**Context (current-state §"Config expansion today"; design §4):** the `${SCADABRIDGE_*}` tokens at `appsettings.Central.json:23,33,35` are **non-functional literal placeholders** today — real values arrive by whole-key env override (`ScadaBridge__…`). The whole-key-override convention is exactly what `${secret:}` replaces. The five config-secret targets and their validators:
- `ScadaBridge:Database:ConfigurationDb` — read `Program.cs:221-222`, validated `StartupValidator.cs:60-62`
- `ScadaBridge:Database:MachineDataDb` — validated `StartupValidator.cs:63-65`
- `ScadaBridge:Security:Ldap:ServiceAccountPassword` — bound via `AddZbLdapAuth(builder.Configuration, LdapSectionPath)` `Program.cs:140-142` (`LdapSectionPath="ScadaBridge:Security:Ldap"`, `Security/ServiceCollectionExtensions.cs:24`)
- `ScadaBridge:Security:JwtSigningKey` — `SecurityOptions.cs:34`, bound `Program.cs:299`
- `ScadaBridge:InboundApi:ApiKeyPepper` (≥16 chars) — `InboundAPI/InboundApiOptions.cs:36`, validated `StartupValidator.cs:89-91`
**Steps:**
1. In `appsettings.Central.json`, replace the whole-key `${SCADABRIDGE_*}` tokens with `${secret:}` refs (canonical `sql/…`, `ldap/…`, `security/…` naming), e.g.:
```json
"ConfigurationDb": "${secret:sql/scadabridge/configdb-connection}",
"ServiceAccountPassword": "${secret:ldap/scadabridge/service-account-password}",
"JwtSigningKey": "${secret:security/scadabridge/jwt-signing-key}"
```
Also cover `Database:MachineDataDb` and `InboundApi:ApiKeyPepper` wherever they appear as literal secrets in `appsettings.Central.json`. Update the `_secrets` note (`:21`) to document the `${secret:}` convention (this key is `_`-prefixed → skipped by the expander; safe for docs).
2. **Seed each referenced secret** (same task — fail-closed, design §8.1). Use the `secret` CLI shipped with the package, e.g.:
```bash
dotnet ZB.MOM.WW.Secrets.Cli.dll set sql/scadabridge/configdb-connection --value '<connstr>'
```
(or seed via the `/admin/secrets` UI once Task 7 lands, then re-run boot). Ensure `ZB_SECRETS_MASTER_KEY` is exported for the seeding process so the same KEK encrypts the rows the app will read.
3. **Boot smoke:** start the Host with `SCADABRIDGE_CONFIG=Central` and the secrets seeded; assert clean boot (`/healthz` 200) and that `StartupValidator` passes (values expanded before `:46`).
4. **Negative control (fail-closed):** unseed/rename one secret → restart → assert `SecretNotFoundException` at startup before validation. Re-seed to restore. (Mirrors the HistorianGateway proof; design §10.)
5. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** 0 warnings.
---
## Task 5 — G-4 cleanup: remove committed dev plaintext + loose `*_login.txt`
**Classification:** small
**Estimated implement time:** 4 min
**Parallelizable with:** Task 4 (adjacent files; coordinate if the same appsettings is touched)
**Files:**
- `docker/central-node-a/appsettings.Central.json` — SQL passwords `:21-22` (`ScadaBridge_Dev1#`), `ServiceAccountPassword: "serviceaccount123"` `:32`, `JwtSigningKey` `:38`
- `docker/central-node-b/appsettings.Central.json` — same fields
- `ldap_login.txt`, `sql_login.txt`, `sqllogin.txt` (repo root, all git-tracked)
**Context (surprises a + b):** the docker per-node files commit **real** plaintext dev secrets, and three loose credential files are tracked at the repo root. Both are unnecessary once `${secret:}` + env-delivered KEK is the convention.
**Steps:**
1. In `docker/central-node-a/appsettings.Central.json` and `docker/central-node-b/appsettings.Central.json`, replace the committed plaintext SQL passwords (`:21-22`), `ServiceAccountPassword` (`:32`, `serviceaccount123`), and `JwtSigningKey` (`:38`) with `${secret:…}` refs matching Task 4's names — OR, if these are intentionally dev-only, delete them entirely and rely on the docker-compose env (`ZB_SECRETS_MASTER_KEY` + seeded dev store). Do not leave real plaintext committed.
2. Delete the loose tracked credential files:
```bash
git -C ~/Desktop/ScadaBridge rm ldap_login.txt sql_login.txt sqllogin.txt
```
3. Grep-sweep for any remaining committed plaintext:
```bash
git -C ~/Desktop/ScadaBridge grep -nE 'ScadaBridge_Dev1#|serviceaccount123|scadabridge-dev-jwt-signing-key' -- '*.json' '*.txt'
```
- **Expected:** no hits after the edits.
4. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** 0 warnings (docker files aren't compiled; this just confirms nothing else broke).
---
## Task 6 — G-6: claim-type VERIFICATION (RequireRole vs RequireClaim)
**Classification:** high-risk (authz — a mismatch silently denies admins)
**Estimated implement time:** 5 min
**Parallelizable with:** Task 2/3 (read-only inspection until remediation is needed)
**Files (inspection):**
- `src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs:157-197` (`AddScadaBridgeAuthorization` — policies use `policy.RequireClaim(JwtTokenService.RoleClaimType, …)`, `:162` etc.)
- `src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs:45` (`Administrator = "Administrator"`)
- `JwtTokenService` (where `RoleClaimType` is defined) — locate via `grep -rn "RoleClaimType" src/ZB.MOM.WW.ScadaBridge.Security`
- Library: `SecretsAuthorization.AddSecretsAuthorization()` (`.Ui` pkg) uses `RequireRole(...)` → reads `ClaimsIdentity.RoleClaimType` / `ZbClaimTypes.Role`
**Context (design §6 claim-type caveat; surprise d):** the library's `secrets:manage`/`secrets:reveal` policies use `RequireRole(...)`, which reads the principal's `ClaimsIdentity.RoleClaimType` (the library expects `ZbClaimTypes.Role`). ScadaBridge's own policies use `RequireClaim(JwtTokenService.RoleClaimType, …)`. If `JwtTokenService.RoleClaimType` is **not** the same claim type the framework's `RequireRole` reads, an Administrator will satisfy `RequireAdmin` but **not** the library's secrets policies → the `/admin/secrets` page 403s for admins even though everything else works.
**Steps:**
1. Determine `JwtTokenService.RoleClaimType`'s value and confirm whether the authenticated `ClaimsPrincipal`'s identity is constructed with `RoleClaimType` set to that same value (i.e. does the JWT/cookie identity map role claims onto `ClaimsIdentity.RoleClaimType`, and is that `ZbClaimTypes.Role` / `ClaimTypes.Role`?). Inspect the identity-construction site (JWT validation `TokenValidationParameters.RoleClaimType`, and/or the cookie claims-principal factory).
2. **Decision (record the outcome in the task's commit message):**
- **If they match** (the role claim the library's `RequireRole` reads is the same type ScadaBridge issues) → no remediation; Task 7's `AddSecretsAuthorization()` is sufficient. Note the evidence.
- **If they do NOT match** → remediate. Preferred, lowest-blast-radius option: **register app-specific policies under the library's exact policy names** `secrets:manage` and `secrets:reveal` using ScadaBridge's own primitive:
```csharp
options.AddPolicy("secrets:manage", p => p.RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator));
options.AddPolicy("secrets:reveal", p => p.RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator));
```
inside the existing `AddAuthorization(options => …)` at `AuthorizationPolicies.cs:159-194`. This shadows the library's `RequireRole`-based policies with matching names bound to ScadaBridge's claim type. Alternative: align the identity's `RoleClaimType` to `ZbClaimTypes.Role` at construction — but that is higher blast radius (touches every existing role check) and is **not** recommended for this task.
3. Defer actually wiring the policy registration to Task 7 (this task decides *which* form Task 7 uses).
---
## Task 7 — G-6: register secrets authorization + mount `/admin/secrets`
**Classification:** standard (authz already de-risked in Task 6)
**Estimated implement time:** 5 min
**Parallelizable with:** none (depends on Task 6 decision + Task 3)
**Files:**
- `src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs:159-194` (inside the existing `AddAuthorization(options => …)`)
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/EndpointExtensions.cs:26-28` (`MapRazorComponents<TApp>().AddInteractiveServerRenderMode().AddAdditionalAssemblies(typeof(MainLayout).Assembly)`)
- `src/ZB.MOM.WW.ScadaBridge.Host/Components/Routes.razor:2-3` (`AdditionalAssemblies="new[] { typeof(…CentralUI.Components.Layout.MainLayout).Assembly }"`)
**Context (design §3d, §6):** the RCL page `ZB.MOM.WW.Secrets.Ui.SecretsPage` (`@page "/admin/secrets"`, `[Authorize(Policy = ManagePolicy)]`) must have its assembly registered in **both** the endpoint mount **and** the router `AdditionalAssemblies`, or the route 404s. The page uses the default `MainLayout` supplied by the Router. `AddScadaBridgeAuthorization` is called from `AddSecurity` (`Security/ServiceCollectionExtensions.cs:216`, invoked `Program.cs:159`).
**Steps:**
1. Register secrets policies inside the existing `AddAuthorization(options => …)` (`AuthorizationPolicies.cs:159-194`):
- If Task 6 found a **match**: `options.AddSecretsAuthorization();` (additive; requires `using ZB.MOM.WW.Secrets.Ui;` or the namespace of `SecretsAuthorization`).
- If Task 6 found a **mismatch**: register the two app-specific `RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator)` policies under names `secrets:manage`/`secrets:reveal` (Task 6 step 2).
2. In `EndpointExtensions.cs:28`, extend the mount:
```csharp
.AddAdditionalAssemblies(typeof(MainLayout).Assembly, typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly)
```
3. In `Routes.razor:3`, add the assembly to `AdditionalAssemblies`:
```razor
AdditionalAssemblies="new[] { typeof(ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout.MainLayout).Assembly, typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly }"
```
4. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** 0 warnings.
5. **Browser verify** (design §10): boot CentralUI; navigate `/admin/secrets`:
- Unauthenticated → login redirect (policy enforced).
- Administrator → metadata-only list; **reveal** shows plaintext; an audit row is written **with no plaintext** (query the app's audit store to confirm — proven pattern from HistorianGateway).
- **This is the real test of Task 6.** If an Administrator gets 403, the claim-type remediation was needed/incorrect — return to Task 6.
---
## Task 8 — G-5: clustered master-key posture (central pair + site nodes)
**Classification:** standard (config + ops posture; no new code)
**Estimated implement time:** 5 min
**Parallelizable with:** Task 4/5
**Files:**
- `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json` — the `"Secrets"` block added in Task 2
- (reference only) `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs` (`BuildHocon` `:186/252`, seeds `:258`, SBR `:290`, singletons `:415-460+`); central seeds e.g. `docker/central-node-a/appsettings.Central.json:10-13`
**Context (design §5; surprise c):** ScadaBridge is Akka-clustered — **both** central nodes of a pair resolve Layer-A `${secret:}` config at boot, so each needs (1) the **same KEK** and (2) the **same store rows**. The library ships a **SQLite-only store** with a **`NoOpSecretReplicator`** — there is **no built-in shared-SQL `ISecretStore`** and no cross-node replication. True replication is the **G-7 hand-off** (out of scope here).
**Steps:**
1. Set the interim clustered posture in the `"Secrets"` block (design §5 recommended interim):
```json
"Secrets": {
"SqlitePath": "/shared/secrets/scadabridge-secrets.db",
"MasterKey": { "Source": "File", "FilePath": "/shared/secrets/master.key" },
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
}
```
- `MasterKey.Source=File` with a **read-only mounted key file identical on every node** (KEK never committed — design §8.6).
- `SqlitePath` → a **single shared/replicated volume** both central nodes mount, so all nodes read identical rows. Secret **writes** are rare + human-driven (via one node's `/admin/secrets` / CLI); **reads** dominate.
2. **Document the fork explicitly** in a comment beside the block / in the plan's rollout notes: SQLite over a network share has multi-writer locking limits (fine for read-mostly single-writer; **not** a substitute for real replication). The integrated **ConfigDb-backed `ISecretStore`** (mirroring the existing `AddDataProtection().PersistKeysToDbContext<ScadaBridgeDbContext>()` key-ring sharing at `ConfigurationDatabase/ServiceCollectionExtensions.cs:80-81`) is the long-term shape but requires new library code → **deferred to G-7**, not attempted here.
3. **Do NOT reconfigure Data Protection** (design §8.5) — the existing DP key ring (no `ProtectKeysWith`, no `SetApplicationName`) is independent of Secrets envelope crypto; touching it risks invalidating cookies/hub tokens.
4. Site nodes: site nodes do not resolve central config secrets, but if any `${secret:}` token appears in `appsettings.Site.json`, the same File-KEK + shared-store rule applies. Confirm none is introduced without the mount.
5. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** 0 warnings (config-only).
---
## Task 9 — G-3: resolve the MxGateway `ApiKey` `secret:` ref at runtime (Layer B)
**Classification:** high-risk (touches the DCL connection/actor data path)
**Estimated implement time:** 5 min
**Parallelizable with:** none (Slice 2; after Slice 1 review)
**Files:**
- `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs:96` (`cfg.ApiKey` passed into `new MxGatewayConnectionOptions(cfg.Endpoint, cfg.ApiKey, …)`) — the resolution hook
- `src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/MxGatewayEndpointConfig.cs:14` (`public string ApiKey { get; set; } = "";`) — the field that will carry a `secret:<name>` ref
- (context) `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/SiteConfiguration.cs:52-56` (column `.HasMaxLength(4000)`, **no** encrypting converter → plaintext today); `ManagementService/ConfigSecretScrubber.cs:16-19` (display/audit redaction only)
**Context (design §7, recommended approach):** the `ApiKey` is a field **inside** the `DataConnection.PrimaryConfiguration`/`BackupConfiguration` JSON blob, so the existing `EncryptedStringConverter` can't target it directly. **Recommended:** store a `secret:<name>` ref in the `ApiKey` field and resolve it via `ISecretResolver.GetAsync` **at consumption** (`MxGatewayDataConnection.cs:96`) — leaves the JSON blob human-readable, no schema change, and matches the family's Layer-B pattern (design §2). **Alternative (note only, do not implement):** encrypt the whole `PrimaryConfiguration`/`BackupConfiguration` column with `EncryptedStringConverter` — fewer refs but encrypts a mixed blob (scrubber + every display/edit path must decrypt first) and the column is read in several places → higher blast radius.
**Steps (TDD):**
1. **Test first.** Add a unit test with a fake `ISecretResolver` (in the DCL test project). Assert:
- a `cfg.ApiKey == "secret:mxgateway/<conn>/api-key"` → resolves to the fake's plaintext before `ConnectAsync`;
- a **literal** `cfg.ApiKey` (no `secret:` prefix) → passes through unchanged (back-compat);
- an **unknown** `secret:` ref (resolver returns `null`) → fails closed (throws, does not connect with an empty key).
Run: `dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~MxGateway"` — **Expected:** red.
2. Thread `ISecretResolver` into `MxGatewayDataConnection` (constructor injection, or via its factory — check how the adapter is constructed by the DCL; the `…DataConnectionLayer` project may need to reference `ZB.MOM.WW.Secrets.Abstractions`). Register is already done (Task 3 on the host container).
3. At `:96`, before building `MxGatewayConnectionOptions`, resolve the key:
```csharp
var apiKey = cfg.ApiKey;
if (apiKey.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
{
var name = apiKey["secret:".Length..];
apiKey = await _secretResolver.GetAsync(new SecretName(name), cancellationToken)
?? throw new InvalidOperationException($"MxGateway ApiKey secret '{name}' not found");
}
```
then pass `apiKey` (not `cfg.ApiKey`) into `MxGatewayConnectionOptions`. Keep the literal path for back-compat (existing deployments store a literal key).
4. Run tests: `dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~MxGateway"` — **Expected:** green.
5. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** 0 warnings.
> Note: the four `EncryptedStringConverter` columns (`ScadaBridgeDbContext.cs:333-347`) and the redaction scrubber are **unchanged** by this task — they are a separate Data-Protection concern (design §8.5). This task only indirects the one plaintext `ApiKey` field.
---
## Task 10 — Verification: build, suites, live connection
**Classification:** standard
**Estimated implement time:** 5 min (+ live, VPN-gated)
**Parallelizable with:** none (final gate)
**Files:** n/a (verification)
**Steps:**
1. **0-warning build:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** 0 warnings, 0 errors (ScadaBridge builds under `TreatWarningsAsErrors` conventions).
2. **Offline suites green** — the DCL / InboundAPI / SiteRuntime suites (per prior deploy notes these are the load-bearing suites):
```bash
dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~DataConnectionLayer"
dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~InboundAPI"
dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~SiteRuntime"
```
Then full: `dotnet test ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** all green.
3. **Boot smoke + fail-closed** re-confirmed (Task 4 steps 34) on the assembled branch.
4. **UI** (Task 7 step 5) re-confirmed: `/admin/secrets` policy-gated + reveal + no-plaintext audit row.
5. **Live (VPN-gated):** with `ZB_SECRETS_MASTER_KEY` present on the box + a real MxGateway endpoint's `ApiKey` switched to a seeded `secret:mxgateway/<conn>/api-key` ref, deploy the DataConnection and confirm the MxGateway adapter **authenticates real traffic** (connection reaches Connected, tags read Good) — mirroring the HistorianGateway live proof (design §10). If VPN/box is unavailable, document as deferred-live and rely on the offline resolver unit test + boot smoke.
6. On green: FF-merge `feat/adopt-zb-secrets` → `main`, push to Gitea.
---
## Testing & rollout
- **Offline gate (blocking):** 0-warning `ZB.MOM.WW.ScadaBridge.slnx` build; DCL/InboundAPI/SiteRuntime + full test suite green; the G-3 resolver unit test (fake `ISecretResolver`: `secret:` ref resolves, literal passes through, unknown fails closed).
- **Boot smoke (blocking):** Host boots `SCADABRIDGE_CONFIG=Central` with the five G-4 config secrets seeded; negative control proves `SecretNotFoundException` fail-closed before `StartupValidator`.
- **UI (blocking):** `/admin/secrets` — login redirect when unauthenticated, Administrator sees metadata + reveal, audit row carries no plaintext. This validates the Task 6 claim-type decision end-to-end.
- **Live (VPN-gated, best-effort):** one real MxGateway DataConnection `ApiKey` as a `secret:` ref authenticates real traffic on the box, with the File-KEK shared across the central pair.
- **Rollout:** Slice 1 (Tasks 18) reviewed and merged first (config/UI/clustering, no data-path change); Slice 2 (Task 9, G-3) as a separate reviewable slice. Per-branch mirroring auth/theme/audit: commit + review, FF-merge to `main`, push to Gitea. KEK delivered out-of-band to every node (never committed); env-override remains the rollback path for any token.
## Deferred / out of scope
- **G-7 — shared store + Akka replicator:** a ConfigDb-backed `ISecretStore` (SQL-Server, mirroring the DP key-ring sharing) or the `ZB.MOM.WW.Secrets.Akka` cross-node replicator. The library today is SQLite-only with `NoOpSecretReplicator`; Task 8 ships the interim File-KEK + shared-SQLite posture and flags this hand-off. Tracked in [`components/secrets/GAPS.md`](../../components/secrets/GAPS.md).
- **G-8 — `RewrapAll` KEK rotation.** Deferred per `components/secrets/GAPS.md`.
- **Akka remoting auth/TLS / shared secure-cookie** (plain TCP today, current-state §row Akka remoting) — a separate hardening concern, not this component.
- **Migrating the four existing `EncryptedStringConverter` columns to Secrets** — they already have at-rest Data-Protection encryption; coexistence is fine (design §8.5), migration is not in scope.
- **mxaccessgw / OtOpcUa** secrets adoption — their own plans (design §5/§9).
@@ -0,0 +1,23 @@
{
"planPath": "docs/plans/2026-07-16-secrets-adoption-scadabridge.md",
"design": "docs/plans/2026-07-16-secrets-adoption-design.md",
"app": "ScadaBridge",
"gaps": ["G-3", "G-4", "G-5", "G-6"],
"slices": {
"1": {"desc": "config + UI + clustering", "tasks": [1, 2, 3, 4, 5, 6, 7, 8]},
"2": {"desc": "G-3 Layer-B MxGateway ApiKey", "tasks": [9, 10]}
},
"tasks": [
{"id": 1, "subject": "Package references + CPM versions + nuget.config mapping", "status": "pending", "classification": "small", "slice": 1},
{"id": 2, "subject": "Layer-A pre-host ${secret:} expander (G-4 mechanism)", "status": "pending", "classification": "standard", "slice": 1, "blockedBy": [1]},
{"id": 3, "subject": "Runtime AddZbSecrets on the host container", "status": "pending", "classification": "small", "slice": 1, "blockedBy": [2]},
{"id": 4, "subject": "G-4: formalize ${SCADABRIDGE_*} placeholders -> ${secret:} tokens", "status": "pending", "classification": "standard", "slice": 1, "blockedBy": [3]},
{"id": 5, "subject": "G-4 cleanup: remove committed dev plaintext + loose *_login.txt", "status": "pending", "classification": "small", "slice": 1, "blockedBy": [4]},
{"id": 6, "subject": "G-6: claim-type VERIFICATION (RequireRole vs RequireClaim)", "status": "pending", "classification": "high-risk", "slice": 1, "blockedBy": [1]},
{"id": 7, "subject": "G-6: register secrets authorization + mount /admin/secrets", "status": "pending", "classification": "standard", "slice": 1, "blockedBy": [3, 6]},
{"id": 8, "subject": "G-5: clustered master-key posture (central pair + site nodes)", "status": "pending", "classification": "standard", "slice": 1, "blockedBy": [2]},
{"id": 9, "subject": "G-3: resolve the MxGateway ApiKey secret: ref at runtime (Layer B)", "status": "pending", "classification": "high-risk", "slice": 2, "blockedBy": [3]},
{"id": 10, "subject": "Verification: build, suites, live connection", "status": "pending", "classification": "standard", "slice": 2, "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9]}
],
"lastUpdated": "2026-07-16"
}