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

359 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ScadaBridge — Secrets Adoption (G-3, G-4, G-5, G-6) Implementation Plan
> **✅ EXECUTED 2026-07-16** — merged to `origin/main` @ `128f1596`, G-3 live-proven against the
> real production MxGateway gateway. This document is the **historical record**; the `0.1.2` pins
> are what was landed then, **not** what to use now.
>
> **Follow-on (not yet executed):** bump to `0.2.0` and adopt clustered replication —
> [`2026-07-18-secrets-0.2.0-upgrade-and-clustering.md`](2026-07-18-secrets-0.2.0-upgrade-and-clustering.md),
> Tasks 4, 7, 8, 9. ScadaBridge's recommended topology is **SQL-Server hub mode**
> (`ZB.MOM.WW.Secrets.Replicator.SqlServer`, `AddZbSecretsSqlServerReplication`), **not** Akka.
> Reason: central and each site are **separate Akka clusters** with their own `seed-nodes` lists,
> so `DistributedPubSub` cannot cross the central↔site boundary — an Akka replicator here would
> converge each cluster internally and leave **secret islands that never reconcile**, while every
> node reports healthy. Hub mode gives sites local reads through a WAN outage, which is the
> premise of the hub-and-spoke architecture.
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. Shared context: [`docs/plans/2026-07-16-secrets-adoption-design.md`](2026-07-16-secrets-adoption-design.md) — read it first; this plan **references** that design (library API §1, two layers §2, wiring template §3, G-4 §4, G-5 §5, G-6 §6 incl. the ScadaBridge claim-type caveat, G-3 §7, gotchas §8) rather than re-deriving it.
**Goal:** Adopt the already-built, already-published `ZB.MOM.WW.Secrets` library (`0.1.2`, Gitea feed, reference-consumer-proven in HistorianGateway) into **ScadaBridge** — retiring plaintext credentials at rest. Concretely: **G-3** (retire the MxGateway per-endpoint `ApiKey` plaintext-in-ConfigDb via a runtime `ISecretResolver` `secret:` ref), **G-4** (pre-host `${secret:}` config-secret expansion — ScadaBridge is the heaviest config-secret surface in the family), **G-5** (clustered master-key posture for the central pair + site nodes), **G-6** (mount `/admin/secrets` in CentralUI, with a mandatory claim-type verification).
**Architecture:** No library construction — pure per-app wiring of an existing package following the HistorianGateway template (design §3). Two resolution layers (design §2): **Layer A** = pre-host `${secret:}` config expansion (G-4, entirely config-resident); **Layer B** = runtime `ISecretResolver.GetAsync` for database-resident secrets (G-3, the MxGateway `ApiKey` inside a `DataConnection` JSON blob). ScadaBridge is **Akka-clustered** (central pair + N site nodes) → every node that resolves a `${secret:}` needs the **same KEK and the same store rows** (design §5). Two ScadaBridge-specific hazards drive dedicated tasks: (1) **docker per-node appsettings commit real plaintext dev secrets** plus loose `*_login.txt` files at repo root; (2) a **claim-type mismatch risk** — the library's `AddSecretsAuthorization()` uses `RequireRole(...)` while ScadaBridge authz uses `RequireClaim(JwtTokenService.RoleClaimType, …)`, so an Administrator may not satisfy `secrets:manage`/`secrets:reveal` without alignment.
**Tech Stack:** .NET 10, C#, `ZB.MOM.WW.Secrets{,.Abstractions,.Ui}` @ `0.1.2` from the Gitea NuGet feed (`dohertj2-gitea`), AES-256-GCM envelope crypto, SQLite store, ASP.NET Core authorization + Blazor Server RCL. ScadaBridge uses **Central Package Management** (`Directory.Packages.props`). Solution: `ZB.MOM.WW.ScadaBridge.slnx`.
---
## Branch & sequencing
- Branch `feat/adopt-zb-secrets` off `origin/main` in `~/Desktop/ScadaBridge` (mirrors the auth/theme/audit adoption pattern across the family).
- Commit per task with a clear message; request review at the slice boundaries below.
- On completion: FF-merge to the repo default (`main`) and push to Gitea (`origin`).
- Keep the branch buildable at every commit: never switch a config value to a `${secret:}` token without seeding the secret in the same task (fail-closed at boot — design §8.1). The whole-key env override still works if a token is reverted (rollback path).
## Slices
- **Slice 1 — config + UI + clustering (mostly standard/small):** Tasks 18. Package plumbing (CPM + nuget mapping), the Layer-A pre-host `${secret:}` expander (G-4 mechanism), runtime `AddZbSecrets`, the G-4 config-secret migration + committed-plaintext cleanup, the G-6 claim-type verification + `/admin/secrets` mount, and the G-5 clustered master-key posture. No product data-path changes. Review at end of slice.
- **Slice 2 — G-3 Layer-B MxGateway `ApiKey` (high-risk):** Task 9. Injects `ISecretResolver` into the DCL `MxGatewayDataConnection` adapter and resolves a `secret:`-ref `ApiKey` at the point of use. Touches the runtime data path — TDD, separate review. Task 10 is final verification across both slices.
---
## Task 1 — Package references + CPM versions + nuget.config mapping
**Classification:** small
**Estimated implement time:** 4 min
**Parallelizable with:** none (foundation — Tasks 29 depend on restore succeeding)
**Files:**
- `nuget.config` (repo root) — `packageSourceMapping` for `dohertj2-gitea` at `:13-29` (patterns `:18-27`, no `ZB.MOM.WW.*` wildcard)
- `Directory.Packages.props` (repo root) — existing ZB.MOM `<PackageVersion>` block at `:75-88`
- `src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj` — add three `<PackageReference>` (no `Version` attr under CPM)
**Steps:**
1. In `nuget.config`, under the `<packageSource key="dohertj2-gitea">` mapping (after `:27`), add:
```xml
<package pattern="ZB.MOM.WW.Secrets" />
<package pattern="ZB.MOM.WW.Secrets.*" />
```
(The mapping is explicit with **no** wildcard — restore fails without these two patterns.)
2. In `Directory.Packages.props`, in the ZB.MOM block (`:75-88`), add three CPM version rows:
```xml
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.1.2" />
```
3. In `ZB.MOM.WW.ScadaBridge.Host.csproj`, add three version-less `<PackageReference Include="ZB.MOM.WW.Secrets" />`, `…Secrets.Abstractions`, `…Secrets.Ui` (the Host is where the pre-host expander, runtime registration, and CentralUI/App live). The `.Ui` package transitively pulls `ZB.MOM.WW.Theme` + `ZB.MOM.WW.Auth.AspNetCore` + `ZB.MOM.WW.Audit` — all already referenced, so no conflict.
4. Restore: `dotnet restore ZB.MOM.WW.ScadaBridge.slnx`
- **Expected:** restore succeeds, all three packages resolve at `0.1.2` from `dohertj2-gitea`. If restore 404s, re-check the two nuget mapping patterns (Task premise).
5. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
- **Expected:** 0 warnings, 0 errors (nothing consumes the packages yet).
---
## Task 2 — Layer-A pre-host `${secret:}` expander (G-4 mechanism)
**Classification:** standard
**Estimated implement time:** 5 min
**Parallelizable with:** Task 6 (verification-only) after Task 1
**Files:**
- `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:38-43` (`ConfigurationBuilder…Build()` → `var configuration`), insert **between `:43` and `:46`** (`StartupValidator.Validate(configuration)`)
**Context (design §3, ScadaBridge variant):** ScadaBridge assembles config in a **bare `ConfigurationBuilder` before `WebApplication.CreateBuilder` exists** (`:38-43`), producing an `IConfigurationRoot` local named `configuration`. The expander must run against that local via a **throwaway `ServiceCollection` provider** (bootstrap chicken-and-egg — surprise e: no host DI yet). It MUST run **before `:46`** or `StartupValidator`/`ConfigPreflight` validates unexpanded `${…}` tokens. Top-level statements at `Program.cs` are already async, so `await` is legal here.
**Steps:**
1. Add usings at the top of `Program.cs`: `ZB.MOM.WW.Secrets.Abstractions`, `ZB.MOM.WW.Secrets.Configuration`, `ZB.MOM.WW.Secrets.DependencyInjection`, `ZB.MOM.WW.Secrets.Sqlite`.
2. Immediately after `:43` (the `.Build()` assigning `configuration`) and before `:46`, insert the throwaway-provider expander (design §3a):
```csharp
#pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons
await using (var secretsProvider = new ServiceCollection()
.AddZbSecrets(configuration, "Secrets")
.BuildServiceProvider())
#pragma warning restore ASP0000
{
await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default);
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
await new SecretReferenceExpander(resolver)
.ExpandConfigurationAsync(configuration, default);
}
```
Note: `configuration` is already an `IConfigurationRoot` (result of `.Build()`), so no cast is needed here (unlike the `builder.Configuration` case in the shared template). `_`-prefixed keys (e.g. `_secrets`, `_nodeName`) are auto-skipped by the expander (design §8.2).
3. Add a `"Secrets"` block to `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json` (shared across roles) matching the HistorianGateway shape (design §1):
```json
"Secrets": {
"SqlitePath": "scadabridge-secrets.db",
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
}
```
(Task 8 revises `MasterKey`/`SqlitePath` for the clustered posture.)
4. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
- **Expected:** 0 warnings. With no `${secret:…}` tokens yet in config, the expander is a no-op pass; the store migrates and the app still boots on env-override values.
---
## Task 3 — Runtime `AddZbSecrets` on the host container
**Classification:** small
**Estimated implement time:** 3 min
**Parallelizable with:** Task 4
**Files:**
- `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` — on the real host `builder.Services` (the `WebApplicationBuilder` created after `:46`; the DI-registration region alongside `AddSecurity`/`AddCentralUI`, Program.cs:159-160)
**Context (design §3b, §8.3):** even though G-4 is Layer A, the **runtime** resolver is required for the UI (G-6) and the G-3 Layer-B hook (Task 9). `AddZbSecrets` also registers `SecretsMigrationHostedService` (keeps the store schema current) and lazily binds `IAuditWriter` — ScadaBridge already registers `ZB.MOM.WW.Audit`, so secret reveals/resolves audit automatically through the app's writer (design §8.4).
**Steps:**
1. Locate the host `builder` and its service-registration block (near `AddSecurity`/`AddCentralUI`, Program.cs:159-160). Add:
```csharp
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
```
2. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
- **Expected:** 0 warnings. `ISecretResolver` is now injectable on the runtime container.
---
## Task 4 — G-4: formalize `${SCADABRIDGE_*}` placeholders → `${secret:}` tokens
**Classification:** standard
**Estimated implement time:** 5 min
**Parallelizable with:** Task 3
**Files:**
- `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json` — `_secrets` note `:21`; `Database:ConfigurationDb` `:23`; `Security:Ldap:ServiceAccountPassword` `:33`; `Security:JwtSigningKey` `:35`
**Context (current-state §"Config expansion today"; design §4):** the `${SCADABRIDGE_*}` tokens at `appsettings.Central.json:23,33,35` are **non-functional literal placeholders** today — real values arrive by whole-key env override (`ScadaBridge__…`). The whole-key-override convention is exactly what `${secret:}` replaces. The five config-secret targets and their validators:
- `ScadaBridge:Database:ConfigurationDb` — read `Program.cs:221-222`, validated `StartupValidator.cs:60-62`
- `ScadaBridge:Database:MachineDataDb` — validated `StartupValidator.cs:63-65`
- `ScadaBridge:Security:Ldap:ServiceAccountPassword` — bound via `AddZbLdapAuth(builder.Configuration, LdapSectionPath)` `Program.cs:140-142` (`LdapSectionPath="ScadaBridge:Security:Ldap"`, `Security/ServiceCollectionExtensions.cs:24`)
- `ScadaBridge:Security:JwtSigningKey` — `SecurityOptions.cs:34`, bound `Program.cs:299`
- `ScadaBridge:InboundApi:ApiKeyPepper` (≥16 chars) — `InboundAPI/InboundApiOptions.cs:36`, validated `StartupValidator.cs:89-91`
**Steps:**
1. In `appsettings.Central.json`, replace the whole-key `${SCADABRIDGE_*}` tokens with `${secret:}` refs (canonical `sql/…`, `ldap/…`, `security/…` naming), e.g.:
```json
"ConfigurationDb": "${secret:sql/scadabridge/configdb-connection}",
"ServiceAccountPassword": "${secret:ldap/scadabridge/service-account-password}",
"JwtSigningKey": "${secret:security/scadabridge/jwt-signing-key}"
```
Also cover `Database:MachineDataDb` and `InboundApi:ApiKeyPepper` wherever they appear as literal secrets in `appsettings.Central.json`. Update the `_secrets` note (`:21`) to document the `${secret:}` convention (this key is `_`-prefixed → skipped by the expander; safe for docs).
2. **Seed each referenced secret** (same task — fail-closed, design §8.1). Use the `secret` CLI shipped with the package, e.g.:
```bash
dotnet ZB.MOM.WW.Secrets.Cli.dll set sql/scadabridge/configdb-connection --value '<connstr>'
```
(or seed via the `/admin/secrets` UI once Task 7 lands, then re-run boot). Ensure `ZB_SECRETS_MASTER_KEY` is exported for the seeding process so the same KEK encrypts the rows the app will read.
3. **Boot smoke:** start the Host with `SCADABRIDGE_CONFIG=Central` and the secrets seeded; assert clean boot (`/healthz` 200) and that `StartupValidator` passes (values expanded before `:46`).
4. **Negative control (fail-closed):** unseed/rename one secret → restart → assert `SecretNotFoundException` at startup before validation. Re-seed to restore. (Mirrors the HistorianGateway proof; design §10.)
5. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** 0 warnings.
---
## Task 5 — G-4 cleanup: remove committed dev plaintext + loose `*_login.txt`
**Classification:** small
**Estimated implement time:** 4 min
**Parallelizable with:** Task 4 (adjacent files; coordinate if the same appsettings is touched)
**Files:**
- `docker/central-node-a/appsettings.Central.json` — SQL passwords `:21-22` (`ScadaBridge_Dev1#`), `ServiceAccountPassword: "serviceaccount123"` `:32`, `JwtSigningKey` `:38`
- `docker/central-node-b/appsettings.Central.json` — same fields
- `ldap_login.txt`, `sql_login.txt`, `sqllogin.txt` (repo root, all git-tracked)
**Context (surprises a + b):** the docker per-node files commit **real** plaintext dev secrets, and three loose credential files are tracked at the repo root. Both are unnecessary once `${secret:}` + env-delivered KEK is the convention.
**Steps:**
1. In `docker/central-node-a/appsettings.Central.json` and `docker/central-node-b/appsettings.Central.json`, replace the committed plaintext SQL passwords (`:21-22`), `ServiceAccountPassword` (`:32`, `serviceaccount123`), and `JwtSigningKey` (`:38`) with `${secret:…}` refs matching Task 4's names — OR, if these are intentionally dev-only, delete them entirely and rely on the docker-compose env (`ZB_SECRETS_MASTER_KEY` + seeded dev store). Do not leave real plaintext committed.
2. Delete the loose tracked credential files:
```bash
git -C ~/Desktop/ScadaBridge rm ldap_login.txt sql_login.txt sqllogin.txt
```
3. Grep-sweep for any remaining committed plaintext:
```bash
git -C ~/Desktop/ScadaBridge grep -nE 'ScadaBridge_Dev1#|serviceaccount123|scadabridge-dev-jwt-signing-key' -- '*.json' '*.txt'
```
- **Expected:** no hits after the edits.
4. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** 0 warnings (docker files aren't compiled; this just confirms nothing else broke).
---
## Task 6 — G-6: claim-type VERIFICATION (RequireRole vs RequireClaim)
**Classification:** high-risk (authz — a mismatch silently denies admins)
**Estimated implement time:** 5 min
**Parallelizable with:** Task 2/3 (read-only inspection until remediation is needed)
**Files (inspection):**
- `src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs:157-197` (`AddScadaBridgeAuthorization` — policies use `policy.RequireClaim(JwtTokenService.RoleClaimType, …)`, `:162` etc.)
- `src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs:45` (`Administrator = "Administrator"`)
- `JwtTokenService` (where `RoleClaimType` is defined) — locate via `grep -rn "RoleClaimType" src/ZB.MOM.WW.ScadaBridge.Security`
- Library: `SecretsAuthorization.AddSecretsAuthorization()` (`.Ui` pkg) uses `RequireRole(...)` → reads `ClaimsIdentity.RoleClaimType` / `ZbClaimTypes.Role`
**Context (design §6 claim-type caveat; surprise d):** the library's `secrets:manage`/`secrets:reveal` policies use `RequireRole(...)`, which reads the principal's `ClaimsIdentity.RoleClaimType` (the library expects `ZbClaimTypes.Role`). ScadaBridge's own policies use `RequireClaim(JwtTokenService.RoleClaimType, …)`. If `JwtTokenService.RoleClaimType` is **not** the same claim type the framework's `RequireRole` reads, an Administrator will satisfy `RequireAdmin` but **not** the library's secrets policies → the `/admin/secrets` page 403s for admins even though everything else works.
**Steps:**
1. Determine `JwtTokenService.RoleClaimType`'s value and confirm whether the authenticated `ClaimsPrincipal`'s identity is constructed with `RoleClaimType` set to that same value (i.e. does the JWT/cookie identity map role claims onto `ClaimsIdentity.RoleClaimType`, and is that `ZbClaimTypes.Role` / `ClaimTypes.Role`?). Inspect the identity-construction site (JWT validation `TokenValidationParameters.RoleClaimType`, and/or the cookie claims-principal factory).
2. **Decision (record the outcome in the task's commit message):**
- **If they match** (the role claim the library's `RequireRole` reads is the same type ScadaBridge issues) → no remediation; Task 7's `AddSecretsAuthorization()` is sufficient. Note the evidence.
- **If they do NOT match** → remediate. Preferred, lowest-blast-radius option: **register app-specific policies under the library's exact policy names** `secrets:manage` and `secrets:reveal` using ScadaBridge's own primitive:
```csharp
options.AddPolicy("secrets:manage", p => p.RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator));
options.AddPolicy("secrets:reveal", p => p.RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator));
```
inside the existing `AddAuthorization(options => …)` at `AuthorizationPolicies.cs:159-194`. This shadows the library's `RequireRole`-based policies with matching names bound to ScadaBridge's claim type. Alternative: align the identity's `RoleClaimType` to `ZbClaimTypes.Role` at construction — but that is higher blast radius (touches every existing role check) and is **not** recommended for this task.
3. Defer actually wiring the policy registration to Task 7 (this task decides *which* form Task 7 uses).
---
## Task 7 — G-6: register secrets authorization + mount `/admin/secrets`
**Classification:** standard (authz already de-risked in Task 6)
**Estimated implement time:** 5 min
**Parallelizable with:** none (depends on Task 6 decision + Task 3)
**Files:**
- `src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs:159-194` (inside the existing `AddAuthorization(options => …)`)
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/EndpointExtensions.cs:26-28` (`MapRazorComponents<TApp>().AddInteractiveServerRenderMode().AddAdditionalAssemblies(typeof(MainLayout).Assembly)`)
- `src/ZB.MOM.WW.ScadaBridge.Host/Components/Routes.razor:2-3` (`AdditionalAssemblies="new[] { typeof(…CentralUI.Components.Layout.MainLayout).Assembly }"`)
**Context (design §3d, §6):** the RCL page `ZB.MOM.WW.Secrets.Ui.SecretsPage` (`@page "/admin/secrets"`, `[Authorize(Policy = ManagePolicy)]`) must have its assembly registered in **both** the endpoint mount **and** the router `AdditionalAssemblies`, or the route 404s. The page uses the default `MainLayout` supplied by the Router. `AddScadaBridgeAuthorization` is called from `AddSecurity` (`Security/ServiceCollectionExtensions.cs:216`, invoked `Program.cs:159`).
**Steps:**
1. Register secrets policies inside the existing `AddAuthorization(options => …)` (`AuthorizationPolicies.cs:159-194`):
- If Task 6 found a **match**: `options.AddSecretsAuthorization();` (additive; requires `using ZB.MOM.WW.Secrets.Ui;` or the namespace of `SecretsAuthorization`).
- If Task 6 found a **mismatch**: register the two app-specific `RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator)` policies under names `secrets:manage`/`secrets:reveal` (Task 6 step 2).
2. In `EndpointExtensions.cs:28`, extend the mount:
```csharp
.AddAdditionalAssemblies(typeof(MainLayout).Assembly, typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly)
```
3. In `Routes.razor:3`, add the assembly to `AdditionalAssemblies`:
```razor
AdditionalAssemblies="new[] { typeof(ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout.MainLayout).Assembly, typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly }"
```
4. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** 0 warnings.
5. **Browser verify** (design §10): boot CentralUI; navigate `/admin/secrets`:
- Unauthenticated → login redirect (policy enforced).
- Administrator → metadata-only list; **reveal** shows plaintext; an audit row is written **with no plaintext** (query the app's audit store to confirm — proven pattern from HistorianGateway).
- **This is the real test of Task 6.** If an Administrator gets 403, the claim-type remediation was needed/incorrect — return to Task 6.
---
## Task 8 — G-5: clustered master-key posture (central pair + site nodes)
**Classification:** standard (config + ops posture; no new code)
**Estimated implement time:** 5 min
**Parallelizable with:** Task 4/5
**Files:**
- `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json` — the `"Secrets"` block added in Task 2
- (reference only) `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs` (`BuildHocon` `:186/252`, seeds `:258`, SBR `:290`, singletons `:415-460+`); central seeds e.g. `docker/central-node-a/appsettings.Central.json:10-13`
**Context (design §5; surprise c):** ScadaBridge is Akka-clustered — **both** central nodes of a pair resolve Layer-A `${secret:}` config at boot, so each needs (1) the **same KEK** and (2) the **same store rows**. The library ships a **SQLite-only store** with a **`NoOpSecretReplicator`** — there is **no built-in shared-SQL `ISecretStore`** and no cross-node replication. True replication is the **G-7 hand-off** (out of scope here).
**Steps:**
1. Set the interim clustered posture in the `"Secrets"` block (design §5 recommended interim):
```json
"Secrets": {
"SqlitePath": "/shared/secrets/scadabridge-secrets.db",
"MasterKey": { "Source": "File", "FilePath": "/shared/secrets/master.key" },
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
}
```
- `MasterKey.Source=File` with a **read-only mounted key file identical on every node** (KEK never committed — design §8.6).
- `SqlitePath` → a **single shared/replicated volume** both central nodes mount, so all nodes read identical rows. Secret **writes** are rare + human-driven (via one node's `/admin/secrets` / CLI); **reads** dominate.
2. **Document the fork explicitly** in a comment beside the block / in the plan's rollout notes: SQLite over a network share has multi-writer locking limits (fine for read-mostly single-writer; **not** a substitute for real replication). The integrated **ConfigDb-backed `ISecretStore`** (mirroring the existing `AddDataProtection().PersistKeysToDbContext<ScadaBridgeDbContext>()` key-ring sharing at `ConfigurationDatabase/ServiceCollectionExtensions.cs:80-81`) is the long-term shape but requires new library code → **deferred to G-7**, not attempted here.
3. **Do NOT reconfigure Data Protection** (design §8.5) — the existing DP key ring (no `ProtectKeysWith`, no `SetApplicationName`) is independent of Secrets envelope crypto; touching it risks invalidating cookies/hub tokens.
4. Site nodes: site nodes do not resolve central config secrets, but if any `${secret:}` token appears in `appsettings.Site.json`, the same File-KEK + shared-store rule applies. Confirm none is introduced without the mount.
5. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** 0 warnings (config-only).
---
## Task 9 — G-3: resolve the MxGateway `ApiKey` `secret:` ref at runtime (Layer B)
**Classification:** high-risk (touches the DCL connection/actor data path)
**Estimated implement time:** 5 min
**Parallelizable with:** none (Slice 2; after Slice 1 review)
**Files:**
- `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs:96` (`cfg.ApiKey` passed into `new MxGatewayConnectionOptions(cfg.Endpoint, cfg.ApiKey, …)`) — the resolution hook
- `src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/MxGatewayEndpointConfig.cs:14` (`public string ApiKey { get; set; } = "";`) — the field that will carry a `secret:<name>` ref
- (context) `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/SiteConfiguration.cs:52-56` (column `.HasMaxLength(4000)`, **no** encrypting converter → plaintext today); `ManagementService/ConfigSecretScrubber.cs:16-19` (display/audit redaction only)
**Context (design §7, recommended approach):** the `ApiKey` is a field **inside** the `DataConnection.PrimaryConfiguration`/`BackupConfiguration` JSON blob, so the existing `EncryptedStringConverter` can't target it directly. **Recommended:** store a `secret:<name>` ref in the `ApiKey` field and resolve it via `ISecretResolver.GetAsync` **at consumption** (`MxGatewayDataConnection.cs:96`) — leaves the JSON blob human-readable, no schema change, and matches the family's Layer-B pattern (design §2). **Alternative (note only, do not implement):** encrypt the whole `PrimaryConfiguration`/`BackupConfiguration` column with `EncryptedStringConverter` — fewer refs but encrypts a mixed blob (scrubber + every display/edit path must decrypt first) and the column is read in several places → higher blast radius.
**Steps (TDD):**
1. **Test first.** Add a unit test with a fake `ISecretResolver` (in the DCL test project). Assert:
- a `cfg.ApiKey == "secret:mxgateway/<conn>/api-key"` → resolves to the fake's plaintext before `ConnectAsync`;
- a **literal** `cfg.ApiKey` (no `secret:` prefix) → passes through unchanged (back-compat);
- an **unknown** `secret:` ref (resolver returns `null`) → fails closed (throws, does not connect with an empty key).
Run: `dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~MxGateway"` — **Expected:** red.
2. Thread `ISecretResolver` into `MxGatewayDataConnection` (constructor injection, or via its factory — check how the adapter is constructed by the DCL; the `…DataConnectionLayer` project may need to reference `ZB.MOM.WW.Secrets.Abstractions`). Register is already done (Task 3 on the host container).
3. At `:96`, before building `MxGatewayConnectionOptions`, resolve the key:
```csharp
var apiKey = cfg.ApiKey;
if (apiKey.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
{
var name = apiKey["secret:".Length..];
apiKey = await _secretResolver.GetAsync(new SecretName(name), cancellationToken)
?? throw new InvalidOperationException($"MxGateway ApiKey secret '{name}' not found");
}
```
then pass `apiKey` (not `cfg.ApiKey`) into `MxGatewayConnectionOptions`. Keep the literal path for back-compat (existing deployments store a literal key).
4. Run tests: `dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~MxGateway"` — **Expected:** green.
5. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** 0 warnings.
> Note: the four `EncryptedStringConverter` columns (`ScadaBridgeDbContext.cs:333-347`) and the redaction scrubber are **unchanged** by this task — they are a separate Data-Protection concern (design §8.5). This task only indirects the one plaintext `ApiKey` field.
---
## Task 10 — Verification: build, suites, live connection
**Classification:** standard
**Estimated implement time:** 5 min (+ live, VPN-gated)
**Parallelizable with:** none (final gate)
**Files:** n/a (verification)
**Steps:**
1. **0-warning build:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** 0 warnings, 0 errors (ScadaBridge builds under `TreatWarningsAsErrors` conventions).
2. **Offline suites green** — the DCL / InboundAPI / SiteRuntime suites (per prior deploy notes these are the load-bearing suites):
```bash
dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~DataConnectionLayer"
dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~InboundAPI"
dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~SiteRuntime"
```
Then full: `dotnet test ZB.MOM.WW.ScadaBridge.slnx` — **Expected:** all green.
3. **Boot smoke + fail-closed** re-confirmed (Task 4 steps 34) on the assembled branch.
4. **UI** (Task 7 step 5) re-confirmed: `/admin/secrets` policy-gated + reveal + no-plaintext audit row.
5. **Live (VPN-gated):** with `ZB_SECRETS_MASTER_KEY` present on the box + a real MxGateway endpoint's `ApiKey` switched to a seeded `secret:mxgateway/<conn>/api-key` ref, deploy the DataConnection and confirm the MxGateway adapter **authenticates real traffic** (connection reaches Connected, tags read Good) — mirroring the HistorianGateway live proof (design §10). If VPN/box is unavailable, document as deferred-live and rely on the offline resolver unit test + boot smoke.
6. On green: FF-merge `feat/adopt-zb-secrets` → `main`, push to Gitea.
---
## Testing & rollout
- **Offline gate (blocking):** 0-warning `ZB.MOM.WW.ScadaBridge.slnx` build; DCL/InboundAPI/SiteRuntime + full test suite green; the G-3 resolver unit test (fake `ISecretResolver`: `secret:` ref resolves, literal passes through, unknown fails closed).
- **Boot smoke (blocking):** Host boots `SCADABRIDGE_CONFIG=Central` with the five G-4 config secrets seeded; negative control proves `SecretNotFoundException` fail-closed before `StartupValidator`.
- **UI (blocking):** `/admin/secrets` — login redirect when unauthenticated, Administrator sees metadata + reveal, audit row carries no plaintext. This validates the Task 6 claim-type decision end-to-end.
- **Live (VPN-gated, best-effort):** one real MxGateway DataConnection `ApiKey` as a `secret:` ref authenticates real traffic on the box, with the File-KEK shared across the central pair.
- **Rollout:** Slice 1 (Tasks 18) reviewed and merged first (config/UI/clustering, no data-path change); Slice 2 (Task 9, G-3) as a separate reviewable slice. Per-branch mirroring auth/theme/audit: commit + review, FF-merge to `main`, push to Gitea. KEK delivered out-of-band to every node (never committed); env-override remains the rollback path for any token.
## Deferred / out of scope
- **G-7 — shared store + Akka replicator:** a ConfigDb-backed `ISecretStore` (SQL-Server, mirroring the DP key-ring sharing) or the `ZB.MOM.WW.Secrets.Akka` cross-node replicator. The library today is SQLite-only with `NoOpSecretReplicator`; Task 8 ships the interim File-KEK + shared-SQLite posture and flags this hand-off. Tracked in [`components/secrets/GAPS.md`](../../components/secrets/GAPS.md).
- **G-8 — `RewrapAll` KEK rotation.** Deferred per `components/secrets/GAPS.md`.
- **Akka remoting auth/TLS / shared secure-cookie** (plain TCP today, current-state §row Akka remoting) — a separate hardening concern, not this component.
- **Migrating the four existing `EncryptedStringConverter` columns to Secrets** — they already have at-rest Data-Protection encryption; coexistence is fine (design §8.5), migration is not in scope.
- **mxaccessgw / OtOpcUa** secrets adoption — their own plans (design §5/§9).