Files
scadaproj/components/secrets/GAPS.md
T
Joseph Doherty 1d05ecdf1e docs(secrets): OtOpcUa adoption executed + merged (lmxopcua @872cf7e3) — all 4 apps done
Record the OtOpcUa secrets adoption (G-2/G-4/G-5/G-6, the last app): FF-merged +
pushed to lmxopcua origin/master @872cf7e3. Update the components/secrets/GAPS.md
OtOpcUa bullet (execution record + the three verified plan deviations + the regression
the gate caught) and the CLAUDE.md secrets-component-table row. All four apps
(HistorianGateway, mxaccessgw, ScadaBridge, OtOpcUa) now adopt ZB.MOM.WW.Secrets.
2026-07-16 22:52:21 -04:00

12 KiB

Secrets — GAPS (adoption backlog)

The delta between each project's current-state and the SPEC, as prioritized work. The library itself is built + published + reference-consumer-proven; everything here is per-app adoption of an existing lib (not lib construction).

Execution status (2026-07-16)

  • mxaccessgw — G-4 + G-5 + G-6 DONE. Executed via the plan below (8 tasks, subagent-driven, classification-driven reviews), merged + pushed to origin/main (mxaccessgw @ e088dfa). Offline: 0-warning build, full suite 781 pass / 44 pre-existing worker-COM (0 non-worker), behavioral claim-type authz test, no Data-Protection disturbance. Box-verified on windev (real .NET 10.0.8): fail-closed on unseeded ${secret:} (SecretNotFoundException, refuses to start) + CLI seed/get decrypt round-trip + zero plaintext in the SQLite store at rest. (/admin/secrets interactive reveal not re-proven on this box — same shared .Ui component already live-proven via HistorianGateway; box gateway runs LDAP-disabled/anonymous so a real LDAP-via-secret login wasn't exercised there.) Two review-caught fixes folded in: the LDAP password change's test/doc blast radius (validator-helper + host-test-bootstrap + doc truth), and the live-LDAP integration suite now honors the MxGateway__Ldap__ServiceAccountPassword env override. G-4/G-5/G-6 below are the remaining OtOpcUa + ScadaBridge scope.
  • ScadaBridge — G-3 + G-4 + G-5 + G-6 DONE. Executed via the plan below (10 tasks, subagent-driven, classification-driven reviews), FF-merged + pushed to origin/main (ScadaBridge @ 128f1596). Offline: 0-warning build, all suites green (Host 279, DCL 235 incl. 5 G-3 resolver tests, Security 181, CentralUI 925, InboundAPI 269, SiteRuntime 522/523 [1 pre-existing ScriptActor timing-flake, passes isolated + branch touched no SiteRuntime src], Commons/ConfigDb/ManagementService/Communication/ClusterInfra all green). G-3 LIVE-PROVEN vs the REAL production MxGateway gateway (wonder-app-vd03.zmr.zimmer.com:5120): a throwaway harness drove the real MxGatewayDataConnection through the DI factory + AddZbSecrets with a secret:-ref ApiKeydummy key Unauthenticated (proves the resolved value is transmitted, not fail-closed first) → real read-scoped key → Connected + browsed the real Galaxy root (10 nodes). Temp key created via the gateway dashboard, then revoked + deleted + functionally confirmed dead. Notable deltas from the plan: T3 registers AddZbSecrets on both the Central branch (UI) and SiteServiceRegistration (DCL adapter) — the plan assumed one container; T6 claim-type resolved MATCH (ZbClaimTypes.Role == ClaimTypes.Role, adversarially verified) → AddSecretsAuthorization() used directly; T8 (G-5) shipped as a docs-only production-posture runbook (docs/operations/2026-07-16-secrets-clustered-master-key.md) because hardcoding Source=File + /shared paths would break the Central-boot Host.Tests + local dev (the pre-host expander migrates the store unconditionally every Central boot). One review-flagged non-blocking follow-up: a pre-existing xUnit env-mutation CI-flake that T4 amplifies (single [Collection] / disable parallelization for Host.Tests).
  • OtOpcUa — G-2 + G-4 + G-5 + G-6 DONE (the LAST app — all three now adopted). Executed 2026-07-16 via the plan below (10 tasks, 2 slices, subagent-driven, classification-driven reviews), FF-merged + pushed to origin/master (lmxopcua @ 872cf7e3, ec6598ce..872cf7e3). Clean build (0 errors). The full offline suite caught one real regressionAddOtOpcUaDriverFactories' registration now resolves ISecretResolver (Galaxy/OpcUaClient secret: refs), so two ResilienceInvokerFactoryRegistrationTests that built a minimal container threw No service for ISecretResolver; fixed test-only by registering a stub resolver (production always has it via the unconditional AddZbSecrets, and GetRequiredService correctly fails-fast for a genuinely misconfigured host). Touched-area suites green (Galaxy 338, OpcUaClient 142, AdminUI 662); remaining non-passes are unrelated (flaky Akka/Roslyn timing tests pass in isolation; the environmental AbCip_Green_AgainstSim sim-fixture test — zero AbCip code touched). Three deliberate deviations from the plan's literal text (verified corrections, same class as the CPM miss the plan itself caught): (1) G-4 does NOT commit a ${secret:} token into the default appsettings — the expander is fail-closed AND section-agnostic and the only committed secret-shaped value is ServerHistorian:ApiKey="" (empty, disabled-by-default), so a committed token would break every dev/CI/TwoNodeClusterHarness boot for zero at-rest benefit; instead documented the token-delivery convention (docs/security.md) + updated the ServerHistorian comments, and proved fail-closed live (unseeded → SecretNotFoundException at pre-host expansion; seeded → resolves). (2) G-5 shipped docs-only (docs/operations/2026-07-16-secrets-clustered-master-key.md) — hardcoding Source=File+shared paths into the committed role-overlay appsettings (also consumed by dev + integration) would break those boots; base stays Source=Environment. Mirrors the ScadaBridge G-5 resolution. (3) Task 8 resolves in the factory/driver only, NOT the probe — the v3 OpcUaClientDriverProbe is unauthenticated GetEndpoints-only and never reads Password/UserCertificatePassword, so probe resolution would be dead code. Layer-B resolution mirrors Galaxy: the sync driver-registry factory means resolution happens lazily at the driver's async session-open (GalaxyDriver.BuildClientOptionsAsync / OpcUaClientDriver.InitializeAsync), threaded via ctor + DriverFactoryBootstrap (real resolver from DI; NullSecretResolver null-object backs test/parse paths only, fail-closed on secret:). OpcUaClientDriverOptions was converted sealed classsealed record for the with-expression (no positional params → identical JSON; verified no reference-equality/dict-key/ToString-log usages → no leak/behavior change). AdminUI G-2c is structurally leak-proof: DriverConfig persists as an opaque JSON string via RawTreeService, which has NO resolver dependency. Live wonder gate deferred to rollout (needs the shared KEK + a seeded real gateway key on the box — same as HistorianGateway/ScadaBridge were proven). All of G-2/G-4/G-5/G-6 now DONE across all three apps + mxaccessgw. 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 held; 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)

G-2 … G-6 are now planned. Shared design + three per-repo executable plans (task-metadata'd, code-verified anchors, co-located .tasks.json):

G-7/G-8 remain design-only/deferred (below).

Cross-cutting observations

  • No app has ${secret:} resolution today. All three assemble config with the stock AddJsonFile+AddEnvironmentVariables chain and read secrets verbatim. Two already lean on the pattern the expander formalizes: ScadaBridge's non-functional ${SCADABRIDGE_*} placeholders (whole-key env override) and OtOpcUa's driver-level GalaxySecretRef (env:/file:/dev:/literal). These are the natural first swaps.
  • The common high-value gap is plaintext credentials in appsettings/env: LDAP service-account passwords (all three), SQL connection-string passwords (OtOpcUa ConfigDb, ScadaBridge ConfigDb + MachineDataDb), JWT/HS256 signing keys (OtOpcUa, ScadaBridge), and API-key peppers (all three, runtime-supplied but plaintext).
  • Secrets already stored in a DB in plaintext are the sharpest edge: OtOpcUa's OpcUaClient Password/UserCertificatePassword and the dev:/literal Galaxy API key live cleartext in the central config DB; ScadaBridge's MxGateway per-endpoint ApiKey lives plaintext in the ConfigDb DataConnections JSON. These leak at rest to anyone with DB read.
  • Existing at-rest crypto is Data Protection, and only ScadaBridge applies it to secrets (4 encrypted ConfigDb columns via EncryptedStringConverter). OtOpcUa/mxaccessgw use Data Protection only for cookies/tokens. None use DPAPI or ProtectKeysWith*; key rings are unencrypted-at-rest. ZB.MOM.WW.Secrets adds envelope encryption with a KEK held outside the store — a stronger boundary than "the DB is the only protection."
  • Peppered-HMAC API-key stores stay bespoke (mxaccessgw + ScadaBridge): they are hashed, not reversibly stored — already correct. Secrets only takes over the pepper value supply.

Backlog (prioritized)

G-1 — HistorianGateway reference consumer DONE

Adopted + live-proven 2026-07-16 (historian password via ${secret:}, authenticated read against the real wonder historian). This is the wiring template for the three apps.

G-2 — OtOpcUa: cleartext-in-DB driver secrets (highest value)

Add a secret: arm to GalaxySecretRef (its own comment anticipates this) and to the OpcUaClient driver options, resolving through ISecretResolver — retire the dev:/literal and plaintext Password/UserCertificatePassword DB paths.

G-3 — ScadaBridge: MxGateway ApiKey plaintext-in-ConfigDb

Resolve via ISecretResolver or extend the existing EncryptedStringConverter to that JSON column.

G-4 — Pre-host ${secret:} for plaintext config secrets (all three)

LDAP passwords, SQL connstr passwords, JWT signing keys, deploy API key (OtOpcUa), peppers — swap to ${secret:…} tokens, expander running before each app's existing validator (ConfigPreflight / Ldap+OpcUa validators / GatewayOptionsValidator). Delete committed dev plaintext and the loose *_login.txt files (ScadaBridge).

G-5 — Master-key provider per deployment

Env (ZB_SECRETS_MASTER_KEY) for containers; DPAPI for the Windows boxes; File (shared mounted key) for clustered pairs (hard requirement — same KEK on every node).

G-6 — Mount /admin/secrets UI (all three)

Add the RCL page to each dashboard (OtOpcUa AdminUI, MxGateway Server, ScadaBridge CentralUI) and map each app's admin role onto secrets:manage / secrets:reveal.

G-7 — Clustered replication (ScadaBridge, OtOpcUa) — build ZB.MOM.WW.Secrets.Akka

Deferred until G-2…G-6 land for a clustered app. Options per SPEC: shared SQL-Server ISecretStore (simplest — mirrors the shared Data-Protection key ring) or the Akka replicator (LWW, anti-entropy resync, tombstones). Requires a shared KEK.

G-8 — KEK-rotation runbook + RewrapAll

The RewrapAll(oldKek, newKek) admin primitive + an operator runbook. Deferred (design-only in this cut).

Out of scope (this component)

  • Akka remoting authentication/TLS (ScadaBridge plain TCP remoting) — a separate hardening concern, not secret storage.
  • SQL-Server ISecretStore provider construction (seam supports it; build with G-7).