Shared design (two resolution layers, wiring template, master-key/clustering fork) + per-repo executable plans with code-verified anchors and co-located .tasks.json: - mxaccessgw (G-4/G-5/G-6, 8 tasks) - otopcua (G-2/G-4/G-5/G-6, 10 tasks, 2 slices) - scadabridge (G-3/G-4/G-5/G-6, 10 tasks, 2 slices) Linked from components/secrets/GAPS.md.
29 KiB
OtOpcUa — Secrets Adoption (G-2, G-4, G-5, G-6) Implementation Plan
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.
Goal: Adopt the already-built, already-published ZB.MOM.WW.Secrets library (@ 0.1.2, Gitea feed, reference-consumer-proven in HistorianGateway) into OtOpcUa — retiring plaintext credentials at rest. Covers G-4 (pre-host ${secret:} config-secret expansion), G-6 (mount /admin/secrets UI), G-5 (clustered master-key posture), and the highest-value gap G-2 (retire cleartext-in-DB driver secrets via runtime ISecretResolver).
Architecture: No library construction — every task is per-app wiring of an existing package, following the HistorianGateway template (design §3) verbatim. OtOpcUa is the heaviest app because it spans both resolution layers (design §2):
- Layer A — pre-host config expansion (
${secret:}) — G-4. Config-resident secrets (Security:Jwt:SigningKey,Security:Ldap:ServiceAccountPassword,Security:DeployApiKey,ConfigDbconnstr,ServerHistorian:ApiKey) are rewritten once, before the host is built, by a throwaway bootstrap provider. Also underpins G-6 (UI). - Layer B — runtime resolution (
ISecretResolver.GetAsync) — G-2. Per-driverDriverConfigJSON secrets (Galaxy API key, OpcUaClientPassword/UserCertificatePassword) live as data in a ConfigDb row, are re-deserialized on every deploy/delta, and are resolved at the point of use in driver-factory code. The pre-host expander never sees these.
Critical clustering fact: OtOpcUa is Akka-clustered (Program.cs:287-297). Layer-B driver secrets resolve on DRIVER-role nodes, which do NOT register auth / Data-Protection / AdminUI (those are admin-role-gated, Program.cs:309-324). Therefore AddZbSecrets (the runtime ISecretResolver + its KEK access) must be registered UNCONDITIONALLY, and every node needs the same KEK + same store rows (design §5).
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.
Branch & sequencing
- Branch
feat/adopt-zb-secretsofforigin/master(OtOpcUa's default branch ismaster, notmain— remoteorigin=https://gitea.dohertylan.com/dohertj2/lmxopcua). - Mirror how auth / theme / audit were adopted across the family: commit + review on the feature branch, then FF-merge into
masterand push to Gitea. Keep thefeat/*branch locally as history. - Do not push mid-slice; land Slice 1 reviewed-and-green, then Slice 2.
Slices
- Slice 1 — Layer A + UI + plumbing + master-key (lands first, one reviewable slice):
Task 1 (package plumbing) → Task 2 (pre-host expander, G-4 mechanism) → Task 3 (unconditional runtime
AddZbSecrets) → Task 4 (switch config secrets to${secret:}, G-4) → Task 5 (mount/admin/secrets, G-6) → Task 6 (File master-key posture for clustered nodes, G-5). Review + FF-merge + push. - Slice 2 — Layer B driver secrets (G-2), separate reviewable slice:
Task 7 (GalaxySecretRef
secret:arm) → Task 8 (OpcUaClient factory + probe hooks — both call sites) → Task 9 (AdminUI editor round-trip preserves refs) → Task 10 (verification incl. live Galaxy secret-ref). Review + FF-merge + push.
Slice 2 depends on Slice 1's Task 3 (the runtime ISecretResolver registration) being in place. Within a slice, only the noted Parallelizable with tasks are independent.
Task 1 — Package references + NuGet.config mapping
Classification: small/trivial Estimated implement time: 4 min Parallelizable with: none (gates every other task — restore must succeed first)
Add the three ZB.MOM.WW.Secrets packages where they are consumed:
- Host (
…OtOpcUa.Host) — pre-host expander + runtime registration + master-key options:ZB.MOM.WW.Secrets,ZB.MOM.WW.Secrets.Abstractions. - AdminUI (
…OtOpcUa.AdminUI) — the/admin/secretsUI + authorization:ZB.MOM.WW.Secrets.Ui(transitively pulls.Abstractions; bringsZB.MOM.WW.Theme+ZB.MOM.WW.Auth.AspNetCore+ZB.MOM.WW.Audit, all already referenced). - Drivers projects — for the Layer-B
ISecretResolverseam: theDrivers.Galaxy.Contractsproject (GalaxySecretRef) and theDrivers.OpcUaClientproject (factory/probe) needZB.MOM.WW.Secrets.Abstractions(theISecretResolver/SecretNameseam lives there — do NOT reference the full.Secretsimpl or.Uifrom Drivers).
Files:
src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj— addZB.MOM.WW.Secrets+.AbstractionsPackageReference@0.1.2src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj— addZB.MOM.WW.Secrets.Ui@0.1.2src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/*.csproj— addZB.MOM.WW.Secrets.Abstractions@0.1.2src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/*.csproj— addZB.MOM.WW.Secrets.Abstractions@0.1.2/Users/dohertj2/Desktop/OtOpcUa/NuGet.config:17-27— thedohertj2-giteapackageSourceMappingblock (explicit patterns, NO wildcard)
Steps:
- Under
dohertj2-giteainNuGet.config(after line 27, alongside the existingZB.MOM.WW.*explicit patterns) add:Without these, restore 404s (there is no wildcard mapping — verified at NuGet.config:9-29).<package pattern="ZB.MOM.WW.Secrets" /> <package pattern="ZB.MOM.WW.Secrets.*" /> - Add the
PackageReferencerows listed above (OtOpcUa is NOT CPM — versions go inline on eachcsproj, unlike ScadaBridge). dotnet restore ZB.MOM.WW.OtOpcUa.slnx→ expect all three packages resolve fromdohertj2-gitea.dotnet build ZB.MOM.WW.OtOpcUa.slnx→ 0 warnings, 0 errors (packages referenced but not yet used).
Task 2 — Pre-host ${secret:} expander in Host/Program.cs (G-4 mechanism)
Classification: standard Estimated implement time: 5 min Parallelizable with: Task 5, Task 6 (different regions/files)
Insert the Layer-A pre-host expansion (design §3a) into the assembled configuration provider stack. Insertion point: immediately after Host/Program.cs:73 — the role-overlay block (:59-73) is complete, and env-vars (:71) + command-line (:72) have been re-appended to the top of the provider stack, so ${secret:} tokens supplied by ANY source are visible. It must run before AddOtOpcUaConfigDb(builder.Configuration) at :94 (reads the ConfigDb connstr) and before the first ValidateOnStart bind at :102 (LdapOptionsValidator).
This is the mechanism only — no config value is switched to a token yet (Task 4 does that), so this task is behavior-neutral (no ${secret:} tokens present → expander is a no-op pass).
Files:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:73(insert after) — before:94AddOtOpcUaConfigDb, before:102first validated bind- usings:
ZB.MOM.WW.Secrets.Abstractions,ZB.MOM.WW.Secrets.Configuration,ZB.MOM.WW.Secrets.DependencyInjection,ZB.MOM.WW.Secrets.Sqlite
Steps:
- After
Program.cs:73, insert the throwaway-provider block (design §3a verbatim):Note the cast to#pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons await using (var secretsProvider = new ServiceCollection() .AddZbSecrets(builder.Configuration, "Secrets") .BuildServiceProvider()) #pragma warning restore ASP0000 { await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default); var resolver = secretsProvider.GetRequiredService<ISecretResolver>(); await new SecretReferenceExpander(resolver) .ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default); }IConfigurationRoot(aWebApplicationBuilder.Configurationis aConfigurationManager, which implementsIConfigurationRoot). - Add a
Secretssection toappsettings.json(design §1 block) —SqlitePath: "otopcua-secrets.db",MasterKey.Source: "Environment",EnvVarName: "ZB_SECRETS_MASTER_KEY",RunMigrationsOnStartup: true,ResolveCacheTtl: "00:00:30". (Task 6 revisitsMasterKeyfor the clustered posture.) dotnet build ZB.MOM.WW.OtOpcUa.slnx→ 0 warnings.- Boot smoke (no tokens present yet,
ZB_SECRETS_MASTER_KEYseeded with a base64 32-byte dev key):dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Host→ clean startup, secrets store migrates, expander is a no-op. Expected: identical boot to baseline.
Task 3 — Register AddZbSecrets on the runtime host UNCONDITIONALLY (surprise f)
Classification: high-risk (cross-node resolution; touches the conditional role-gated registration structure) Estimated implement time: 5 min Parallelizable with: none within Slice 1 (Slice 2's Layer-B tasks depend on this)
Register the runtime ISecretResolver on the real host container (design §3b). Registration asymmetry — the biggest structural gotcha (surprise f): Auth (AddOtOpcUaAuth, Program.cs:312), Data Protection (ServiceCollectionExtensions.cs:73-75), and AdminUI (Program.cs:313,352) are all admin-role-gated (Program.cs:309-324 block). But Layer-B driver-secret resolution (Slice 2) runs on DRIVER-role nodes (the hasDriver block) — a driver-only node has no auth/DP/AdminUI, yet must resolve DriverConfig secret: refs.
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).
Files:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs— add a singlebuilder.Services.AddZbSecrets(builder.Configuration, "Secrets");on the unconditional service-registration path (before the role-gatedProgram.cs:309-324admin block and outside thehasDriverblock); usings from Task 2
Steps:
- Locate the unconditional service-registration region (before the
Program.cs:309-324admin-only block, before thehasDriverblock). Addbuilder.Services.AddZbSecrets(builder.Configuration, "Secrets");. - Add a code comment:
// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI. dotnet build ZB.MOM.WW.OtOpcUa.slnx→ 0 warnings.- Boot smoke, admin+driver node (default): clean boot,
ISecretResolverresolvable,SecretsMigrationHostedServiceruns once. - Boot smoke, driver-only role overlay (
roleSuffix→ driver-only appsettings, no admin block): assert the app boots andISecretResolveris present (no auth/DP registered). Expected: clean boot, resolver available.
Task 4 — G-4: switch config secrets to ${secret:} + seed + boot smoke + negative control
Classification: standard Estimated implement time: 5 min Parallelizable with: Task 5 (different files)
Switch OtOpcUa's Layer-A config-resident secrets to ${secret:} tokens, seeding each secret first (fail-closed — gotcha §8.1). Targets (design §4 / current-state):
| Config key | Owner (SectionName / const) | Seed name |
|---|---|---|
Security:Jwt:SigningKey |
JwtOptions Security/Jwt/JwtOptions.cs:5,9 (MinSigningKeyBytes 32) |
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 |
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_secretsCommentexample key, skipped per gotcha §8.2)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:
- Seed each secret in the store via the
secretCLI (or the/admin/secretsUI once Task 5 lands) — e.g.secret set otopcua/jwt/signing-key <value>against the sameSqlitePath+ KEK the Host uses. - Switch each config value to its
${secret:<name>}token. - Update the
ServerHistorianOptions.cs:40-45doc comment (do not change validation — it stays warn-only). dotnet build ZB.MOM.WW.OtOpcUa.slnx→ 0 warnings.- Boot smoke (positive): all five secrets seeded →
dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Hostboots clean;LdapOptionsValidator(Program.cs:102),ServerHistorianOptionsValidator(:127-128),OpcUaApplicationHostOptionsValidator(:275-276) all bind resolved plaintext. - Negative control (fail-closed): unseed one secret (e.g.
otopcua/jwt/signing-key) → boot must throwSecretNotFoundExceptionduring pre-host expansion, before any validator. Confirms design §8.1. Re-seed to restore. - Rollback note: reverting a token to the literal/env value still works (env override path intact) — keep this documented in the task's commit.
Task 5 — G-6: AddSecretsAuthorization() + mount /admin/secrets (both anchors)
Classification: standard Estimated implement time: 5 min Parallelizable with: Task 4 (different files)
Grant admins the secrets policies and mount the UI. OtOpcUa gates on role claims already (FleetAdmin → role Administrator at Security/ServiceCollectionExtensions.cs:160), and AddSecretsAuthorization() uses AdminRole = "Administrator" — so an existing Administrator satisfies secrets:manage/secrets:reveal with NO new role mapping (GroupToRole/DB grants already emit "Administrator"; no claim-type mismatch, unlike ScadaBridge).
The RCL assembly must be registered in BOTH places (design §3d / §6) or /admin/secrets 404s — SSR discovery via .AddAdditionalAssemblies AND interactive routing via the router's AdditionalAssemblies param.
Files:
src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:143-173— inside theAddAuthorization(...)block (or right afterAddOtOpcUaAuthatProgram.cs:312), addo.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 theMapRazorComponents<TApp>().AddInteractiveServerRenderMode()chain (:31-32)src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor:22— passAdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })"into<Routes/>(the param is already plumbed throughComponents/Routes.razor:8,37-38)- Do NOT touch the stale
Host/Routes.razor(plain RouteView) —App.razor's<Routes/>resolves to the AdminUI one
Steps:
- Add
AddSecretsAuthorization()in the AdminUI authorization block (viaConfigure<AuthorizationOptions>or directly in theAddAuthorizationcallback atServiceCollectionExtensions.cs:143-173). Confirm it composes additively with the existingFleetAdmin/ConfigEditor/etc. policies. - Add
.AddAdditionalAssemblies(...)atEndpointRouteBuilderExtensions.cs:31. - Add the
AdditionalAssembliesarg to<Routes/>atApp.razor:22. dotnet build ZB.MOM.WW.OtOpcUa.slnx→ 0 warnings.- 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). Usesuperpowers-extended-cc:executing-plans' browser step or the multi-role test user (multi-role/password).
Task 6 — G-5: File master-key posture for clustered nodes (the fork)
Classification: small/trivial (config + docs; the interim posture ships no new code) Estimated implement time: 4 min Parallelizable with: Task 4, Task 5
OtOpcUa is Akka-clustered (Program.cs:287-297, AddAkka("otopcua", …)) → every node needs the same KEK and the same store rows (design §5). The library today ships a SQLite-only store + NoOpSecretReplicator (no built-in shared-SQL store, no cross-node replication).
Interim posture (recommended, ships G-5 with no new library code):
MasterKey.Source = File,FilePath= a read-only mounted secret file identical on every node (notEnvironment, so all nodes read the same key material without per-node env drift).SqlitePath→ a single shared SQLite store on a shared/replicated volume (secrets are written rarely, human-driven, from one node's UI/CLI; reads dominate — acceptable for SQLite's single-writer limits).
Fork (design §5): the "right" long-term shape is a ConfigDb-backed ISecretStore mirroring OtOpcUa's existing AddDataProtection().PersistKeysToDbContext<OtOpcUaConfigDbContext>() pattern (ServiceCollectionExtensions.cs:73-75 — DP key ring is persisted to ConfigDb precisely to share key material across nodes). That requires building a custom ISecretStore, overlaps G-7, and is deferred there — NOT attempted in this cut. Flag the hand-off in the commit.
Files:
appsettings.json(or deployment/role-overlay appsettings) —Secrets.MasterKey.Source: "File",FilePath: "<shared-mount>/otopcua-master.key";Secrets.SqlitePath: "<shared-mount>/otopcua-secrets.db"- deployment runbook / docs — document the shared mount + KEK delivery (KEK never committed, gotcha §8.6); note the G-7 hand-off
Steps:
- Set
MasterKey.Source=File+ sharedFilePath+ sharedSqlitePathin the deployment appsettings (dev can stayEnvironmentfor single-node testing). - Add a doc note: interim File-KEK + shared-SQLite now; ConfigDb-backed
ISecretStoretracked under G-7. dotnet build ZB.MOM.WW.OtOpcUa.slnx→ 0 warnings.- Multi-node smoke (if a 2-node docker-dev rig is available): seed a secret from node A's UI, resolve it on node B → same value (proves shared store + shared KEK). Otherwise document as a live-gate for rollout.
Slice 1 gate: review, FF-merge feat/adopt-zb-secrets → master, push to Gitea. Then proceed to Slice 2.
Task 7 — G-2a: add secret: arm to GalaxySecretRef (Layer B)
Classification: high-risk (signature change on a static method consumed by the driver + AdminUI; touches the driver model) 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.
TDD. Write the fake-ISecretResolver unit test first.
Files:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs:43-88— addsecret:arm before the literal fall-through (~:78); change signature to acceptISecretResolver(and goasync Task<string>sinceGetAsyncis 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) — threadISecretResolver(ctor-injected) - call site 2: AdminUI
GalaxyDriverBrowser— threadISecretResolver(DI-injected) - new test:
src/Drivers/…Driver.Galaxy.Tests/GalaxySecretRefTests.cs(or existing test project)
Steps:
- Test first: add
GalaxySecretRefTests— a fakeISecretResolverreturning a known value forsecret:galaxy/inst1/apikey; assert (a)secret:ref resolves to the fake value, (b) an unknownsecret:ref fails closed (resolver returnsnull→ method throws/returns fail-closed, not silent literal fall-through), (c) existingenv:/file:/dev:/literal arms unchanged.dotnet test --filter "FullyQualifiedName~GalaxySecretRefTests"→ RED. - Add the
secret:arm:if (secretRef.StartsWith("secret:")) { var name = secretRef["secret:".Length..]; var val = await resolver.GetAsync(new SecretName(name), ct); return val ?? throw/fail-closed; }before the literal fall-through. - Change the signature to accept
ISecretResolver(+CancellationToken); make async as needed. - Update
GalaxyDriver+GalaxyDriverBrowsercall sites to inject + pass the resolver. dotnet test --filter "FullyQualifiedName~GalaxySecretRefTests"→ GREEN.dotnet build ZB.MOM.WW.OtOpcUa.slnx→ 0 warnings;dotnet test ZB.MOM.WW.OtOpcUa.slnx→ driver unit suite green.
Task 8 — G-2b: ISecretResolver hook in OpcUaClient factory AND probe (both call sites)
Classification: high-risk (driver-factory + actor model; TWO call sites must adopt or Test-Connect and runtime diverge) 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<OpcUaClientDriverOptions>(...)at:54(sharedJsonOptions:27-32). Runtime path (DriverHostActor.cs:1704passesspec.DriverConfig, re-sent onInitializeRequested:1756/ApplyDelta:1790— surprise b). - (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.
TDD. Fake-ISecretResolver unit test first.
Files:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverFactoryExtensions.cs:48-59(deserialize at:54) — after deserialize, resolvesecret:-prefixedPassword/UserCertificatePasswordvia injectedISecretResolversrc/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs:38(opts at:24) — same resolutionsrc/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:216—AddOtOpcUaDriverFactories()registration threadsISecretResolverinto the factoryOpcUaClientDriver.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 resolvesecret:refs
Steps:
- Test first: fake
ISecretResolver; assert factoryCreateInstancewithDriverConfigJSON wherePassword="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. - Add a shared helper (e.g.
ResolveSecretRefs(OpcUaClientDriverOptions, ISecretResolver, ct)) that resolvessecret:-prefixedPassword/UserCertificatePassword; call it from BOTHCreateInstance:54andProbe:38. - Thread
ISecretResolverinto the factory (viaAddOtOpcUaDriverFactories()atProgram.cs:216) and into the probe's DI. dotnet test --filter "FullyQualifiedName~OpcUaClientDriver"→ GREEN.dotnet build ZB.MOM.WW.OtOpcUa.slnx→ 0 warnings;dotnet test ZB.MOM.WW.OtOpcUa.slnx→ driver suite green.
Task 9 — G-2c: AdminUI driver-config editor round-trip preserves secret: refs (surprise e)
Classification: standard (verify/guard; risk is silent cleartext-resave) Estimated implement time: 5 min Parallelizable with: none (validates Tasks 7-8)
Driver secrets are stored cleartext in the ConfigDb today (GalaxySecretRef.cs:84-86 warns). The AdminUI driver-config editors emit/read that same DriverConfig JSON. The UI round-trip must PRESERVE the secret:/env:/file: refs — it must NOT resolve-then-resave cleartext (which would defeat the whole point and re-leak the secret into the DB). Verify the editor persists the raw ref string, and add a guard/test.
Files:
- AdminUI driver-config editor components (Galaxy + OpcUaClient driver config edit pages) + their save path — confirm they persist the raw
DriverConfigJSON (withsecret:refs intact), not a resolved-plaintext round-trip GalaxyDriverBrowser(Task 7 call site) — ensure browse/test resolves for the live call but does NOT write the resolved value back- test: editor save round-trip preserves a
secret:ref verbatim
Steps:
- Trace the AdminUI editor save path for Galaxy + OpcUaClient driver configs; confirm the persisted
DriverConfigJSON is the user-entered ref, unresolved. - If any path resolves-then-saves, fix it to persist the ref; resolution happens only at driver-instantiation/test time (Tasks 7-8).
- Add a round-trip test: save
{"Password":"secret:opcua/x/pw"}→ reload → assert the stored value is stillsecret:opcua/x/pw(not plaintext). dotnet test ZB.MOM.WW.OtOpcUa.slnx→ green.- Browser check: edit a driver config with a
secret:ref, save, reopen → ref shown, not plaintext; ConfigDb row holds the ref.
Task 10 — Verification (Slice 2 gate)
Classification: standard Estimated implement time: 5 min Parallelizable with: none (final gate)
Steps:
dotnet build ZB.MOM.WW.OtOpcUa.slnx→ 0 warnings (repo builds under warnings-as-errors discipline).dotnet test ZB.MOM.WW.OtOpcUa.slnx→ full suite green (driver unit tests incl. the newGalaxySecretRef+OpcUaClientsecret-resolution tests).- Boot smoke, admin+driver node:
/admin/secretsreachable, config secrets resolve, clean boot. - Boot smoke, driver-only node: resolves a
DriverConfigsecret:ref with no auth/DP/AdminUI present (proves surprise f / Task 3). - Live (VPN, wonder): configure a Galaxy driver instance with an
ApiKeySecretRef = "secret:galaxy/<inst>/apikey", seed the real gateway key, deploy → assert the driver authenticates real MxAccess traffic through the resolved key (mirrors HistorianGateway's live proof; requires the shared KEK present on the box). Also verify an OpcUaClient driver with asecret:Passwordconnects. - Review, FF-merge
feat/adopt-zb-secrets→master, push to Gitea.
Testing & rollout
- Offline (per design §10): fake-
ISecretResolverunit tests forGalaxySecretRef+ OpcUaClient factory/probe (assertsecret:resolves + unknown fails closed); 0-warning build with the three new package refs. - Boot smoke: one config value switched to
${secret:…}+ seeded → clean boot; negative control (unseed →SecretNotFoundExceptionat startup, fail-closed). - UI: browser-verify
/admin/secrets— unauth → login redirect; Administrator → metadata list + reveal shows plaintext + audit row carries NO plaintext. - Live (VPN): real Galaxy + OpcUaClient
secret:refs authenticate real traffic on wonder, with the shared KEK on the box. - Rollout: Slice 1 (Layer A + UI + master-key) merges first; Slice 2 (Layer B driver secrets) merges after review. Each slice: commit + review → FF-merge to
master→ push to Gitea. Seed all referenced secrets before switching any config value to a token (fail-closed staging, gotcha §8.1).
Deferred / out of scope (per components/secrets/GAPS.md)
- G-7 — clustered shared store / replicator. The interim posture (Task 6) is File-KEK + shared-SQLite on a shared volume. The integrated ConfigDb-backed
ISecretStore(mirroring OtOpcUa's DPPersistKeysToDbContextpattern) and the AkkaZB.MOM.WW.Secrets.Akkareplicator are G-7 — NOT built here. Task 6 flags the hand-off. - G-8 —
RewrapAllKEK rotation. Rewrapping all envelopes under a new master key is deferred to G-8. - Not touched (keep bespoke): the Data-Protection cookie/JWT key ring (do NOT reconfigure DP — gotcha §8.5), in-memory end-user OPC-UA passwords, and the file-system OPC UA PKI Directory stores.