docs(secrets): re-verify OtOpcUa adoption plan vs v3.0 master (ec6598ce)

The v3.0 dual-namespace rewrite (PR #472) moved anchors and reshaped the
build, so the plan is refreshed to stay executable: CPM correction (OtOpcUa
is on Central Package Management, not inline versions), OpcUaClient options
in .Contracts + GalaxyDriverBrowser in .Browser, DriverHostActor line
shifts, and appsettings/owner precision. Driver-secret flow itself was
untouched by v3.0, so the two-layer approach holds.
This commit is contained in:
Joseph Doherty
2026-07-16 16:50:14 -04:00
parent 5eea386ebf
commit c6b782e635
2 changed files with 49 additions and 26 deletions
+1
View File
@@ -38,6 +38,7 @@ construction).
follow-up: a pre-existing xUnit env-mutation CI-flake that T4 amplifies (single `[Collection]` / disable follow-up: a pre-existing xUnit env-mutation CI-flake that T4 amplifies (single `[Collection]` / disable
parallelization for Host.Tests). parallelization for Host.Tests).
- **OtOpcUa — planned, NOT executed (the LAST remaining app; recommended next — carries the highest-value G-2 Layer-B driver secrets).** - **OtOpcUa — planned, NOT executed (the LAST remaining app; recommended next — carries the highest-value G-2 Layer-B driver secrets).**
**Plan re-verified 2026-07-16 against `origin/master` @ `ec6598ce`** (the merged **v3.0 dual-namespace** rewrite, PR #472). The v3.0 rewrite did NOT touch the driver-secret flow, so the approach holds; the plan was corrected for: OtOpcUa **is** on Central Package Management (was mis-stated as inline-versioned), `OpcUaClientDriverOptions` lives in `…Driver.OpcUaClient.Contracts` + `GalaxyDriverBrowser` in `…Driver.Galaxy.Browser`, and `DriverHostActor.cs` line shifts (1704/1756/1790 → 1766/1818/1852). Program.cs / Security / AdminUI anchors survived essentially unchanged.
## Design + implementation plans (2026-07-16) ## Design + implementation plans (2026-07-16)
@@ -11,6 +11,14 @@
**Tech Stack:** .NET 10, C#, `ZB.MOM.WW.Secrets{,.Abstractions,.Ui}` @ `0.1.2` from the Gitea NuGet feed, AES-256-GCM envelope crypto, SQLite store, ASP.NET Core authorization + Blazor RCL. Solution `ZB.MOM.WW.OtOpcUa.slnx`. **Tech Stack:** .NET 10, C#, `ZB.MOM.WW.Secrets{,.Abstractions,.Ui}` @ `0.1.2` from the Gitea NuGet feed, AES-256-GCM envelope crypto, SQLite store, ASP.NET Core authorization + Blazor RCL. Solution `ZB.MOM.WW.OtOpcUa.slnx`.
> **Anchors re-verified 2026-07-16 against `origin/master` @ `ec6598ce`** (the merged **v3.0 dual-namespace** rewrite, PR #472). Key findings that changed this plan vs the original design:
> - **The v3.0 rewrite did NOT touch the driver-secret flow.** `GalaxySecretRef` (still `env:`/`file:`/`dev:`/literal, no `secret:` arm), `OpcUaClientDriverOptions.Password`/`UserCertificatePassword` (still plaintext-in-JSON), and `DriverHostActor`'s `spec.DriverConfig` passing all survived structurally intact. So the whole approach (Layer A + Layer B) still holds. The new **Calculation** driver has **no** secrets surface (out of scope).
> - **CPM correction (was backwards):** OtOpcUa **IS** on Central Package Management — `Directory.Packages.props` at repo root, `ManagePackageVersionsCentrally=true`, 110 `<PackageVersion>` rows, and consuming csprojs carry **no** `Version` attribute. New packages need a `<PackageVersion>` entry **plus** a bare `<PackageReference>` (an inline-versioned ref will not resolve). Task 1 rewritten accordingly.
> - **Project-path corrections:** `OpcUaClientDriverOptions` is in `…Driver.OpcUaClient.Contracts`; `GalaxyDriverBrowser` is in its own `…Driver.Galaxy.Browser` project (AdminUI references it), not inside AdminUI.
> - **`DriverHostActor.cs` line shifts** (path unchanged, `src/Server/…Runtime/Drivers/DriverHostActor.cs`): `spec.DriverConfig` create 1704→**1766**, `InitializeRequested` 1756→**1818**, `ApplyDelta` 1790→**1852**.
> - **`LdapOptionsValidator.ValidateOnStart` was hoisted to run unconditionally** (`Program.cs:97-102`, out of the old `hasDriver` gate) so admin-only nodes also validate LDAP — a precedent for registering `AddZbSecrets` unconditionally (Task 3).
> - Program.cs, Security/*, AdminUI/*, driver Options/Probe/Factory files, and `NuGet.config` anchors otherwise survived essentially unchanged in path and line number.
--- ---
## Branch & sequencing ## Branch & sequencing
@@ -41,21 +49,24 @@ Add the three `ZB.MOM.WW.Secrets` packages where they are consumed:
- **AdminUI** (`…OtOpcUa.AdminUI`) — the `/admin/secrets` UI + authorization: `ZB.MOM.WW.Secrets.Ui` (transitively pulls `.Abstractions`; brings `ZB.MOM.WW.Theme` + `ZB.MOM.WW.Auth.AspNetCore` + `ZB.MOM.WW.Audit`, all already referenced). - **AdminUI** (`…OtOpcUa.AdminUI`) — the `/admin/secrets` UI + authorization: `ZB.MOM.WW.Secrets.Ui` (transitively pulls `.Abstractions`; brings `ZB.MOM.WW.Theme` + `ZB.MOM.WW.Auth.AspNetCore` + `ZB.MOM.WW.Audit`, all already referenced).
- **Drivers projects** — for the Layer-B `ISecretResolver` seam: the `Drivers.Galaxy.Contracts` project (`GalaxySecretRef`) and the `Drivers.OpcUaClient` project (factory/probe) need `ZB.MOM.WW.Secrets.Abstractions` (the `ISecretResolver`/`SecretName` seam lives there — do NOT reference the full `.Secrets` impl or `.Ui` from Drivers). - **Drivers projects** — for the Layer-B `ISecretResolver` seam: the `Drivers.Galaxy.Contracts` project (`GalaxySecretRef`) and the `Drivers.OpcUaClient` project (factory/probe) need `ZB.MOM.WW.Secrets.Abstractions` (the `ISecretResolver`/`SecretName` seam lives there — do NOT reference the full `.Secrets` impl or `.Ui` from Drivers).
**OtOpcUa uses Central Package Management** (`Directory.Packages.props`, `ManagePackageVersionsCentrally=true`) — versions go in `<PackageVersion>` rows there, and each consuming csproj carries a **bare** `<PackageReference Include="…" />` (NO `Version` attribute — an inline version won't resolve under CPM). This is the ScadaBridge pattern, NOT inline like the original plan assumed.
**Files:** **Files:**
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` — add `ZB.MOM.WW.Secrets` + `.Abstractions` `PackageReference` @ `0.1.2` - `Directory.Packages.props` (repo root, near the existing `ZB.MOM.WW.Auth.*` rows at ~`:132-134`) — add three `<PackageVersion>` rows @ `0.1.2`: `ZB.MOM.WW.Secrets`, `ZB.MOM.WW.Secrets.Abstractions`, `ZB.MOM.WW.Secrets.Ui`
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj`add `ZB.MOM.WW.Secrets.Ui` @ `0.1.2` - `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj`bare `<PackageReference>` for `ZB.MOM.WW.Secrets` + `ZB.MOM.WW.Secrets.Abstractions` (pre-host expander + runtime registration + master-key options)
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/*.csproj` — add `ZB.MOM.WW.Secrets.Abstractions` @ `0.1.2` - `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` — bare `<PackageReference>` for `ZB.MOM.WW.Secrets.Ui` (transitively pulls `.Abstractions`; brings `ZB.MOM.WW.Theme` + `ZB.MOM.WW.Auth.AspNetCore` + `ZB.MOM.WW.Audit`, all already referenced)
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/*.csproj` — add `ZB.MOM.WW.Secrets.Abstractions` @ `0.1.2` - `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj` — bare `<PackageReference>` for `ZB.MOM.WW.Secrets.Abstractions` (this project defines `GalaxySecretRef.ResolveApiKey`, which gains an `ISecretResolver` param; the two callers `Driver.Galaxy`/`Driver.Galaxy.Browser` inherit the ref transitively)
- `/Users/dohertj2/Desktop/OtOpcUa/NuGet.config:17-27`the `dohertj2-gitea` `packageSourceMapping` block (explicit patterns, NO wildcard) - `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj`bare `<PackageReference>` for `ZB.MOM.WW.Secrets.Abstractions` (the factory + probe that deserialize `OpcUaClientDriverOptions` live here; do NOT reference the full `.Secrets` impl or `.Ui` from Drivers)
- `NuGet.config` (repo root) — the `dohertj2-gitea` `packageSourceMapping` block (`~:16-28`; explicit patterns, NO `ZB.MOM.WW.*` wildcard — verified)
**Steps:** **Steps:**
1. Under `dohertj2-gitea` in `NuGet.config` (after line 27, alongside the existing `ZB.MOM.WW.*` explicit patterns) add: 1. Under `dohertj2-gitea` in `NuGet.config` (after the last existing pattern `ZB.MOM.WW.HistorianGateway.Client` at `~:27`, before the block's closing `</packageSource>`) add:
```xml ```xml
<package pattern="ZB.MOM.WW.Secrets" /> <package pattern="ZB.MOM.WW.Secrets" />
<package pattern="ZB.MOM.WW.Secrets.*" /> <package pattern="ZB.MOM.WW.Secrets.*" />
``` ```
Without these, restore 404s (there is no wildcard mapping — verified at NuGet.config:9-29). Without these, restore 404s (there is no wildcard mapping — verified).
2. Add the `PackageReference` rows listed above (OtOpcUa is NOT CPM — versions go inline on each `csproj`, unlike ScadaBridge). 2. Add the three `<PackageVersion Include="ZB.MOM.WW.Secrets[.…]" Version="0.1.2" />` rows to `Directory.Packages.props`, then the bare `<PackageReference>` rows to the four csprojs above.
3. `dotnet restore ZB.MOM.WW.OtOpcUa.slnx` → expect all three packages resolve from `dohertj2-gitea`. 3. `dotnet restore ZB.MOM.WW.OtOpcUa.slnx` → expect all three packages resolve from `dohertj2-gitea`.
4. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → **0 warnings, 0 errors** (packages referenced but not yet used). 4. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → **0 warnings, 0 errors** (packages referenced but not yet used).
@@ -107,11 +118,13 @@ Register the runtime `ISecretResolver` on the **real** host container (design §
Therefore register `AddZbSecrets(builder.Configuration, "Secrets")` **unconditionally** (outside both the admin-only and driver-only role blocks), so `ISecretResolver` + `SecretsMigrationHostedService` are present on **every** node regardless of role. This is independent of the admin-only DP registration — do NOT reconfigure Data Protection (gotcha §8.5). Therefore register `AddZbSecrets(builder.Configuration, "Secrets")` **unconditionally** (outside both the admin-only and driver-only role blocks), so `ISecretResolver` + `SecretsMigrationHostedService` are present on **every** node regardless of role. This is independent of the admin-only DP registration — do NOT reconfigure Data Protection (gotcha §8.5).
**Role-gate layout (verified):** the `hasDriver` block is `Program.cs:104-283`; the `hasAdmin` block is `:309-324` (condition `if (hasAdmin)` at `:309`); `var app = builder.Build();` is at `:329`. The clean unconditional gap is `:325-328` — right after the `hasAdmin` block closes, alongside the already-unconditional `builder.Services.AddOtOpcUaHealth();` (`~:326`).
**Files:** **Files:**
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` — add a single `builder.Services.AddZbSecrets(builder.Configuration, "Secrets");` on the unconditional service-registration path (before the role-gated `Program.cs:309-324` admin block and outside the `hasDriver` block); usings from Task 2 - `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:~326` — add a single `builder.Services.AddZbSecrets(builder.Configuration, "Secrets");` on the unconditional path (after the `hasAdmin` block ends `:324`, outside the `hasDriver` block, before `builder.Build()` `:329`); usings from Task 2
**Steps:** **Steps:**
1. Locate the unconditional service-registration region (before the `Program.cs:309-324` admin-only block, before the `hasDriver` block). Add `builder.Services.AddZbSecrets(builder.Configuration, "Secrets");`. 1. In the unconditional region between the `hasAdmin` block close (`:324`) and `var app = builder.Build();` (`:329`) — next to `AddOtOpcUaHealth()` — add `builder.Services.AddZbSecrets(builder.Configuration, "Secrets");`.
2. Add a code comment: `// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.` 2. Add a code comment: `// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.`
3. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings. 3. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings.
4. Boot smoke, admin+driver node (default): clean boot, `ISecretResolver` resolvable, `SecretsMigrationHostedService` runs once. 4. Boot smoke, admin+driver node (default): clean boot, `ISecretResolver` resolvable, `SecretsMigrationHostedService` runs once.
@@ -129,14 +142,17 @@ Switch OtOpcUa's Layer-A config-resident secrets to `${secret:}` tokens, seeding
| Config key | Owner (SectionName / const) | Seed name | | Config key | Owner (SectionName / const) | Seed name |
|---|---|---| |---|---|---|
| `Security:Jwt:SigningKey` | `JwtOptions` `Security/Jwt/JwtOptions.cs:5,9` (MinSigningKeyBytes 32) | `otopcua/jwt/signing-key` | | `Security:Jwt:SigningKey` | `JwtOptions` `Security/Jwt/JwtOptions.cs` (SectionName `:5`, `MinSigningKeyBytes = 32` `:6`, `SigningKey` `:9`) | `otopcua/jwt/signing-key` |
| `Security:Ldap:ServiceAccountPassword` | `LdapOptions` `Security/Ldap/LdapOptions.cs:22,63` | `otopcua/ldap/service-account-password` | | `Security:Ldap:ServiceAccountPassword` | `LdapOptions` `Security/Ldap/LdapOptions.cs:22,63` | `otopcua/ldap/service-account-password` |
| `Security:DeployApiKey` | `DeployApiEndpoints` const `AdminUI/Api/DeployApiEndpoints.cs:32` | `otopcua/deploy/api-key` | | `Security:DeployApiKey` | `DeployApiEndpoints` const `AdminUI/Api/DeployApiEndpoints.cs:32` | `otopcua/deploy/api-key` |
| `ConfigDb` connstr | `AddOtOpcUaConfigDb` `Configuration/ServiceCollectionExtensions.cs:10,19-25` | `otopcua/sql/configdb-connstr` (or password-only ref inside the connstr) | | `ConfigDb` connstr | `AddOtOpcUaConfigDb` `Configuration/ServiceCollectionExtensions.cs:10,19-25` | `otopcua/sql/configdb-connstr` (or password-only ref inside the connstr) |
| `ServerHistorian:ApiKey` | `ServerHistorianOptions` SectionName `ServerHistorian` `Runtime/Historian/ServerHistorianOptions.cs:24,46` | `otopcua/historian/api-key` | | `ServerHistorian:ApiKey` | `ServerHistorianOptions` SectionName `ServerHistorian` `Runtime/Historian/ServerHistorianOptions.cs:24,46` | `otopcua/historian/api-key` |
**Committed-appsettings reality (verified):** of the five targets, only `ServerHistorian:ApiKey` is physically present in a committed appsettings — an **empty-string placeholder** at `Host/appsettings.json:18` with a sibling `_ApiKeyComment` at `:19`. The other four (`Jwt:SigningKey`, `Ldap:ServiceAccountPassword`, `Security:DeployApiKey`, `ConnectionStrings:ConfigDb`) are **not committed anywhere** — supplied via env only. `appsettings.Development.json` sets `Security:Ldap:DevStubMode = true`, so dev boots without an LDAP password. So: switch the `ServerHistorian:ApiKey` placeholder to a `${secret:otopcua/historian/api-key}` token in `appsettings.json:18`; for the other four, the token goes wherever the value is supplied (env delivery, or the deployment appsettings) — document it, don't invent a committed plaintext to replace.
**Files:** **Files:**
- `appsettings.json` / role-overlay appsettings / env-delivery docs — set the five targets to `"${secret:<name>}"` tokens (these are NOT in committed appsettings today per current-state, so the token goes wherever the value is supplied — env or the deployment appsettings; keep a `_secretsComment` example key, skipped per gotcha §8.2) - `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json:18` — swap the empty `ServerHistorian:ApiKey` placeholder for `"${secret:otopcua/historian/api-key}"` (keep/adjust the `_ApiKeyComment` at `:19`, which is `_`-prefixed → skipped per gotcha §8.2)
- env-delivery docs / deployment appsettings — set the other four targets to their `"${secret:<name>}"` tokens where each value is supplied
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs:40-45` — update the "env-var-only, NEVER commit" doc comment to note the value now also accepts a `${secret:}` token resolved pre-host (surprise g) - `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs:40-45` — update the "env-var-only, NEVER commit" doc comment to note the value now also accepts a `${secret:}` token resolved pre-host (surprise g)
**Steps:** **Steps:**
@@ -163,15 +179,17 @@ The RCL assembly must be registered in **BOTH** places (design §3d / §6) or `/
**Files:** **Files:**
- `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:143-173` — inside the `AddAuthorization(...)` block (or right after `AddOtOpcUaAuth` at `Program.cs:312`), add `o.AddSecretsAuthorization();` (the admin-role block is admin-gated, which is correct for the UI — the UI only mounts on admin nodes) - `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:143-173` — inside the `AddAuthorization(...)` block (or right after `AddOtOpcUaAuth` at `Program.cs:312`), add `o.AddSecretsAuthorization();` (the admin-role block is admin-gated, which is correct for the UI — the UI only mounts on admin nodes)
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs:31` — add `.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly)` onto the `MapRazorComponents<TApp>().AddInteractiveServerRenderMode()` chain (`:31-32`) - `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs:31` — add `.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly)` onto the `MapRazorComponents<TApp>().AddInteractiveServerRenderMode()` chain (`:31-32`)
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor:22` — pass `AdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })"` into `<Routes/>` (the param is already plumbed through `Components/Routes.razor:8,37-38`) - `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor:22` — pass `AdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })"` into `<Routes/>` (currently a bare `<Routes/>`; the param is already plumbed through `Components/Routes.razor` — `[Parameter] AdditionalAssemblies` at `:37-38`, consumed by `<Router AdditionalAssemblies=…>` at `:8`)
- **Do NOT touch** the stale `Host/Routes.razor` (plain RouteView) — `App.razor`'s `<Routes/>` resolves to the AdminUI one - `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor:22` — add `<NavRailItem Href="/admin/secrets" Text="Secrets" />` after the `Role grants` item (`:22`) in the Navigation `NavRailSection` (`:13-23`). Nav items here are NOT markup-gated — the page's own `[Authorize(Policy = ManagePolicy)]` gates access, so an unconditional link is correct (non-admins get redirected).
- **Do NOT touch** the stale `Host/Routes.razor` (plain `RouteView`, still present but dead/unreferenced) — `App.razor`'s `<Routes/>` resolves to the AdminUI RCL one
**Steps:** **Steps:**
1. Add `AddSecretsAuthorization()` in the AdminUI authorization block (via `Configure<AuthorizationOptions>` or directly in the `AddAuthorization` callback at `ServiceCollectionExtensions.cs:143-173`). Confirm it composes additively with the existing `FleetAdmin`/`ConfigEditor`/etc. policies. 1. Add `AddSecretsAuthorization()` in the AdminUI authorization block (via `Configure<AuthorizationOptions>` or directly in the `AddAuthorization` callback at `ServiceCollectionExtensions.cs:143-173`). Confirm it composes additively with the existing `FleetAdmin`/`ConfigEditor`/etc. policies. **Claim-type is a confirmed MATCH** — `FleetAdmin` uses `RequireRole("Administrator")` (`ServiceCollectionExtensions.cs:160`) reading the standard `ClaimTypes.Role`, same claim `AddSecretsAuthorization()`'s `AdminRole="Administrator"` reads (independently confirmed via `MainLayout.razor:40-41` filtering `ClaimTypes.Role`). No new role mapping, no ScadaBridge-style mismatch.
2. Add `.AddAdditionalAssemblies(...)` at `EndpointRouteBuilderExtensions.cs:31`. 2. Add `.AddAdditionalAssemblies(...)` at `EndpointRouteBuilderExtensions.cs:31`.
3. Add the `AdditionalAssemblies` arg to `<Routes/>` at `App.razor:22`. 3. Add the `AdditionalAssemblies` arg to `<Routes/>` at `App.razor:22`.
4. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings. 4. Add the `<NavRailItem>` at `MainLayout.razor:22`.
5. **Browser verify** (AdminUI on `:9000`): unauthenticated `/admin/secrets` → login redirect (policy enforced); Administrator → metadata-only list + **reveal** shows plaintext; confirm an **audit row** is written **with no plaintext** (gotcha §8.4 — reveal audit proven plaintext-free in HistorianGateway). Use `superpowers-extended-cc:executing-plans`' browser step or the multi-role test user (`multi-role`/`password`). 5. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings.
6. **Browser verify** (AdminUI on `:9000`): unauthenticated `/admin/secrets` → login redirect (policy enforced); Administrator → metadata-only list + **reveal** shows plaintext; confirm an **audit row** is written **with no plaintext** (gotcha §8.4 — reveal audit proven plaintext-free in HistorianGateway). Use `superpowers-extended-cc:executing-plans`' browser step or the multi-role test user (`multi-role`/`password`).
--- ---
@@ -209,15 +227,19 @@ OtOpcUa is Akka-clustered (`Program.cs:287-297`, `AddAkka("otopcua", …)`) →
**Estimated implement time:** 5 min **Estimated implement time:** 5 min
**Parallelizable with:** none (Task 8 shares the OpcUaClient path but is a separate file; can proceed after this if reviewers want serial) **Parallelizable with:** none (Task 8 shares the OpcUaClient path but is a separate file; can proceed after this if reviewers want serial)
Add a `secret:` arm to `GalaxySecretRef.ResolveApiKey` (`Driver.Galaxy.Contracts/GalaxySecretRef.cs`), resolving via `ISecretResolver.GetAsync`, replacing the cleartext-in-DB `dev:`/literal path. The class doc (`:23-24`) already anticipates this. **Structural change:** `ResolveApiKey` is `public static string ResolveApiKey(string secretRef, ILogger? logger = null)` (`:43`) with no DI — it must accept an `ISecretResolver` parameter (signature change). Both call sites (`GalaxyDriver` + AdminUI `GalaxyDriverBrowser`, per the remarks at `:26-29`) must be updated to thread the resolver in. Add a `secret:` arm to `GalaxySecretRef.ResolveApiKey` (`Driver.Galaxy.Contracts/GalaxySecretRef.cs`), resolving via `ISecretResolver.GetAsync`, replacing the cleartext-in-DB `dev:`/literal path. The class doc (`:23-24`) anticipates exactly this kind of swap ("a future PR can swap any of these arms … without changing the call site"). **Structural change:** `ResolveApiKey` is still `public static string ResolveApiKey(string secretRef, ILogger? logger = null)` (`:43`) with no DI — it must accept an `ISecretResolver` parameter (signature change). Both call sites (per the remarks at `:26-30`) must be updated to thread the resolver in:
- `Driver.Galaxy/GalaxyDriver.cs:537` — `ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger)`
- `Driver.Galaxy.Browser/GalaxyDriverBrowser.cs:125` — same call. **Note:** `GalaxyDriverBrowser` is in its own project `…Driver.Galaxy.Browser` (AdminUI references it and registers it as `IDriverBrowser` at `AdminUI/ServiceCollectionExtensions.cs:59`), not inside AdminUI.
Current arms in the file: `env:` (`:47-55`), `file:` (`:57-70`), `dev:` (`:72-77`), literal fall-through (`:79-87`, `return secretRef;` at `:87`) with the "cleartext in DB" warning at `:83-86`.
**TDD.** Write the fake-`ISecretResolver` unit test first. **TDD.** Write the fake-`ISecretResolver` unit test first.
**Files:** **Files:**
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs:43-88` — add `secret:` arm before the literal fall-through (~`:78`); change signature to accept `ISecretResolver` (and go `async Task<string>` since `GetAsync` is async — or resolve synchronously via the resolver's cache if a sync seam exists; prefer async and propagate) - `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs:43-87` — add `secret:` arm before the literal fall-through (before `:79`); change signature to accept `ISecretResolver` (and go `async Task<string>` since `GetAsync` is async — or resolve synchronously via the resolver's cache if a sync seam exists; prefer async and propagate)
- call site 1: `GalaxyDriver` (in `…Driver.Galaxy`) — thread `ISecretResolver` (ctor-injected) - call site 1: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:537` — thread `ISecretResolver` (ctor-injected)
- call site 2: AdminUI `GalaxyDriverBrowser` — thread `ISecretResolver` (DI-injected) - call site 2: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs:125` — thread `ISecretResolver` (DI-injected)
- new test: `src/Drivers/Driver.Galaxy.Tests/GalaxySecretRefTests.cs` (or existing test project) - test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/` — extend the existing `GalaxyDriverApiKeyResolverTests.cs` (there is no dedicated `…Contracts.Tests` project; this suite already references the Contracts project) with the `secret:`-arm cases
**Steps:** **Steps:**
1. **Test first:** add `GalaxySecretRefTests` — a fake `ISecretResolver` returning a known value for `secret:galaxy/inst1/apikey`; assert (a) `secret:` ref resolves to the fake value, (b) an unknown `secret:` ref fails closed (resolver returns `null` → method throws/returns fail-closed, not silent literal fall-through), (c) existing `env:`/`file:`/`dev:`/literal arms unchanged. `dotnet test --filter "FullyQualifiedName~GalaxySecretRefTests"` → RED. 1. **Test first:** add `GalaxySecretRefTests` — a fake `ISecretResolver` returning a known value for `secret:galaxy/inst1/apikey`; assert (a) `secret:` ref resolves to the fake value, (b) an unknown `secret:` ref fails closed (resolver returns `null` → method throws/returns fail-closed, not silent literal fall-through), (c) existing `env:`/`file:`/`dev:`/literal arms unchanged. `dotnet test --filter "FullyQualifiedName~GalaxySecretRefTests"` → RED.
@@ -235,8 +257,8 @@ Add a `secret:` arm to `GalaxySecretRef.ResolveApiKey` (`Driver.Galaxy.Contracts
**Estimated implement time:** 5 min **Estimated implement time:** 5 min
**Parallelizable with:** Task 7 (separate driver) **Parallelizable with:** Task 7 (separate driver)
Resolve `secret:` refs for the OpcUaClient driver's `Password` (`OpcUaClientDriverOptions.cs:76`) and `UserCertificatePassword` (`:93`) at the **deserialization hook** — where the `DriverConfig` JSON is deserialized into `OpcUaClientDriverOptions`. **Two hook sites, both mandatory** (surprise d — resolve at BOTH or Test-Connect and runtime diverge): Resolve `secret:` refs for the OpcUaClient driver's `Password` (`…Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs:76`) and `UserCertificatePassword` (`:93`) at the **deserialization hook** — where the `DriverConfig` JSON is deserialized into `OpcUaClientDriverOptions`. **Note the Options POCO lives in the `.Contracts` project; the two hook sites below live in the `Driver.OpcUaClient` project.** **Two hook sites, both mandatory** (surprise d — resolve at BOTH or Test-Connect and runtime diverge):
- (a) `OpcUaClientDriverFactoryExtensions.CreateInstance(driverInstanceId, driverConfigJson, ILoggerFactory?)` (`:48-59`) — `JsonSerializer.Deserialize<OpcUaClientDriverOptions>(...)` at `:54` (shared `JsonOptions` `:27-32`). Runtime path (`DriverHostActor.cs:1704` passes `spec.DriverConfig`, re-sent on `InitializeRequested` `:1756` / `ApplyDelta` `:1790` — surprise b). - (a) `OpcUaClientDriverFactoryExtensions.CreateInstance(driverInstanceId, driverConfigJson, ILoggerFactory?)` (signature `:48-49`, body to `:59`) — `JsonSerializer.Deserialize<OpcUaClientDriverOptions>(...)` at `:54` (shared `JsonOptions` `:27-32`). Runtime path: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:1766` passes `spec.DriverConfig` into `TryCreate`, re-sent on `InitializeRequested` `:1818` / `ApplyDelta` `:1852` — surprise b. (These shifted ~+62 lines in v3; path unchanged.)
- (b) `OpcUaClientDriverProbe.cs:38` (AdminUI Test-Connect), same shape, opts at `:24`. - (b) `OpcUaClientDriverProbe.cs:38` (AdminUI Test-Connect), same shape, opts at `:24`.
**Structural gotcha (surprise c):** the `Drivers` project has no DI/`IConfiguration` — `ISecretResolver` must be threaded in as a ctor/param dependency. The factory is registered via `AddOtOpcUaDriverFactories()` at `Program.cs:216`; wire the resolver into the factory registration. The resolver is guaranteed present because Task 3 registered `AddZbSecrets` unconditionally. **Structural gotcha (surprise c):** the `Drivers` project has no DI/`IConfiguration` — `ISecretResolver` must be threaded in as a ctor/param dependency. The factory is registered via `AddOtOpcUaDriverFactories()` at `Program.cs:216`; wire the resolver into the factory registration. The resolver is guaranteed present because Task 3 registered `AddZbSecrets` unconditionally.
@@ -247,8 +269,8 @@ Resolve `secret:` refs for the OpcUaClient driver's `Password` (`OpcUaClientDriv
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs:48-59` (deserialize at `:54`) — after deserialize, resolve `secret:`-prefixed `Password`/`UserCertificatePassword` via injected `ISecretResolver` - `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs:48-59` (deserialize at `:54`) — after deserialize, resolve `secret:`-prefixed `Password`/`UserCertificatePassword` via injected `ISecretResolver`
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs:38` (opts at `:24`) — same resolution - `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs:38` (opts at `:24`) — same resolution
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:216` — `AddOtOpcUaDriverFactories()` registration threads `ISecretResolver` into the factory - `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:216` — `AddOtOpcUaDriverFactories()` registration threads `ISecretResolver` into the factory
- `OpcUaClientDriver.cs:376-385,480-495` — consumers (`BuildUserIdentity` `:382`, `BuildCertificateIdentity`/`LoadPkcs12FromFile` `:495`) receive already-resolved plaintext (no change needed if resolution happens at deserialization) - `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs` — consumers (`BuildUserIdentity` `:376`, using `options.Password` at `:382`; `LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword)` at `:495`) receive already-resolved plaintext (no change needed if resolution happens at deserialization)
- new test: `Driver.OpcUaClient.Tests` — factory + probe resolve `secret:` refs - new test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` — factory + probe resolve `secret:` refs
**Steps:** **Steps:**
1. **Test first:** fake `ISecretResolver`; assert factory `CreateInstance` with `DriverConfig` JSON where `Password="secret:opcua/inst/password"` yields options carrying the resolved plaintext; assert unknown ref fails closed; mirror for the probe. `dotnet test --filter "FullyQualifiedName~OpcUaClientDriver"` → RED. 1. **Test first:** fake `ISecretResolver`; assert factory `CreateInstance` with `DriverConfig` JSON where `Password="secret:opcua/inst/password"` yields options carrying the resolved plaintext; assert unknown ref fails closed; mirror for the probe. `dotnet test --filter "FullyQualifiedName~OpcUaClientDriver"` → RED.