diff --git a/components/secrets/GAPS.md b/components/secrets/GAPS.md index 809f46b..c61c047 100644 --- a/components/secrets/GAPS.md +++ b/components/secrets/GAPS.md @@ -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 diff --git a/docs/plans/2026-07-16-secrets-adoption-design.md b/docs/plans/2026-07-16-secrets-adoption-design.md new file mode 100644 index 0000000..099b2e7 --- /dev/null +++ b/docs/plans/2026-07-16-secrets-adoption-design.md @@ -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` | **`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` 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": "-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//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:` 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().MigrateAsync(default); + var resolver = secretsProvider.GetRequiredService(); + 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(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() + .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 `` 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()` — add `.AddAdditionalAssemblies`) **and** thread `AdditionalAssemblies` from `App.razor:22` into `` (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:` 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. diff --git a/docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md b/docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md new file mode 100644 index 0000000..18d0e92 --- /dev/null +++ b/docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md @@ -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 1–3. Task 5 (optional Galaxy/pepper) depends on 4. Task 6 (UI mount) depends only on 1–3 and is **parallelizable with 4/5**. Task 7 (master-key operator config) is docs/appsettings only, parallelizable with 4–6. Task 8 is the final verification gate. + +> **Gotcha carried from design §8 / the recon:** Data Protection is **never explicitly configured** in this app — the only DP consumer is `Dashboard/HubTokenService.cs:43` (SignalR hub tokens on the framework-default key ring). Secrets envelope crypto is independent of Data Protection. **Do NOT add/reconfigure `AddDataProtection`** anywhere in this work, or you risk rotating the hub-token key ring and breaking live SignalR sessions. Task 8 explicitly re-verifies SignalR is unaffected. + +--- + +## Task 1 — Package references + nuget.config packageSourceMapping + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** none (gates 2–8) + +**Files:** +- Modify: `~/Desktop/MxAccessGateway/nuget.config:24` (add two `` rows after the last existing ZB pattern; the file has explicit `packageSourceMapping` at `:10-26`, source key `dohertj2-gitea` at `:6`, **no** `ZB.MOM.WW.*` wildcard — restore fails without the new patterns) +- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj` (add three `PackageReference`s) + +**Steps:** +1. In `nuget.config`, under the `dohertj2-gitea` `` mapping block (after the existing `ZB.MOM.WW.GalaxyRepository` pattern at line 24), add: + ```xml + + + ``` + (The second pattern covers `.Abstractions` and `.Ui`.) +2. In `ZB.MOM.WW.MxGateway.Server.csproj`, add to the existing `ZB.MOM.WW.*` `PackageReference` `ItemGroup`: + ```xml + + + + ``` + (`.Ui` transitively pulls `ZB.MOM.WW.Theme` + `ZB.MOM.WW.Auth.AspNetCore` + `ZB.MOM.WW.Audit`, all of which this app already references — no version conflict expected; verify in step 3.) +3. Restore + build to prove the feed resolves the three packages: + ```bash + dotnet restore src/MxGateway.sln + dotnet build src/MxGateway.sln -warnaserror + ``` + **Expect:** clean restore (packages pulled from `dohertj2-gitea`), 0 warnings, 0 errors. If restore 404s, re-check the two nuget.config patterns and that `$GITEA_TOKEN`/feed creds are present. +4. Commit: `git add -A && git commit -m "build(secrets): add ZB.MOM.WW.Secrets 0.1.2 package refs + nuget source mapping"`. + +--- + +## Task 2 — Pre-host `${secret:}` expander in GatewayApplication.CreateBuilder (G-4 mechanism) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends on 1; gates 3–8) + +Wire the throwaway-provider pre-host expansion (design §3a) so **every** downstream config consumer sees resolved values. This is a pure mechanism task — no config value is switched to a token yet (that is Task 4), so the app must still boot unchanged (the expander is a no-op when no `${secret:}` tokens are present). + +**Files:** +- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs` — method `CreateBuilder(string[] args)` at `:58`; builder-creation chain `:60-64`; `ConfigureSelfSignedTls(builder)` at `:67`. **Insert the expander block between `:64` and `:67`** (after the builder is created, before TLS reads config), so even TLS/Kestrel values can carry tokens. +- Test: `~/Desktop/MxAccessGateway/src/MxGateway.Tests/…` (add `PreHostSecretExpansionTests`, see step 1) + +**Why this insertion point (recon-verified):** the expander must mutate `builder.Configuration` **before** all of: `ConfigureSelfSignedTls` (`:67`), `AddGatewayConfiguration(builder.Configuration)` (`:71`, which runs `AddValidatedOptions` with `ValidateOnStart`), `AddZbLdapAuth` (`DashboardServiceCollectionExtensions.cs:38`, binds `MxGateway:Ldap` directly with its own `ValidateOnStart`), `AddZbGalaxyRepository(...,"MxGateway:Galaxy")` (`:103`), and `AddZbApiKeyAuth` (`AuthStoreServiceCollectionExtensions.cs:70`). Because `CreateBuilder` runs first in the pipeline, placing it right after builder creation satisfies all of these. + +**Steps:** +1. **Write test first.** Add `PreHostSecretExpansionTests` that constructs a config with a `${secret:test/value}` token and a seeded fake/temp SQLite store, runs the same `SecretReferenceExpander` flow, and asserts the token is rewritten to the seeded plaintext; and a second test asserting an unseeded `${secret:missing}` throws `SecretNotFoundException`. (If the test project cannot easily reach a private `CreateBuilder`, test the `SecretReferenceExpander`/`AddZbSecrets` flow directly against a temp `SqlitePath` — the same code the host runs.) + ```bash + dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter "FullyQualifiedName~PreHostSecretExpansion" + ``` + **Expect:** fails to compile / red (expander not yet wired). +2. Add usings to `GatewayApplication.cs`: `ZB.MOM.WW.Secrets.Abstractions`, `ZB.MOM.WW.Secrets.Configuration`, `ZB.MOM.WW.Secrets.DependencyInjection`, `ZB.MOM.WW.Secrets.Sqlite`, plus `Microsoft.Extensions.DependencyInjection` / `Microsoft.Extensions.Configuration` if not already present. +3. Insert the expander block between line 64 and line 67 (the design §3a template, verbatim). Note `CreateBuilder` is synchronous today — make it `async` (return `Task<…>` / `ValueTask`) or run the expansion synchronously via `.GetAwaiter().GetResult()`. **Preferred:** keep the method signature stable if the caller chain is non-trivial by calling `.GetAwaiter().GetResult()` on the two awaits (pre-host bootstrap, single-shot, no sync-context deadlock risk in a console/host startup). Confirm the actual caller of `CreateBuilder` before choosing; if it is already in an async context, prefer real `await`. + ```csharp + #pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons + using (var secretsProvider = new ServiceCollection() + .AddZbSecrets(builder.Configuration, "Secrets") + .BuildServiceProvider()) + #pragma warning restore ASP0000 + { + secretsProvider.GetRequiredService() + .MigrateAsync(default).GetAwaiter().GetResult(); + var resolver = secretsProvider.GetRequiredService(); + new SecretReferenceExpander(resolver) + .ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default) + .GetAwaiter().GetResult(); + } + ``` + (Use `await using` + `await` if you made the method async — either is fine; keep the `#pragma`.) +4. Add the `Secrets` block to `src/ZB.MOM.WW.MxGateway.Server/appsettings.json` (design §1 shape): + ```json + "Secrets": { + "SqlitePath": "mxgateway-secrets.db", + "MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" }, + "RunMigrationsOnStartup": true, + "ResolveCacheTtl": "00:00:30" + } + ``` +5. Build + run the two tests: + ```bash + dotnet build src/MxGateway.sln -warnaserror + dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter "FullyQualifiedName~PreHostSecretExpansion" + ``` + **Expect:** 0 warnings; both tests green (rewrite works, missing-ref throws). +6. Commit: `git commit -am "feat(secrets): pre-host \${secret:} config expansion in GatewayApplication.CreateBuilder (G-4 mechanism)"`. + +--- + +## Task 3 — Runtime `AddZbSecrets` registration on the host container + +**Classification:** trivial +**Estimated implement time:** ~2 min +**Parallelizable with:** none (depends on 2; gates 4–8) + +The pre-host throwaway provider (Task 2) only expands config once. The **runtime** resolver + `SecretsMigrationHostedService` are needed by the UI (Task 6) and keep the store schema current (design §3b, gotcha §8.3). + +**Files:** +- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs` — service-registration region (alongside `AddGatewayConfiguration` at `:71` / `AddGatewayDashboard` at `:98`). + +**Steps:** +1. Add, on the real host's `builder.Services` (after `AddGatewayConfiguration`, before/near `AddGatewayDashboard`): + ```csharp + builder.Services.AddZbSecrets(builder.Configuration, "Secrets"); + ``` +2. Build: + ```bash + dotnet build src/MxGateway.sln -warnaserror + ``` + **Expect:** 0 warnings, 0 errors. +3. Commit: `git commit -am "feat(secrets): register runtime AddZbSecrets on the gateway host"`. + +--- + +## Task 4 — Switch LDAP ServiceAccountPassword to `${secret:}` + seed + remove hardcoded defaults (G-4) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 6, Task 7 (depends on 1–3) + +Retire the plaintext LDAP bind password. It appears in **two** places (both must be addressed, or the blanked appsettings falls back to a leaked default): +- `appsettings.json:29` → `"ServiceAccountPassword": "serviceaccount123"` +- `Configuration/LdapOptions.cs:61` → `public string ServiceAccountPassword { get; init; } = "serviceaccount123";` + +Expanding `MxGateway:Ldap:ServiceAccountPassword` covers **both** the shadow `GatewayOptionsValidator` (requires non-blank, `GatewayOptionsValidator.cs:134-137`) and the real runtime `AddZbLdapAuth` bind (`DashboardServiceCollectionExtensions.cs:38`, own `ValidateOnStart`), because the expander mutates `builder.Configuration` before both. + +**Files:** +- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/appsettings.json:29` (token instead of plaintext) +- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs:61` (blank the hardcoded default) + +**Steps:** +1. Change `appsettings.json:29` to: + ```json + "ServiceAccountPassword": "${secret:ldap/mxgateway/bind}", + ``` +2. Blank the hardcoded default at `LdapOptions.cs:61`: + ```csharp + public string ServiceAccountPassword { get; init; } = string.Empty; + ``` + (Non-blank enforcement now lives entirely in the validator + fail-closed expansion; the leaked literal is gone.) +3. **Seed the secret** into the local store via the `secret` CLI (`ZB.MOM.WW.Secrets.Cli`) before booting — the dev LDAP bind password is the current dev value (`serviceaccount123` for the shared GLAuth, or the box's real value). Set `ZB_SECRETS_MASTER_KEY` (base64 32 bytes) and point the CLI at the same `SqlitePath` the app uses (`mxgateway-secrets.db`): + ```bash + export ZB_SECRETS_MASTER_KEY="$(openssl rand -base64 32)" # dev only; box uses the operator-delivered key + dotnet run --project /ZB.MOM.WW.Secrets.Cli -- set \ + --sqlite ./src/ZB.MOM.WW.MxGateway.Server/mxgateway-secrets.db \ + --name ldap/mxgateway/bind --value 'serviceaccount123' + ``` + (Confirm the exact CLI verb/flags from the `ZB.MOM.WW.Secrets.Cli` help — design §1 references a `set`/seed verb; the store path and `ZB_SECRETS_MASTER_KEY` must match what the app resolves with.) +4. **Boot smoke** — start the gateway with the key present and the secret seeded: + ```bash + dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj + ``` + **Expect:** clean startup — `GatewayOptionsValidator` + `AddZbLdapAuth` `ValidateOnStart` both pass (password non-blank because the token expanded to the seeded value). Dashboard reachable; LDAP login works against the shared GLAuth. +5. **Negative control (fail-closed proof, design §10 / §8.1):** stop the app, remove/rename the seeded secret (or unset `ZB_SECRETS_MASTER_KEY`), boot again. + **Expect:** `SecretNotFoundException` (missing secret) or a decrypt failure (missing key) at pre-host expansion → the app refuses to start. Re-seed / restore the key to recover. +6. Confirm no plaintext `serviceaccount123` remains in the repo: + ```bash + grep -rn "serviceaccount123" ~/Desktop/MxAccessGateway/src || echo "clean" + ``` + **Expect:** `clean` (both the appsettings literal and the LdapOptions default are gone). +7. Commit: `git commit -am "feat(secrets): source LDAP bind password via \${secret:ldap/mxgateway/bind}; drop plaintext defaults (G-4)"`. + +--- + +## Task 5 — (Optional) Galaxy connstr + API-key pepper to `${secret:}` + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 6, Task 7 (depends on 4) + +Two lower-priority Layer-A targets. Do these only if the operator wants them under management now; each is independently revertible. + +**Files:** +- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/appsettings.json:76` (Galaxy connstr, section `MxGateway:Galaxy`, bound by `AddZbGalaxyRepository(...,"MxGateway:Galaxy")` at `GatewayApplication.cs:103`) +- (Pepper) no appsettings change — the pepper is env-supplied today via config-key **name** `MxGateway:ApiKeyPepper` (`AuthStoreServiceCollectionExtensions.cs:36`, resolved `:70`); optionally add an appsettings token so it flows through the expander. + +**Steps:** +1. **Galaxy connstr:** the current value is `Integrated Security=True` (no password today), but it is a raw connstr that would carry `Password=` if the box switches to SQL auth. If the box uses SQL auth, move the whole connstr (or just the password) behind a token, e.g. set `appsettings.json:76` to `"${secret:sql/mxgateway/galaxy}"` and seed `sql/mxgateway/galaxy` with the full connection string. If the box stays on Integrated Security, **skip** — leave it as-is and note it in the operator doc. The expander already runs before `AddZbGalaxyRepository` (`:103`), so no ordering work is needed. +2. **API-key pepper (SEC-10):** to close the "no dedicated env reader" gap while keeping the pepper out of appsettings, add under `MxGateway`: + ```json + "ApiKeyPepper": "${secret:apikey-pepper/mxgateway}", + ``` + then seed `apikey-pepper/mxgateway`. Because `AddZbApiKeyAuth` resolves the pepper by config-key name (`:70`) after the expander runs, the token resolves transparently. **Caveat:** if the box already supplies `MxGateway__ApiKeyPepper` via env, that env value **overrides** appsettings — decide one source and document it (env-supplied stays valid and needs no change; the token path is the alternative). Do not set both to different values. +3. Seed whichever secrets you introduced (as in Task 4 step 3), then boot-smoke + negative-control the same way. +4. Build + confirm clean: + ```bash + dotnet build src/MxGateway.sln -warnaserror + ``` +5. Commit: `git commit -am "feat(secrets): (optional) Galaxy connstr + api-key pepper via \${secret:} (G-4)"`. + +--- + +## Task 6 — AddSecretsAuthorization + mount `/admin/secrets` in Router & endpoint (G-6) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 4, Task 5, Task 7 (depends on 1–3) + +Mount the RCL page (design §3d/§6). The library's `SecretsPage` route is `/admin/secrets` under policy `secrets:manage`. Because the library `AdminRole == "Administrator"` matches `DashboardRoles.Admin` (`DashboardRoles.cs:14`) **verbatim**, and the dashboard already gates on **role claims** (no claim-type mismatch here, unlike ScadaBridge), Administrators satisfy `secrets:manage`/`secrets:reveal` automatically and `Viewer` (`DashboardRoles.cs:19`) does not. + +**Files:** +- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs:133-135` — the `endpoints.MapRazorComponents().AddInteractiveServerRenderMode().RequireAuthorization(ViewerPolicy)` chain (inside `MapGatewayDashboard` at `:56`, invoked from `GatewayApplication.cs:225`). Add `.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly)`. +- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Routes.razor:1` — `` (no `AdditionalAssemblies` today). Add the RCL assembly. +- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs` — register `AddSecretsAuthorization()` near `:98` (after `AddGatewayDashboard`). + +**Steps:** +1. **Register the authorization policies.** In `GatewayApplication.cs` after `AddGatewayDashboard` (`:98`), add: + ```csharp + builder.Services.Configure(o => o.AddSecretsAuthorization()); + ``` + (Additive — composes with the existing `services.AddAuthorization(...)` block in `DashboardServiceCollectionExtensions.cs:144-159`. Add `using ZB.MOM.WW.Secrets.Ui;`.) Both approaches in the brief are valid; the `Configure` form keeps the change out of the dashboard extension. +2. **Endpoint side** — `DashboardEndpointRouteBuilderExtensions.cs:133-135`: + ```csharp + endpoints.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly) + .RequireAuthorization(ViewerPolicy); + ``` + (Preserve the existing `.RequireAuthorization(ViewerPolicy)` — the page itself carries `[Authorize(Policy = ManagePolicy)]`, so viewers reaching the assembly still get 403 on the page.) +3. **Router side** — `Routes.razor:1`: + ```razor + + ``` + (Both registrations are required — endpoint-side for render-mode discovery, Router-side for client-side routing — or `/admin/secrets` 404s. Design §3d.) +4. Build: + ```bash + dotnet build src/MxGateway.sln -warnaserror + ``` + **Expect:** 0 warnings, 0 errors. +5. **Browser verify** (defer full check to Task 8, quick smoke here): run the gateway, navigate to `/admin/secrets` unauthenticated → expect redirect to `/login`; log in as an `Administrator` (e.g. `multi-role`) → expect the secrets metadata list renders. +6. Commit: `git commit -am "feat(secrets): mount /admin/secrets + AddSecretsAuthorization in the dashboard (G-6)"`. + +--- + +## Task 7 — Master-key provider config for the NSSM box (G-5) + operator note + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 4, Task 5, Task 6 (depends on 1–3) + +MxAccessGateway is a **single Windows/NSSM box, no clustering** (recon grep clean — no Akka), so G-5 is the simple posture from design §5 (mxaccessgw sub-section): **`Environment` master-key provider**, delivering `ZB_SECRETS_MASTER_KEY` via the NSSM service environment exactly how `MxGateway__Ldap__ServiceAccountPassword` is already delivered (`docs/GatewayConfiguration.md:248`). The local SQLite store (default `SqlitePath`) is fine — single node, no shared-KEK requirement. **DPAPI** is a documented machine-bound alternative (stronger at-rest binding, but ties the store to one machine and complicates DR restore). + +**Files:** +- Modify: `~/Desktop/MxAccessGateway/docs/GatewayConfiguration.md` (add a "Secrets master key" operator section near the existing service-account-password env note at `:248`) +- (No code change — `Secrets.MasterKey.Source=Environment` + `EnvVarName=ZB_SECRETS_MASTER_KEY` was already set in appsettings in Task 2 step 4.) + +**Steps:** +1. Add an operator note to `docs/GatewayConfiguration.md` documenting: + - The gateway now resolves `${secret:}` tokens at startup from an encrypted local SQLite store (`Secrets:SqlitePath`, default `mxgateway-secrets.db` under the app dir / `C:\ProgramData\MxGateway\...`). + - The master key is delivered **out-of-band** via the NSSM service env var `ZB_SECRETS_MASTER_KEY` (base64 32 bytes) — **never committed**, same discipline as `MxGateway__Ldap__ServiceAccountPassword`. Provide the `nssm set AppEnvironmentExtra ZB_SECRETS_MASTER_KEY=` pattern. + - How to seed a secret on the box with the `secret` CLI (name → value), pointing at the same `SqlitePath` and with the same `ZB_SECRETS_MASTER_KEY` present. + - Alternative: `MasterKey.Source=Dpapi` (`FilePath` = machine-bound key file) if a machine-bound key is preferred — note the DR-restore caveat. + - **Rotation / DR note:** losing the KEK makes the store unrecoverable; back up the KEK in the org secret vault. (Full KEK rotation `RewrapAll` is G-8, out of scope — see Deferred.) +2. No build needed (docs only). Confirm the appsettings `Secrets` block from Task 2 already names `ZB_SECRETS_MASTER_KEY`. +3. Commit: `git commit -am "docs(secrets): G-5 master-key operator note (Environment provider via NSSM env)"`. + +--- + +## Task 8 — Verification gate: 0-warning build, UI reveal+audit, SignalR unaffected + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (final gate; depends on all) + +**Files:** +- Test/verify only — no source change (fix-forward into the relevant earlier task if something fails). + +**Steps:** +1. **Full build + test, 0 warnings:** + ```bash + dotnet build src/MxGateway.sln -warnaserror + dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj + ``` + **Expect:** 0 warnings; all tests green (incl. the new `PreHostSecretExpansion` tests). +2. **Boot with real config** (secret seeded, `ZB_SECRETS_MASTER_KEY` set): gateway starts clean; LDAP login works (proves the expanded LDAP password reaches the real `AddZbLdapAuth` bind, not just the shadow validator). +3. **UI reveal + audit browser check** (design §10): + - Unauthenticated `GET /admin/secrets` → redirect to `/login` (policy enforced). + - Log in as `Administrator` → metadata-only secret list renders; **reveal** a secret → plaintext shown in the UI; confirm an **audit row** was written (via the app's `ZB.MOM.WW.Audit` writer) **carrying no plaintext** (the value must not appear in the audit event — HistorianGateway proved this; re-confirm here). + - Log in as a `Viewer`-only user → `/admin/secrets` returns 403 (policy denies non-admins). +4. **SignalR / HubTokenService regression check (design §8.5 / recon surprise):** confirm the dashboard's SignalR-backed live surfaces still work after this change (hub connect succeeds, live tiles update). This proves Data Protection was **not** disturbed — `HubTokenService.cs:43` shares the framework-default DP key ring, and nothing in this plan touched DP. +5. **Repo-clean grep:** `grep -rn "serviceaccount123" src` → nothing; no other plaintext credential introduced. +6. If all green, this is the merge point: FF-merge `feat/adopt-zb-secrets` → `main`, push to Gitea. + ```bash + git switch main && git merge --ff-only feat/adopt-zb-secrets && git push origin main + ``` + +--- + +## Testing & rollout + +- **Offline / CI:** `dotnet build src/MxGateway.sln -warnaserror` + `dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj` — 0 warnings, all green including `PreHostSecretExpansion` (rewrite works; missing ref fails closed). +- **Boot smoke + negative control (Task 4/8):** seeded secret + KEK → clean boot & working LDAP login; unseed the secret or drop the KEK → `SecretNotFoundException`/decrypt-fail at startup (fail-closed proof, mirrors HistorianGateway). +- **UI (Task 8):** `/admin/secrets` — login redirect when anonymous, admin sees metadata + reveal + a plaintext-free audit row, viewer gets 403. +- **SignalR (Task 8):** dashboard live surfaces unaffected → confirms DP key ring untouched. +- **Box rollout:** on the NSSM box, set `ZB_SECRETS_MASTER_KEY` via the service env, seed `ldap/mxgateway/bind` (and any optional Task-5 secrets) with the `secret` CLI, deploy the new binaries. Rollback path: reverting a `${secret:}` token back to the env-var/plaintext override still boots (the env value still binds), so the cutover is reversible per-secret. +- **Follow the family pattern:** commit + review each task, FF-merge `feat/adopt-zb-secrets` → `main`, push to `origin` (mxaccessgw), as auth/theme/audit were rolled out. + +## Deferred / out of scope + +Per [`components/secrets/GAPS.md`](../../components/secrets/GAPS.md): +- **G-7** — Akka cluster secret replicator / shared-SQL `ISecretStore`. **N/A for this app** (single box, no clustering) beyond the general library gap; no work here. +- **G-8** — `RewrapAll` KEK-rotation tooling. Not attempted; the operator note (Task 7) documents KEK backup + the unrecoverable-if-lost caveat only. +- **G-2 / G-3** — Layer-B database-resident driver/connection secrets. **This app has none** (MXAccess/Wonderware creds are runtime pass-through, never stored — CURRENT-STATE §"MXAccess creds"). No Layer-B work. +- **Kept bespoke as-is** (CURRENT-STATE §"Adoption plan" item 5): the peppered-HMAC API-key store (hashed, not reversibly encrypted — already best-practice) and the ACL-protected, null-password TLS PFX. Not migrated. +- **Data Protection** is deliberately left on the framework default (SignalR hub tokens only) — not reconfigured (design §8.5). diff --git a/docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md.tasks.json b/docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md.tasks.json new file mode 100644 index 0000000..a87d446 --- /dev/null +++ b/docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md.tasks.json @@ -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" +} diff --git a/docs/plans/2026-07-16-secrets-adoption-otopcua.md b/docs/plans/2026-07-16-secrets-adoption-otopcua.md new file mode 100644 index 0000000..bf5d583 --- /dev/null +++ b/docs/plans/2026-07-16-secrets-adoption-otopcua.md @@ -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 + + + ``` + 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().MigrateAsync(default); + var resolver = secretsProvider.GetRequiredService(); + 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:}"` 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 ` against the same `SqlitePath` + KEK the Host uses. +2. Switch each config value to its `${secret:}` 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().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 `` (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 `` resolves to the AdminUI one + +**Steps:** +1. Add `AddSecretsAuthorization()` in the AdminUI authorization block (via `Configure` 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 `` 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()` 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: "/otopcua-master.key"`; `Secrets.SqlitePath: "/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` 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(...)` 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//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. diff --git a/docs/plans/2026-07-16-secrets-adoption-otopcua.md.tasks.json b/docs/plans/2026-07-16-secrets-adoption-otopcua.md.tasks.json new file mode 100644 index 0000000..290379e --- /dev/null +++ b/docs/plans/2026-07-16-secrets-adoption-otopcua.md.tasks.json @@ -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" +} diff --git a/docs/plans/2026-07-16-secrets-adoption-scadabridge.md b/docs/plans/2026-07-16-secrets-adoption-scadabridge.md new file mode 100644 index 0000000..ca7fdd2 --- /dev/null +++ b/docs/plans/2026-07-16-secrets-adoption-scadabridge.md @@ -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 1–8. 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 2–9 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 `` block at `:75-88` +- `src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj` — add three `` (no `Version` attr under CPM) + +**Steps:** +1. In `nuget.config`, under the `` mapping (after `:27`), add: + ```xml + + + ``` + (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 + + + + ``` +3. In `ZB.MOM.WW.ScadaBridge.Host.csproj`, add three version-less ``, `…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().MigrateAsync(default); + var resolver = secretsProvider.GetRequiredService(); + 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 '' + ``` + (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().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()` 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:` 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:` 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//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 3–4) 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//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 1–8) 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). diff --git a/docs/plans/2026-07-16-secrets-adoption-scadabridge.md.tasks.json b/docs/plans/2026-07-16-secrets-adoption-scadabridge.md.tasks.json new file mode 100644 index 0000000..e66d0c5 --- /dev/null +++ b/docs/plans/2026-07-16-secrets-adoption-scadabridge.md.tasks.json @@ -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" +}