- 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.
32 KiB
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; the0.1.2pins are what was landed then, not what to use now.Follow-on (not yet executed): bump to
0.2.0and adopt clustered replication —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 ownseed-nodeslists, soDistributedPubSubcannot 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— 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-secretsofforigin/mainin~/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), runtimeAddZbSecrets, the G-4 config-secret migration + committed-plaintext cleanup, the G-6 claim-type verification +/admin/secretsmount, 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. InjectsISecretResolverinto the DCLMxGatewayDataConnectionadapter and resolves asecret:-refApiKeyat 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) —packageSourceMappingfordohertj2-giteaat:13-29(patterns:18-27, noZB.MOM.WW.*wildcard)Directory.Packages.props(repo root) — existing ZB.MOM<PackageVersion>block at:75-88src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj— add three<PackageReference>(noVersionattr under CPM)
Steps:
- In
nuget.config, under the<packageSource key="dohertj2-gitea">mapping (after:27), add:(The mapping is explicit with no wildcard — restore fails without these two patterns.)<package pattern="ZB.MOM.WW.Secrets" /> <package pattern="ZB.MOM.WW.Secrets.*" /> - In
Directory.Packages.props, in the ZB.MOM block (:75-88), add three CPM version rows:<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" /> - 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.Uipackage transitively pullsZB.MOM.WW.Theme+ZB.MOM.WW.Auth.AspNetCore+ZB.MOM.WW.Audit— all already referenced, so no conflict. - Restore:
dotnet restore ZB.MOM.WW.ScadaBridge.slnx- Expected: restore succeeds, all three packages resolve at
0.1.2fromdohertj2-gitea. If restore 404s, re-check the two nuget mapping patterns (Task premise).
- Expected: restore succeeds, all three packages resolve at
- 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:43and: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:
- 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. - Immediately after
:43(the.Build()assigningconfiguration) and before:46, insert the throwaway-provider expander (design §3a):Note:#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); }configurationis already anIConfigurationRoot(result of.Build()), so no cast is needed here (unlike thebuilder.Configurationcase in the shared template)._-prefixed keys (e.g._secrets,_nodeName) are auto-skipped by the expander (design §8.2). - Add a
"Secrets"block tosrc/ZB.MOM.WW.ScadaBridge.Host/appsettings.json(shared across roles) matching the HistorianGateway shape (design §1):(Task 8 revises"Secrets": { "SqlitePath": "scadabridge-secrets.db", "MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" }, "RunMigrationsOnStartup": true, "ResolveCacheTtl": "00:00:30" }MasterKey/SqlitePathfor the clustered posture.) - 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.
- Expected: 0 warnings. With no
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 hostbuilder.Services(theWebApplicationBuildercreated after:46; the DI-registration region alongsideAddSecurity/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:
- Locate the host
builderand its service-registration block (nearAddSecurity/AddCentralUI, Program.cs:159-160). Add:builder.Services.AddZbSecrets(builder.Configuration, "Secrets"); - Build:
dotnet build ZB.MOM.WW.ScadaBridge.slnx- Expected: 0 warnings.
ISecretResolveris now injectable on the runtime container.
- Expected: 0 warnings.
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—_secretsnote: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— readProgram.cs:221-222, validatedStartupValidator.cs:60-62ScadaBridge:Database:MachineDataDb— validatedStartupValidator.cs:63-65ScadaBridge:Security:Ldap:ServiceAccountPassword— bound viaAddZbLdapAuth(builder.Configuration, LdapSectionPath)Program.cs:140-142(LdapSectionPath="ScadaBridge:Security:Ldap",Security/ServiceCollectionExtensions.cs:24)ScadaBridge:Security:JwtSigningKey—SecurityOptions.cs:34, boundProgram.cs:299ScadaBridge:InboundApi:ApiKeyPepper(≥16 chars) —InboundAPI/InboundApiOptions.cs:36, validatedStartupValidator.cs:89-91
Steps:
- In
appsettings.Central.json, replace the whole-key${SCADABRIDGE_*}tokens with${secret:}refs (canonicalsql/…,ldap/…,security/…naming), e.g.:Also cover"ConfigurationDb": "${secret:sql/scadabridge/configdb-connection}", "ServiceAccountPassword": "${secret:ldap/scadabridge/service-account-password}", "JwtSigningKey": "${secret:security/scadabridge/jwt-signing-key}"Database:MachineDataDbandInboundApi:ApiKeyPepperwherever they appear as literal secrets inappsettings.Central.json. Update the_secretsnote (:21) to document the${secret:}convention (this key is_-prefixed → skipped by the expander; safe for docs). - Seed each referenced secret (same task — fail-closed, design §8.1). Use the
secretCLI shipped with the package, e.g.:(or seed via thedotnet ZB.MOM.WW.Secrets.Cli.dll set sql/scadabridge/configdb-connection --value '<connstr>'/admin/secretsUI once Task 7 lands, then re-run boot). EnsureZB_SECRETS_MASTER_KEYis exported for the seeding process so the same KEK encrypts the rows the app will read. - Boot smoke: start the Host with
SCADABRIDGE_CONFIG=Centraland the secrets seeded; assert clean boot (/healthz200) and thatStartupValidatorpasses (values expanded before:46). - Negative control (fail-closed): unseed/rename one secret → restart → assert
SecretNotFoundExceptionat startup before validation. Re-seed to restore. (Mirrors the HistorianGateway proof; design §10.) - 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:38docker/central-node-b/appsettings.Central.json— same fieldsldap_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:
- In
docker/central-node-a/appsettings.Central.jsonanddocker/central-node-b/appsettings.Central.json, replace the committed plaintext SQL passwords (:21-22),ServiceAccountPassword(:32,serviceaccount123), andJwtSigningKey(: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. - Delete the loose tracked credential files:
git -C ~/Desktop/ScadaBridge rm ldap_login.txt sql_login.txt sqllogin.txt - Grep-sweep for any remaining committed plaintext:
git -C ~/Desktop/ScadaBridge grep -nE 'ScadaBridge_Dev1#|serviceaccount123|scadabridge-dev-jwt-signing-key' -- '*.json' '*.txt'- Expected: no hits after the edits.
- 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 usepolicy.RequireClaim(JwtTokenService.RoleClaimType, …),:162etc.)src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs:45(Administrator = "Administrator")JwtTokenService(whereRoleClaimTypeis defined) — locate viagrep -rn "RoleClaimType" src/ZB.MOM.WW.ScadaBridge.Security- Library:
SecretsAuthorization.AddSecretsAuthorization()(.Uipkg) usesRequireRole(...)→ readsClaimsIdentity.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:
- Determine
JwtTokenService.RoleClaimType's value and confirm whether the authenticatedClaimsPrincipal's identity is constructed withRoleClaimTypeset to that same value (i.e. does the JWT/cookie identity map role claims ontoClaimsIdentity.RoleClaimType, and is thatZbClaimTypes.Role/ClaimTypes.Role?). Inspect the identity-construction site (JWT validationTokenValidationParameters.RoleClaimType, and/or the cookie claims-principal factory). - Decision (record the outcome in the task's commit message):
- If they match (the role claim the library's
RequireRolereads is the same type ScadaBridge issues) → no remediation; Task 7'sAddSecretsAuthorization()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:manageandsecrets:revealusing ScadaBridge's own primitive:inside the existingoptions.AddPolicy("secrets:manage", p => p.RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator)); options.AddPolicy("secrets:reveal", p => p.RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator));AddAuthorization(options => …)atAuthorizationPolicies.cs:159-194. This shadows the library'sRequireRole-based policies with matching names bound to ScadaBridge's claim type. Alternative: align the identity'sRoleClaimTypetoZbClaimTypes.Roleat construction — but that is higher blast radius (touches every existing role check) and is not recommended for this task.
- If they match (the role claim the library's
- 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 existingAddAuthorization(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:
- Register secrets policies inside the existing
AddAuthorization(options => …)(AuthorizationPolicies.cs:159-194):- If Task 6 found a match:
options.AddSecretsAuthorization();(additive; requiresusing ZB.MOM.WW.Secrets.Ui;or the namespace ofSecretsAuthorization). - If Task 6 found a mismatch: register the two app-specific
RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator)policies under namessecrets:manage/secrets:reveal(Task 6 step 2).
- If Task 6 found a match:
- In
EndpointExtensions.cs:28, extend the mount:.AddAdditionalAssemblies(typeof(MainLayout).Assembly, typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly) - In
Routes.razor:3, add the assembly toAdditionalAssemblies:AdditionalAssemblies="new[] { typeof(ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout.MainLayout).Assembly, typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly }" - Build:
dotnet build ZB.MOM.WW.ScadaBridge.slnx— Expected: 0 warnings. - 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:
- Set the interim clustered posture in the
"Secrets"block (design §5 recommended interim):"Secrets": { "SqlitePath": "/shared/secrets/scadabridge-secrets.db", "MasterKey": { "Source": "File", "FilePath": "/shared/secrets/master.key" }, "RunMigrationsOnStartup": true, "ResolveCacheTtl": "00:00:30" }MasterKey.Source=Filewith 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.
- 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 existingAddDataProtection().PersistKeysToDbContext<ScadaBridgeDbContext>()key-ring sharing atConfigurationDatabase/ServiceCollectionExtensions.cs:80-81) is the long-term shape but requires new library code → deferred to G-7, not attempted here. - Do NOT reconfigure Data Protection (design §8.5) — the existing DP key ring (no
ProtectKeysWith, noSetApplicationName) is independent of Secrets envelope crypto; touching it risks invalidating cookies/hub tokens. - Site nodes: site nodes do not resolve central config secrets, but if any
${secret:}token appears inappsettings.Site.json, the same File-KEK + shared-store rule applies. Confirm none is introduced without the mount. - 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.ApiKeypassed intonew MxGatewayConnectionOptions(cfg.Endpoint, cfg.ApiKey, …)) — the resolution hooksrc/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/MxGatewayEndpointConfig.cs:14(public string ApiKey { get; set; } = "";) — the field that will carry asecret:<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):
- 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 beforeConnectAsync; - a literal
cfg.ApiKey(nosecret:prefix) → passes through unchanged (back-compat); - an unknown
secret:ref (resolver returnsnull) → fails closed (throws, does not connect with an empty key). Run:dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~MxGateway"— Expected: red.
- a
- Thread
ISecretResolverintoMxGatewayDataConnection(constructor injection, or via its factory — check how the adapter is constructed by the DCL; the…DataConnectionLayerproject may need to referenceZB.MOM.WW.Secrets.Abstractions). Register is already done (Task 3 on the host container). - At
:96, before buildingMxGatewayConnectionOptions, resolve the key:then passvar 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"); }apiKey(notcfg.ApiKey) intoMxGatewayConnectionOptions. Keep the literal path for back-compat (existing deployments store a literal key). - Run tests:
dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~MxGateway"— Expected: green. - Build:
dotnet build ZB.MOM.WW.ScadaBridge.slnx— Expected: 0 warnings.
Note: the four
EncryptedStringConvertercolumns (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 plaintextApiKeyfield.
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:
- 0-warning build:
dotnet build ZB.MOM.WW.ScadaBridge.slnx— Expected: 0 warnings, 0 errors (ScadaBridge builds underTreatWarningsAsErrorsconventions). - Offline suites green — the DCL / InboundAPI / SiteRuntime suites (per prior deploy notes these are the load-bearing suites):
Then full:
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"dotnet test ZB.MOM.WW.ScadaBridge.slnx— Expected: all green. - Boot smoke + fail-closed re-confirmed (Task 4 steps 3–4) on the assembled branch.
- UI (Task 7 step 5) re-confirmed:
/admin/secretspolicy-gated + reveal + no-plaintext audit row. - Live (VPN-gated): with
ZB_SECRETS_MASTER_KEYpresent on the box + a real MxGateway endpoint'sApiKeyswitched to a seededsecret:mxgateway/<conn>/api-keyref, 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. - On green: FF-merge
feat/adopt-zb-secrets→main, push to Gitea.
Testing & rollout
- Offline gate (blocking): 0-warning
ZB.MOM.WW.ScadaBridge.slnxbuild; DCL/InboundAPI/SiteRuntime + full test suite green; the G-3 resolver unit test (fakeISecretResolver:secret:ref resolves, literal passes through, unknown fails closed). - Boot smoke (blocking): Host boots
SCADABRIDGE_CONFIG=Centralwith the five G-4 config secrets seeded; negative control provesSecretNotFoundExceptionfail-closed beforeStartupValidator. - 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
ApiKeyas asecret: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 theZB.MOM.WW.Secrets.Akkacross-node replicator. The library today is SQLite-only withNoOpSecretReplicator; Task 8 ships the interim File-KEK + shared-SQLite posture and flags this hand-off. Tracked incomponents/secrets/GAPS.md. - G-8 —
RewrapAllKEK rotation. Deferred percomponents/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
EncryptedStringConvertercolumns 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).