# 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 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).