62a96e5f22
- 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.
359 lines
32 KiB
Markdown
359 lines
32 KiB
Markdown
# 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 `<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 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/<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 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).
|