Design doc + runbook Production-enablement section + GAPS/index updates. The previously-untracked production-KEK decision is now recorded: one fleet-wide KEK via appsettings/env (never committed); ScadaBridge rejects the SqlServer hub on topology grounds (sites talk to central, never the DB) in favor of a new ZB.MOM.WW.Secrets.Replicator.Grpc package — pull-only, updates originate at central only, by design. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
7.6 KiB
Secrets: production replication topologies + the central gRPC hub replicator
Status: Design — decisions owner-approved 2026-08-07; implementation NOT started.
Tracking: scadaproj#3
Supersedes nothing — this closes the previously-untracked production-enablement gap recorded in
components/secrets/GAPS.md and the CLAUDE.md Secrets row ("production enablement would need real
KEK distribution — own decision/issue"). This is that decision and that issue.
1. What this decides
ZB.MOM.WW.Secrets clustered replication (lib 0.2.0+) is built, published, wired into the apps
behind Secrets:Replication:Enabled (default false), and live-validated only on dev rigs with
the committed dev KEK. Nothing decided how a production deployment would enable it. Four decisions,
all owner-approved 2026-08-07:
D1 — OtOpcUa production topology: Akka peer-to-peer, pair-local
Since mesh Phase 6, each OtOpcUa application cluster is its own 2-node Akka mesh and sites have no SQL — so the Akka replicator is the only topology sites can run, and its scope is pair-local by construction: a secret seeded on one node of a pair converges to its partner and nowhere else. That is accepted, not worked around. Each pair is seeded independently:
- central pair — via the
/admin/secretspage (AdminUI runs only on central); - site pairs — via the
secretinteractive CLI on-box, or the 0.3.0 console's ciphertext-only bundle export/import (which requires D2's shared KEK).
D2 — One fleet-wide production KEK, delivered per node via appsettings/env, never committed
Per-pair KEKs would work (secrets never cross pairs) but a single fleet KEK keeps bundle-based seeding possible between pairs and makes rotation one procedure instead of N. Delivery is the node's appsettings/environment override — the same never-committed channel the rigs use for their observability overrides. Verification is the runbook §0 digest compare, per pair. The committed dev KEK remains dev-only.
D3 — ScadaBridge does NOT use the SqlServer hub replicator
The shipped hub-replication mode has each site node open a connection string to central SQL and sweep against it directly. That violates ScadaBridge's topology rule: site servers never talk to the DB — for templates, config, and deploys they talk to central, and central talks to its DB. Secrets must follow the same path. (The same rule is why OtOpcUa's mesh Phase 6 dropped SQL-hub for its sites.) The SqlServer package stays on the shelf — it remains correct for a topology whose nodes legitimately share a database — but ScadaBridge will not enable it.
D4 — Build ZB.MOM.WW.Secrets.Replicator.Grpc: a central-hosted secrets hub, pull-only
New shared package, third sibling to the SqlServer and AkkaDotNet replicators. Central hosts a
small secrets_hub.v1 gRPC service backed by its own store (central SQL, which only central
touches); site nodes run the existing reconciler sweep against that endpoint instead of a SQL
connection.
Pull-only is a design invariant, not a simplification: updates originate at central only. That is how every other site-bound configuration flows in ScadaBridge, and it is owner-confirmed for secrets. Sites never push; the hub service exposes no write RPC at all — the narrower contract is the enforcement.
2. Why the seams make D4 cheap
The SqlServer package already splits transport from mechanics:
ISecretReplicationHub— the transport seam the sweep talks to;SecretReplicationReconciler(core) — bidirectional anti-entropy withSecretLastWriterWinsand tombstone-based delete propagation;SqlServerSecretSyncService— theBackgroundServicesweep pattern:SyncOnStartup, aPeriodicTimerinterval (default 30 s), and the documented guarantee that a hub outage degrades the node to serving its last-known-good local secrets rather than crashing the host (the sweep never throws out ofExecuteAsync; failures log and retry next tick).
The gRPC package reuses all of that. What is new:
secrets_hub.v1proto — read-only surface mirroring the pull half ofISecretReplicationHub: enumerate rows with versions/timestamps (tombstones included), fetch row content. Ciphertext only — plaintext never crosses the wire, same trust boundary as every other topology.- Central-side service — hosted on ScadaBridge central's existing gRPC listener, adapting the central store to the proto. Bearer auth on the established central↔site pattern, fail-closed: unauthenticated or unauthorized ⇒ deny; a misconfigured hub serves nothing rather than everything. Exactly one public constructor on the interceptor (the Grpc.AspNetCore multiple-ctor trap). No key material in logs or error messages.
- Site-side sweep client — the
SqlServerSecretSyncServiceshape with a gRPC-backed hub and the reconciler run pull-direction only. Same options surface (SyncInterval,SyncOnStartup, command timeout equivalent as a gRPC deadline). - Bootstrap simplification — the SqlServer mode's "the hub connstr cannot come from the hub" seeding constraint disappears: sites already hold the central endpoint and its credentials.
Offline posture, restated because it is the point: a site cluster booting with no route to central
resolves every ${secret:} ref from its last-synced local store, and converges automatically on
the first successful sweep — the same last-known-good stance as the #485 config guarantee and
LocalDb FetchAndCache.
3. Out of scope, recorded
- Bidirectional sync / site-side authoring. Excluded by D4's invariant. If a genuine need ever appears it is a new decision, not a flag.
- OtOpcUa adoption of the gRPC hub. D1 stands (Akka pair-local). The hub would slot onto OtOpcUa's existing central planes if central-authored secret flow is ever wanted there; noted, not planned.
notification_lists/smtp_configurationsreplication. Those tables stayed permanently unreplicated because the channel would carry plaintext SMTP passwords. An SMTP password stored as a${secret:}ref rides this channel as ciphertext, removing that objection — a follow-up candidate once the hub ships, not part of this work.
4. Live gates (each blocks its half's "done")
ScadaBridge gRPC hub gate (docker cluster):
- Secret added centrally converges to both nodes of every site pair ≤ one sweep interval, byte-identical ciphertext, decrypt-verified.
- Site pair cold-boots with central down ⇒ serves last-known-good secrets; converges after central returns.
- Central delete (tombstone) propagates; the row does not resurrect on site restart.
- Auth negatives fail closed: bad/absent bearer ⇒ denied + one warning; no key material in any log line.
OtOpcUa pair-local re-gate (three-mesh rig): the 2026-07-18 six-node convergence gate ran on the pre-Phase-6 fleet-wide mesh and is obsolete as evidence. Re-gate pair-local semantics as such: seed on one node of a pair ⇒ partner converges ≤ 60 s; no cross-pair propagation (now the correct behavior, not a failure); delete propagates; clean pair restart.
5. Rotation under these topologies
Re-wraps deliberately do not replicate, so a KEK rotation is secret rewrap-all run against
each node's store individually — both nodes of every pair (OtOpcUa), and central + both nodes
of every site pair (ScadaBridge) — with the KEK doctor as recovery if a node is missed. The
kek-rotation runbook covers mechanics; the clustered-secrets runbook's Production enablement
section covers the fan-out.