Files
scadaproj/docs/plans/2026-07-16-secrets-adoption-scadabridge.md
Joseph Doherty 62a96e5f22 docs(secrets): reconcile 0.2.1 adoption - what was proven vs merely built
- 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.
2026-07-18 12:21:31 -04:00

32 KiB
Raw Permalink Blame History

ScadaBridge — Secrets Adoption (G-3, G-4, G-5, G-6) Implementation Plan

EXECUTED 2026-07-16 — merged to origin/main @ 128f1596, G-3 live-proven against the real production MxGateway gateway. This document is the historical record; the 0.1.2 pins are what was landed then, not what to use now.

Follow-on (not yet executed): bump to 0.2.0 and adopt clustered replication — 2026-07-18-secrets-0.2.0-upgrade-and-clustering.md, Tasks 4, 7, 8, 9. ScadaBridge's recommended topology is SQL-Server hub mode (ZB.MOM.WW.Secrets.Replicator.SqlServer, AddZbSecretsSqlServerReplication), not Akka. Reason: central and each site are separate Akka clusters with their own seed-nodes lists, so DistributedPubSub cannot cross the central↔site boundary — an Akka replicator here would converge each cluster internally and leave secret islands that never reconcile, while every node reports healthy. Hub mode gives sites local reads through a WAN outage, which is the premise of the hub-and-spoke architecture.

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. Shared context: docs/plans/2026-07-16-secrets-adoption-design.md — read it first; this plan references that design (library API §1, two layers §2, wiring template §3, G-4 §4, G-5 §5, G-6 §6 incl. the ScadaBridge claim-type caveat, G-3 §7, gotchas §8) rather than re-deriving it.

Goal: Adopt the already-built, already-published ZB.MOM.WW.Secrets library (0.1.2, Gitea feed, reference-consumer-proven in HistorianGateway) into ScadaBridge — retiring plaintext credentials at rest. Concretely: G-3 (retire the MxGateway per-endpoint ApiKey plaintext-in-ConfigDb via a runtime ISecretResolver secret: ref), G-4 (pre-host ${secret:} config-secret expansion — ScadaBridge is the heaviest config-secret surface in the family), G-5 (clustered master-key posture for the central pair + site nodes), G-6 (mount /admin/secrets in CentralUI, with a mandatory claim-type verification).

Architecture: No library construction — pure per-app wiring of an existing package following the HistorianGateway template (design §3). Two resolution layers (design §2): Layer A = pre-host ${secret:} config expansion (G-4, entirely config-resident); Layer B = runtime ISecretResolver.GetAsync for database-resident secrets (G-3, the MxGateway ApiKey inside a DataConnection JSON blob). ScadaBridge is Akka-clustered (central pair + N site nodes) → every node that resolves a ${secret:} needs the same KEK and the same store rows (design §5). Two ScadaBridge-specific hazards drive dedicated tasks: (1) docker per-node appsettings commit real plaintext dev secrets plus loose *_login.txt files at repo root; (2) a claim-type mismatch risk — the library's AddSecretsAuthorization() uses RequireRole(...) while ScadaBridge authz uses RequireClaim(JwtTokenService.RoleClaimType, …), so an Administrator may not satisfy secrets:manage/secrets:reveal without alignment.

Tech Stack: .NET 10, C#, ZB.MOM.WW.Secrets{,.Abstractions,.Ui} @ 0.1.2 from the Gitea NuGet feed (dohertj2-gitea), AES-256-GCM envelope crypto, SQLite store, ASP.NET Core authorization + Blazor Server RCL. ScadaBridge uses Central Package Management (Directory.Packages.props). Solution: ZB.MOM.WW.ScadaBridge.slnx.


Branch & sequencing

  • Branch feat/adopt-zb-secrets off origin/main in ~/Desktop/ScadaBridge (mirrors the auth/theme/audit adoption pattern across the family).
  • Commit per task with a clear message; request review at the slice boundaries below.
  • On completion: FF-merge to the repo default (main) and push to Gitea (origin).
  • Keep the branch buildable at every commit: never switch a config value to a ${secret:} token without seeding the secret in the same task (fail-closed at boot — design §8.1). The whole-key env override still works if a token is reverted (rollback path).

Slices

  • Slice 1 — config + UI + clustering (mostly standard/small): Tasks 18. Package plumbing (CPM + nuget mapping), the Layer-A pre-host ${secret:} expander (G-4 mechanism), runtime AddZbSecrets, the G-4 config-secret migration + committed-plaintext cleanup, the G-6 claim-type verification + /admin/secrets mount, and the G-5 clustered master-key posture. No product data-path changes. Review at end of slice.
  • Slice 2 — G-3 Layer-B MxGateway ApiKey (high-risk): Task 9. Injects ISecretResolver into the DCL MxGatewayDataConnection adapter and resolves a secret:-ref ApiKey at the point of use. Touches the runtime data path — TDD, separate review. Task 10 is final verification across both slices.

Task 1 — Package references + CPM versions + nuget.config mapping

Classification: small Estimated implement time: 4 min Parallelizable with: none (foundation — Tasks 29 depend on restore succeeding) Files:

  • nuget.config (repo root) — packageSourceMapping for dohertj2-gitea at :13-29 (patterns :18-27, no ZB.MOM.WW.* wildcard)
  • Directory.Packages.props (repo root) — existing ZB.MOM <PackageVersion> block at :75-88
  • src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj — add three <PackageReference> (no Version attr under CPM)

Steps:

  1. In nuget.config, under the <packageSource key="dohertj2-gitea"> mapping (after :27), add:
    <package pattern="ZB.MOM.WW.Secrets" />
    <package pattern="ZB.MOM.WW.Secrets.*" />
    
    (The mapping is explicit with no wildcard — restore fails without these two patterns.)
  2. In Directory.Packages.props, in the ZB.MOM block (:75-88), add three CPM version rows:
    <PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.1.2" />
    <PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.1.2" />
    <PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.1.2" />
    
  3. In ZB.MOM.WW.ScadaBridge.Host.csproj, add three version-less <PackageReference Include="ZB.MOM.WW.Secrets" />, …Secrets.Abstractions, …Secrets.Ui (the Host is where the pre-host expander, runtime registration, and CentralUI/App live). The .Ui package transitively pulls ZB.MOM.WW.Theme + ZB.MOM.WW.Auth.AspNetCore + ZB.MOM.WW.Audit — all already referenced, so no conflict.
  4. Restore: dotnet restore ZB.MOM.WW.ScadaBridge.slnx
    • Expected: restore succeeds, all three packages resolve at 0.1.2 from dohertj2-gitea. If restore 404s, re-check the two nuget mapping patterns (Task premise).
  5. Build: dotnet build ZB.MOM.WW.ScadaBridge.slnx
    • Expected: 0 warnings, 0 errors (nothing consumes the packages yet).

Task 2 — Layer-A pre-host ${secret:} expander (G-4 mechanism)

Classification: standard Estimated implement time: 5 min Parallelizable with: Task 6 (verification-only) after Task 1 Files:

  • src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:38-43 (ConfigurationBuilder…Build()var configuration), insert between :43 and :46 (StartupValidator.Validate(configuration))

Context (design §3, ScadaBridge variant): ScadaBridge assembles config in a bare ConfigurationBuilder before WebApplication.CreateBuilder exists (:38-43), producing an IConfigurationRoot local named configuration. The expander must run against that local via a throwaway ServiceCollection provider (bootstrap chicken-and-egg — surprise e: no host DI yet). It MUST run before :46 or StartupValidator/ConfigPreflight validates unexpanded ${…} tokens. Top-level statements at Program.cs are already async, so await is legal here.

Steps:

  1. Add usings at the top of Program.cs: ZB.MOM.WW.Secrets.Abstractions, ZB.MOM.WW.Secrets.Configuration, ZB.MOM.WW.Secrets.DependencyInjection, ZB.MOM.WW.Secrets.Sqlite.
  2. Immediately after :43 (the .Build() assigning configuration) and before :46, insert the throwaway-provider expander (design §3a):
    #pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons
    await using (var secretsProvider = new ServiceCollection()
        .AddZbSecrets(configuration, "Secrets")
        .BuildServiceProvider())
    #pragma warning restore ASP0000
    {
        await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default);
        var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
        await new SecretReferenceExpander(resolver)
            .ExpandConfigurationAsync(configuration, default);
    }
    
    Note: configuration is already an IConfigurationRoot (result of .Build()), so no cast is needed here (unlike the builder.Configuration case in the shared template). _-prefixed keys (e.g. _secrets, _nodeName) are auto-skipped by the expander (design §8.2).
  3. Add a "Secrets" block to src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json (shared across roles) matching the HistorianGateway shape (design §1):
    "Secrets": {
      "SqlitePath": "scadabridge-secrets.db",
      "MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
      "RunMigrationsOnStartup": true,
      "ResolveCacheTtl": "00:00:30"
    }
    
    (Task 8 revises MasterKey/SqlitePath for the clustered posture.)
  4. Build: dotnet build ZB.MOM.WW.ScadaBridge.slnx
    • Expected: 0 warnings. With no ${secret:…} tokens yet in config, the expander is a no-op pass; the store migrates and the app still boots on env-override values.

Task 3 — Runtime AddZbSecrets on the host container

Classification: small Estimated implement time: 3 min Parallelizable with: Task 4 Files:

  • src/ZB.MOM.WW.ScadaBridge.Host/Program.cs — on the real host builder.Services (the WebApplicationBuilder created after :46; the DI-registration region alongside AddSecurity/AddCentralUI, Program.cs:159-160)

Context (design §3b, §8.3): even though G-4 is Layer A, the runtime resolver is required for the UI (G-6) and the G-3 Layer-B hook (Task 9). AddZbSecrets also registers SecretsMigrationHostedService (keeps the store schema current) and lazily binds IAuditWriter — ScadaBridge already registers ZB.MOM.WW.Audit, so secret reveals/resolves audit automatically through the app's writer (design §8.4).

Steps:

  1. Locate the host builder and its service-registration block (near AddSecurity/AddCentralUI, Program.cs:159-160). Add:
    builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
    
  2. Build: dotnet build ZB.MOM.WW.ScadaBridge.slnx
    • Expected: 0 warnings. ISecretResolver is now injectable on the runtime container.

Task 4 — G-4: formalize ${SCADABRIDGE_*} placeholders → ${secret:} tokens

Classification: standard Estimated implement time: 5 min Parallelizable with: Task 3 Files:

  • src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json_secrets note :21; Database:ConfigurationDb :23; Security:Ldap:ServiceAccountPassword :33; Security:JwtSigningKey :35

Context (current-state §"Config expansion today"; design §4): the ${SCADABRIDGE_*} tokens at appsettings.Central.json:23,33,35 are non-functional literal placeholders today — real values arrive by whole-key env override (ScadaBridge__…). The whole-key-override convention is exactly what ${secret:} replaces. The five config-secret targets and their validators:

  • ScadaBridge:Database:ConfigurationDb — read Program.cs:221-222, validated StartupValidator.cs:60-62
  • ScadaBridge:Database:MachineDataDb — validated StartupValidator.cs:63-65
  • ScadaBridge:Security:Ldap:ServiceAccountPassword — bound via AddZbLdapAuth(builder.Configuration, LdapSectionPath) Program.cs:140-142 (LdapSectionPath="ScadaBridge:Security:Ldap", Security/ServiceCollectionExtensions.cs:24)
  • ScadaBridge:Security:JwtSigningKeySecurityOptions.cs:34, bound Program.cs:299
  • ScadaBridge:InboundApi:ApiKeyPepper (≥16 chars) — InboundAPI/InboundApiOptions.cs:36, validated StartupValidator.cs:89-91

Steps:

  1. In appsettings.Central.json, replace the whole-key ${SCADABRIDGE_*} tokens with ${secret:} refs (canonical sql/…, ldap/…, security/… naming), e.g.:
    "ConfigurationDb": "${secret:sql/scadabridge/configdb-connection}",
    "ServiceAccountPassword": "${secret:ldap/scadabridge/service-account-password}",
    "JwtSigningKey": "${secret:security/scadabridge/jwt-signing-key}"
    
    Also cover Database:MachineDataDb and InboundApi:ApiKeyPepper wherever they appear as literal secrets in appsettings.Central.json. Update the _secrets note (:21) to document the ${secret:} convention (this key is _-prefixed → skipped by the expander; safe for docs).
  2. Seed each referenced secret (same task — fail-closed, design §8.1). Use the secret CLI shipped with the package, e.g.:
    dotnet ZB.MOM.WW.Secrets.Cli.dll set sql/scadabridge/configdb-connection --value '<connstr>'
    
    (or seed via the /admin/secrets UI once Task 7 lands, then re-run boot). Ensure ZB_SECRETS_MASTER_KEY is exported for the seeding process so the same KEK encrypts the rows the app will read.
  3. Boot smoke: start the Host with SCADABRIDGE_CONFIG=Central and the secrets seeded; assert clean boot (/healthz 200) and that StartupValidator passes (values expanded before :46).
  4. Negative control (fail-closed): unseed/rename one secret → restart → assert SecretNotFoundException at startup before validation. Re-seed to restore. (Mirrors the HistorianGateway proof; design §10.)
  5. Build: dotnet build ZB.MOM.WW.ScadaBridge.slnxExpected: 0 warnings.

Task 5 — G-4 cleanup: remove committed dev plaintext + loose *_login.txt

Classification: small Estimated implement time: 4 min Parallelizable with: Task 4 (adjacent files; coordinate if the same appsettings is touched) Files:

  • docker/central-node-a/appsettings.Central.json — SQL passwords :21-22 (ScadaBridge_Dev1#), ServiceAccountPassword: "serviceaccount123" :32, JwtSigningKey :38
  • docker/central-node-b/appsettings.Central.json — same fields
  • ldap_login.txt, sql_login.txt, sqllogin.txt (repo root, all git-tracked)

Context (surprises a + b): the docker per-node files commit real plaintext dev secrets, and three loose credential files are tracked at the repo root. Both are unnecessary once ${secret:} + env-delivered KEK is the convention.

Steps:

  1. In docker/central-node-a/appsettings.Central.json and docker/central-node-b/appsettings.Central.json, replace the committed plaintext SQL passwords (:21-22), ServiceAccountPassword (:32, serviceaccount123), and JwtSigningKey (:38) with ${secret:…} refs matching Task 4's names — OR, if these are intentionally dev-only, delete them entirely and rely on the docker-compose env (ZB_SECRETS_MASTER_KEY + seeded dev store). Do not leave real plaintext committed.
  2. Delete the loose tracked credential files:
    git -C ~/Desktop/ScadaBridge rm ldap_login.txt sql_login.txt sqllogin.txt
    
  3. 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.
  4. Build: dotnet build ZB.MOM.WW.ScadaBridge.slnxExpected: 0 warnings (docker files aren't compiled; this just confirms nothing else broke).

Task 6 — G-6: claim-type VERIFICATION (RequireRole vs RequireClaim)

Classification: high-risk (authz — a mismatch silently denies admins) Estimated implement time: 5 min Parallelizable with: Task 2/3 (read-only inspection until remediation is needed) Files (inspection):

  • src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs:157-197 (AddScadaBridgeAuthorization — policies use policy.RequireClaim(JwtTokenService.RoleClaimType, …), :162 etc.)
  • src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs:45 (Administrator = "Administrator")
  • JwtTokenService (where RoleClaimType is defined) — locate via grep -rn "RoleClaimType" src/ZB.MOM.WW.ScadaBridge.Security
  • Library: SecretsAuthorization.AddSecretsAuthorization() (.Ui pkg) uses RequireRole(...) → reads ClaimsIdentity.RoleClaimType / ZbClaimTypes.Role

Context (design §6 claim-type caveat; surprise d): the library's secrets:manage/secrets:reveal policies use RequireRole(...), which reads the principal's ClaimsIdentity.RoleClaimType (the library expects ZbClaimTypes.Role). ScadaBridge's own policies use RequireClaim(JwtTokenService.RoleClaimType, …). If JwtTokenService.RoleClaimType is not the same claim type the framework's RequireRole reads, an Administrator will satisfy RequireAdmin but not the library's secrets policies → the /admin/secrets page 403s for admins even though everything else works.

Steps:

  1. Determine JwtTokenService.RoleClaimType's value and confirm whether the authenticated ClaimsPrincipal's identity is constructed with RoleClaimType set to that same value (i.e. does the JWT/cookie identity map role claims onto ClaimsIdentity.RoleClaimType, and is that ZbClaimTypes.Role / ClaimTypes.Role?). Inspect the identity-construction site (JWT validation TokenValidationParameters.RoleClaimType, and/or the cookie claims-principal factory).
  2. Decision (record the outcome in the task's commit message):
    • If they match (the role claim the library's RequireRole reads is the same type ScadaBridge issues) → no remediation; Task 7's AddSecretsAuthorization() is sufficient. Note the evidence.
    • If they do NOT match → remediate. Preferred, lowest-blast-radius option: register app-specific policies under the library's exact policy names secrets:manage and secrets:reveal using ScadaBridge's own primitive:
      options.AddPolicy("secrets:manage", p => p.RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator));
      options.AddPolicy("secrets:reveal", p => p.RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator));
      
      inside the existing AddAuthorization(options => …) at AuthorizationPolicies.cs:159-194. This shadows the library's RequireRole-based policies with matching names bound to ScadaBridge's claim type. Alternative: align the identity's RoleClaimType to ZbClaimTypes.Role at construction — but that is higher blast radius (touches every existing role check) and is not recommended for this task.
  3. Defer actually wiring the policy registration to Task 7 (this task decides which form Task 7 uses).

Task 7 — G-6: register secrets authorization + mount /admin/secrets

Classification: standard (authz already de-risked in Task 6) Estimated implement time: 5 min Parallelizable with: none (depends on Task 6 decision + Task 3) Files:

  • src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs:159-194 (inside the existing AddAuthorization(options => …))
  • src/ZB.MOM.WW.ScadaBridge.CentralUI/EndpointExtensions.cs:26-28 (MapRazorComponents<TApp>().AddInteractiveServerRenderMode().AddAdditionalAssemblies(typeof(MainLayout).Assembly))
  • src/ZB.MOM.WW.ScadaBridge.Host/Components/Routes.razor:2-3 (AdditionalAssemblies="new[] { typeof(…CentralUI.Components.Layout.MainLayout).Assembly }")

Context (design §3d, §6): the RCL page ZB.MOM.WW.Secrets.Ui.SecretsPage (@page "/admin/secrets", [Authorize(Policy = ManagePolicy)]) must have its assembly registered in both the endpoint mount and the router AdditionalAssemblies, or the route 404s. The page uses the default MainLayout supplied by the Router. AddScadaBridgeAuthorization is called from AddSecurity (Security/ServiceCollectionExtensions.cs:216, invoked Program.cs:159).

Steps:

  1. Register secrets policies inside the existing AddAuthorization(options => …) (AuthorizationPolicies.cs:159-194):
    • If Task 6 found a match: options.AddSecretsAuthorization(); (additive; requires using ZB.MOM.WW.Secrets.Ui; or the namespace of SecretsAuthorization).
    • If Task 6 found a mismatch: register the two app-specific RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator) policies under names secrets:manage/secrets:reveal (Task 6 step 2).
  2. In EndpointExtensions.cs:28, extend the mount:
    .AddAdditionalAssemblies(typeof(MainLayout).Assembly, typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly)
    
  3. In Routes.razor:3, add the assembly to AdditionalAssemblies:
    AdditionalAssemblies="new[] { typeof(ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout.MainLayout).Assembly, typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly }"
    
  4. Build: dotnet build ZB.MOM.WW.ScadaBridge.slnxExpected: 0 warnings.
  5. Browser verify (design §10): boot CentralUI; navigate /admin/secrets:
    • Unauthenticated → login redirect (policy enforced).
    • Administrator → metadata-only list; reveal shows plaintext; an audit row is written with no plaintext (query the app's audit store to confirm — proven pattern from HistorianGateway).
    • This is the real test of Task 6. If an Administrator gets 403, the claim-type remediation was needed/incorrect — return to Task 6.

Task 8 — G-5: clustered master-key posture (central pair + site nodes)

Classification: standard (config + ops posture; no new code) Estimated implement time: 5 min Parallelizable with: Task 4/5 Files:

  • src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json — the "Secrets" block added in Task 2
  • (reference only) src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs (BuildHocon :186/252, seeds :258, SBR :290, singletons :415-460+); central seeds e.g. docker/central-node-a/appsettings.Central.json:10-13

Context (design §5; surprise c): ScadaBridge is Akka-clustered — both central nodes of a pair resolve Layer-A ${secret:} config at boot, so each needs (1) the same KEK and (2) the same store rows. The library ships a SQLite-only store with a NoOpSecretReplicator — there is no built-in shared-SQL ISecretStore and no cross-node replication. True replication is the G-7 hand-off (out of scope here).

Steps:

  1. Set the interim clustered posture in the "Secrets" block (design §5 recommended interim):
    "Secrets": {
      "SqlitePath": "/shared/secrets/scadabridge-secrets.db",
      "MasterKey": { "Source": "File", "FilePath": "/shared/secrets/master.key" },
      "RunMigrationsOnStartup": true,
      "ResolveCacheTtl": "00:00:30"
    }
    
    • MasterKey.Source=File with a read-only mounted key file identical on every node (KEK never committed — design §8.6).
    • SqlitePath → a single shared/replicated volume both central nodes mount, so all nodes read identical rows. Secret writes are rare + human-driven (via one node's /admin/secrets / CLI); reads dominate.
  2. Document the fork explicitly in a comment beside the block / in the plan's rollout notes: SQLite over a network share has multi-writer locking limits (fine for read-mostly single-writer; not a substitute for real replication). The integrated ConfigDb-backed ISecretStore (mirroring the existing AddDataProtection().PersistKeysToDbContext<ScadaBridgeDbContext>() key-ring sharing at ConfigurationDatabase/ServiceCollectionExtensions.cs:80-81) is the long-term shape but requires new library code → deferred to G-7, not attempted here.
  3. Do NOT reconfigure Data Protection (design §8.5) — the existing DP key ring (no ProtectKeysWith, no SetApplicationName) is independent of Secrets envelope crypto; touching it risks invalidating cookies/hub tokens.
  4. Site nodes: site nodes do not resolve central config secrets, but if any ${secret:} token appears in appsettings.Site.json, the same File-KEK + shared-store rule applies. Confirm none is introduced without the mount.
  5. Build: dotnet build ZB.MOM.WW.ScadaBridge.slnxExpected: 0 warnings (config-only).

Task 9 — G-3: resolve the MxGateway ApiKey secret: ref at runtime (Layer B)

Classification: high-risk (touches the DCL connection/actor data path) Estimated implement time: 5 min Parallelizable with: none (Slice 2; after Slice 1 review) Files:

  • src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs:96 (cfg.ApiKey passed into new MxGatewayConnectionOptions(cfg.Endpoint, cfg.ApiKey, …)) — the resolution hook
  • src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/MxGatewayEndpointConfig.cs:14 (public string ApiKey { get; set; } = "";) — the field that will carry a secret:<name> ref
  • (context) src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/SiteConfiguration.cs:52-56 (column .HasMaxLength(4000), no encrypting converter → plaintext today); ManagementService/ConfigSecretScrubber.cs:16-19 (display/audit redaction only)

Context (design §7, recommended approach): the ApiKey is a field inside the DataConnection.PrimaryConfiguration/BackupConfiguration JSON blob, so the existing EncryptedStringConverter can't target it directly. Recommended: store a secret:<name> ref in the ApiKey field and resolve it via ISecretResolver.GetAsync at consumption (MxGatewayDataConnection.cs:96) — leaves the JSON blob human-readable, no schema change, and matches the family's Layer-B pattern (design §2). Alternative (note only, do not implement): encrypt the whole PrimaryConfiguration/BackupConfiguration column with EncryptedStringConverter — fewer refs but encrypts a mixed blob (scrubber + every display/edit path must decrypt first) and the column is read in several places → higher blast radius.

Steps (TDD):

  1. Test first. Add a unit test with a fake ISecretResolver (in the DCL test project). Assert:
    • a cfg.ApiKey == "secret:mxgateway/<conn>/api-key" → resolves to the fake's plaintext before ConnectAsync;
    • a literal cfg.ApiKey (no secret: prefix) → passes through unchanged (back-compat);
    • an unknown secret: ref (resolver returns null) → fails closed (throws, does not connect with an empty key). Run: dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~MxGateway"Expected: red.
  2. Thread ISecretResolver into MxGatewayDataConnection (constructor injection, or via its factory — check how the adapter is constructed by the DCL; the …DataConnectionLayer project may need to reference ZB.MOM.WW.Secrets.Abstractions). Register is already done (Task 3 on the host container).
  3. At :96, before building MxGatewayConnectionOptions, resolve the key:
    var apiKey = cfg.ApiKey;
    if (apiKey.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
    {
        var name = apiKey["secret:".Length..];
        apiKey = await _secretResolver.GetAsync(new SecretName(name), cancellationToken)
                 ?? throw new InvalidOperationException($"MxGateway ApiKey secret '{name}' not found");
    }
    
    then pass apiKey (not cfg.ApiKey) into MxGatewayConnectionOptions. Keep the literal path for back-compat (existing deployments store a literal key).
  4. Run tests: dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~MxGateway"Expected: green.
  5. Build: dotnet build ZB.MOM.WW.ScadaBridge.slnxExpected: 0 warnings.

Note: the four EncryptedStringConverter columns (ScadaBridgeDbContext.cs:333-347) and the redaction scrubber are unchanged by this task — they are a separate Data-Protection concern (design §8.5). This task only indirects the one plaintext ApiKey field.


Task 10 — Verification: build, suites, live connection

Classification: standard Estimated implement time: 5 min (+ live, VPN-gated) Parallelizable with: none (final gate) Files: n/a (verification)

Steps:

  1. 0-warning build: dotnet build ZB.MOM.WW.ScadaBridge.slnxExpected: 0 warnings, 0 errors (ScadaBridge builds under TreatWarningsAsErrors conventions).
  2. Offline suites green — the DCL / InboundAPI / SiteRuntime suites (per prior deploy notes these are the load-bearing suites):
    dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~DataConnectionLayer"
    dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~InboundAPI"
    dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~SiteRuntime"
    
    Then full: dotnet test ZB.MOM.WW.ScadaBridge.slnxExpected: all green.
  3. Boot smoke + fail-closed re-confirmed (Task 4 steps 34) on the assembled branch.
  4. UI (Task 7 step 5) re-confirmed: /admin/secrets policy-gated + reveal + no-plaintext audit row.
  5. Live (VPN-gated): with ZB_SECRETS_MASTER_KEY present on the box + a real MxGateway endpoint's ApiKey switched to a seeded secret:mxgateway/<conn>/api-key ref, deploy the DataConnection and confirm the MxGateway adapter authenticates real traffic (connection reaches Connected, tags read Good) — mirroring the HistorianGateway live proof (design §10). If VPN/box is unavailable, document as deferred-live and rely on the offline resolver unit test + boot smoke.
  6. On green: FF-merge feat/adopt-zb-secretsmain, push to Gitea.

Testing & rollout

  • Offline gate (blocking): 0-warning ZB.MOM.WW.ScadaBridge.slnx build; DCL/InboundAPI/SiteRuntime + full test suite green; the G-3 resolver unit test (fake ISecretResolver: secret: ref resolves, literal passes through, unknown fails closed).
  • Boot smoke (blocking): Host boots SCADABRIDGE_CONFIG=Central with the five G-4 config secrets seeded; negative control proves SecretNotFoundException fail-closed before StartupValidator.
  • UI (blocking): /admin/secrets — login redirect when unauthenticated, Administrator sees metadata + reveal, audit row carries no plaintext. This validates the Task 6 claim-type decision end-to-end.
  • Live (VPN-gated, best-effort): one real MxGateway DataConnection ApiKey as a secret: ref authenticates real traffic on the box, with the File-KEK shared across the central pair.
  • Rollout: Slice 1 (Tasks 18) reviewed and merged first (config/UI/clustering, no data-path change); Slice 2 (Task 9, G-3) as a separate reviewable slice. Per-branch mirroring auth/theme/audit: commit + review, FF-merge to main, push to Gitea. KEK delivered out-of-band to every node (never committed); env-override remains the rollback path for any token.

Deferred / out of scope

  • G-7 — shared store + Akka replicator: a ConfigDb-backed ISecretStore (SQL-Server, mirroring the DP key-ring sharing) or the ZB.MOM.WW.Secrets.Akka cross-node replicator. The library today is SQLite-only with NoOpSecretReplicator; Task 8 ships the interim File-KEK + shared-SQLite posture and flags this hand-off. Tracked in components/secrets/GAPS.md.
  • G-8 — RewrapAll KEK rotation. Deferred per components/secrets/GAPS.md.
  • Akka remoting auth/TLS / shared secure-cookie (plain TCP today, current-state §row Akka remoting) — a separate hardening concern, not this component.
  • Migrating the four existing EncryptedStringConverter columns to Secrets — they already have at-rest Data-Protection encryption; coexistence is fine (design §8.5), migration is not in scope.
  • mxaccessgw / OtOpcUa secrets adoption — their own plans (design §5/§9).