diff --git a/components/secrets/GAPS.md b/components/secrets/GAPS.md index d1bfedd..6af7c2f 100644 --- a/components/secrets/GAPS.md +++ b/components/secrets/GAPS.md @@ -38,6 +38,7 @@ construction). follow-up: a pre-existing xUnit env-mutation CI-flake that T4 amplifies (single `[Collection]` / disable parallelization for Host.Tests). - **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) diff --git a/docs/plans/2026-07-16-secrets-adoption-otopcua.md b/docs/plans/2026-07-16-secrets-adoption-otopcua.md index bf5d583..ca9cc63 100644 --- a/docs/plans/2026-07-16-secrets-adoption-otopcua.md +++ b/docs/plans/2026-07-16-secrets-adoption-otopcua.md @@ -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`. +> **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 `` rows, and consuming csprojs carry **no** `Version` attribute. New packages need a `` entry **plus** a bare `` (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 @@ -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). - **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 `` rows there, and each consuming csproj carries a **bare** `` (NO `Version` attribute — an inline version won't resolve under CPM). This is the ScadaBridge pattern, NOT inline like the original plan assumed. + **Files:** -- `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` — add `ZB.MOM.WW.Secrets` + `.Abstractions` `PackageReference` @ `0.1.2` -- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` — add `ZB.MOM.WW.Secrets.Ui` @ `0.1.2` -- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/*.csproj` — add `ZB.MOM.WW.Secrets.Abstractions` @ `0.1.2` -- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/*.csproj` — add `ZB.MOM.WW.Secrets.Abstractions` @ `0.1.2` -- `/Users/dohertj2/Desktop/OtOpcUa/NuGet.config:17-27` — the `dohertj2-gitea` `packageSourceMapping` block (explicit patterns, NO wildcard) +- `Directory.Packages.props` (repo root, near the existing `ZB.MOM.WW.Auth.*` rows at ~`:132-134`) — add three `` rows @ `0.1.2`: `ZB.MOM.WW.Secrets`, `ZB.MOM.WW.Secrets.Abstractions`, `ZB.MOM.WW.Secrets.Ui` +- `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` — bare `` for `ZB.MOM.WW.Secrets` + `ZB.MOM.WW.Secrets.Abstractions` (pre-host expander + runtime registration + master-key options) +- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` — bare `` 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.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj` — bare `` 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) +- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj` — bare `` 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:** -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 ``) add: ```xml ``` - Without these, restore 404s (there is no wildcard mapping — verified at NuGet.config:9-29). -2. Add the `PackageReference` rows listed above (OtOpcUa is NOT CPM — versions go inline on each `csproj`, unlike ScadaBridge). + Without these, restore 404s (there is no wildcard mapping — verified). +2. Add the three `` rows to `Directory.Packages.props`, then the bare `` rows to the four csprojs above. 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). @@ -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). +**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:** -- `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:** -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.` 3. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings. 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 | |---|---|---| -| `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:DeployApiKey` | `DeployApiEndpoints` const `AdminUI/Api/DeployApiEndpoints.cs:32` | `otopcua/deploy/api-key` | | `ConfigDb` connstr | `AddOtOpcUaConfigDb` `Configuration/ServiceCollectionExtensions.cs:10,19-25` | `otopcua/sql/configdb-connstr` (or password-only ref inside the connstr) | | `ServerHistorian:ApiKey` | `ServerHistorianOptions` SectionName `ServerHistorian` `Runtime/Historian/ServerHistorianOptions.cs:24,46` | `otopcua/historian/api-key` | +**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:** -- `appsettings.json` / role-overlay appsettings / env-delivery docs — set the five targets to `"${secret:}"` tokens (these are NOT in committed appsettings today per current-state, so the token goes wherever the value is supplied — env or the deployment appsettings; keep a `_secretsComment` example key, skipped per gotcha §8.2) +- `src/Server/ZB.MOM.WW.OtOpcUa.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:}"` 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) **Steps:** @@ -163,15 +179,17 @@ The RCL assembly must be registered in **BOTH** places (design §3d / §6) or `/ **Files:** - `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:143-173` — inside the `AddAuthorization(...)` block (or right after `AddOtOpcUaAuth` at `Program.cs:312`), add `o.AddSecretsAuthorization();` (the admin-role block is admin-gated, which is correct for the UI — the UI only mounts on admin nodes) - `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs:31` — add `.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly)` onto the `MapRazorComponents().AddInteractiveServerRenderMode()` chain (`:31-32`) -- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor:22` — pass `AdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })"` into `` (the param is already plumbed through `Components/Routes.razor:8,37-38`) -- **Do NOT touch** the stale `Host/Routes.razor` (plain RouteView) — `App.razor`'s `` resolves to the AdminUI one +- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor:22` — pass `AdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })"` into `` (currently a bare ``; the param is already plumbed through `Components/Routes.razor` — `[Parameter] AdditionalAssemblies` at `:37-38`, consumed by `` at `:8`) +- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor:22` — add `` 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 `` resolves to the AdminUI RCL one **Steps:** -1. Add `AddSecretsAuthorization()` in the AdminUI authorization block (via `Configure` or directly in the `AddAuthorization` callback at `ServiceCollectionExtensions.cs:143-173`). Confirm it composes additively with the existing `FleetAdmin`/`ConfigEditor`/etc. policies. +1. Add `AddSecretsAuthorization()` in the AdminUI authorization block (via `Configure` or directly in the `AddAuthorization` callback at `ServiceCollectionExtensions.cs:143-173`). Confirm it composes additively with the existing `FleetAdmin`/`ConfigEditor`/etc. policies. **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`. 3. Add the `AdditionalAssemblies` arg to `` at `App.razor:22`. -4. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings. -5. **Browser verify** (AdminUI on `:9000`): unauthenticated `/admin/secrets` → login redirect (policy enforced); Administrator → metadata-only list + **reveal** shows plaintext; confirm an **audit row** is written **with no plaintext** (gotcha §8.4 — reveal audit proven plaintext-free in HistorianGateway). Use `superpowers-extended-cc:executing-plans`' browser step or the multi-role test user (`multi-role`/`password`). +4. Add the `` at `MainLayout.razor:22`. +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 **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. **Files:** -- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs:43-88` — add `secret:` arm before the literal fall-through (~`:78`); change signature to accept `ISecretResolver` (and go `async Task` since `GetAsync` is async — or resolve synchronously via the resolver's cache if a sync seam exists; prefer async and propagate) -- call site 1: `GalaxyDriver` (in `…Driver.Galaxy`) — thread `ISecretResolver` (ctor-injected) -- call site 2: AdminUI `GalaxyDriverBrowser` — thread `ISecretResolver` (DI-injected) -- new test: `src/Drivers/…Driver.Galaxy.Tests/GalaxySecretRefTests.cs` (or existing test project) +- `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` since `GetAsync` is async — or resolve synchronously via the resolver's cache if a sync seam exists; prefer async and propagate) +- call site 1: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:537` — thread `ISecretResolver` (ctor-injected) +- call site 2: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs:125` — thread `ISecretResolver` (DI-injected) +- 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:** 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 **Parallelizable with:** Task 7 (separate driver) -Resolve `secret:` refs for the OpcUaClient driver's `Password` (`OpcUaClientDriverOptions.cs:76`) and `UserCertificatePassword` (`:93`) at the **deserialization hook** — where the `DriverConfig` JSON is deserialized into `OpcUaClientDriverOptions`. **Two hook sites, both mandatory** (surprise d — resolve at BOTH or Test-Connect and runtime diverge): -- (a) `OpcUaClientDriverFactoryExtensions.CreateInstance(driverInstanceId, driverConfigJson, ILoggerFactory?)` (`:48-59`) — `JsonSerializer.Deserialize(...)` at `:54` (shared `JsonOptions` `:27-32`). Runtime path (`DriverHostActor.cs:1704` passes `spec.DriverConfig`, re-sent on `InitializeRequested` `:1756` / `ApplyDelta` `:1790` — surprise b). +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?)` (signature `:48-49`, body to `:59`) — `JsonSerializer.Deserialize(...)` 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`. **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/OpcUaClientDriverProbe.cs:38` (opts at `:24`) — same resolution - `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:216` — `AddOtOpcUaDriverFactories()` registration threads `ISecretResolver` into the factory -- `OpcUaClientDriver.cs:376-385,480-495` — consumers (`BuildUserIdentity` `:382`, `BuildCertificateIdentity`/`LoadPkcs12FromFile` `:495`) receive already-resolved plaintext (no change needed if resolution happens at deserialization) -- new test: `…Driver.OpcUaClient.Tests` — factory + probe resolve `secret:` refs +- `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: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` — factory + probe resolve `secret:` refs **Steps:** 1. **Test first:** fake `ISecretResolver`; assert factory `CreateInstance` with `DriverConfig` JSON where `Password="secret:opcua/inst/password"` yields options carrying the resolved plaintext; assert unknown ref fails closed; mirror for the probe. `dotnet test --filter "FullyQualifiedName~OpcUaClientDriver"` → RED.