Files
scadaproj/docs/plans/2026-07-17-secrets-g7-clustered-replication-design.md
T
Joseph Doherty dd0a846b64 feat(secrets): cluster replication via SQL Server and Akka.NET (G-7, 0.2.0)
Secrets were per-node SQLite, so a secret written on one node was invisible to
the rest of a cluster. G-7's design resolved the "shared SQL store vs Akka
replicator" fork to build only the former; both are built here so the choice is
a deployment decision (availability vs partition tolerance) rather than a
library limitation.

Two new packages — ZB.MOM.WW.Secrets.Replicator.SqlServer (shared store, plus a
local-store-with-hub mode) and .Replicator.AkkaDotNet (peer-to-peer over
distributed pub/sub). Core gains ISecretsStoreMigrator, one shared
SecretLastWriterWins predicate so no two stores can disagree on a tie, the
transport-agnostic reconciler, and ReplicatingSecretStore — which closes a real
gap: nothing had ever called ISecretReplicator.PublishAsync, so the seam was
inert and local writes would not have propagated at all.

Verified 182 pass / 1 skip / 0 warnings, including 15 live tests against a real
SQL Server 2022 (the SQLite suite ported case-for-case, so any behavioural
divergence between the stores fails) and a 9-test in-process 2-node Akka
cluster over real remoting. A post-build review caught six defects, all fixed
and now covered: both replication modes could not resolve from the container
(no test had built one), an unbounded fetch that broke past SQL Server's
2100-parameter cap, a poison row that aborted the rest of its batch forever,
Enum.Parse on peer input that could restart the actor in a loop, null crypto
blobs crossing the trust boundary, and a silently dropped pull-read failure.

Packed at 0.2.0 and vulnerability-scanned clean; not yet published to the feed.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-18 04:08:23 -04:00

7.1 KiB

G-7 — Clustered secret replication: design & fork resolution

SUPERSEDED BY THE BUILD, 2026-07-18. This document resolved the fork to Option A and deferred Option B. On the user's instruction both were built, as ZB.MOM.WW.Secrets.Replicator.SqlServer (shared-store and hub modes) and ZB.MOM.WW.Secrets.Replicator.AkkaDotNet. The fork analysis below is still the right way to choose between them — it is now a deployment decision, not a build one. The "Decision" section's YAGNI reasoning for deferring Option B no longer applies.

Status: design-only (G-7 in components/secrets/GAPS.md). Companion executable plan: 2026-07-17-secrets-g7-sqlserver-store.md. Prerequisite G-2…G-6 have landed for both clustered apps (ScadaBridge, OtOpcUa); G-8 (KEK rotation) is built. This resolves the SPEC's "shared SQL store vs Akka replicator" fork and specifies the recommended build.

Problem

ZB.MOM.WW.Secrets stores secrets in a local SQLite file by default. The two Akka-clustered apps run multiple nodes (ScadaBridge: central pair + N site pairs; OtOpcUa: admin/driver/dev roles). A secret written on one node must be resolvable on every node that needs it, without exposing plaintext or the KEK over the wire, and without each node holding a divergent copy.

The library was built anticipating this: the schema already carries revision / updated_utc / is_deleted; ISecretStore exposes GetManifestAsync + ApplyReplicatedAsync (last-writer-wins); ISecretReplicator is a no-op seam; SecretManifestEntry supports anti-entropy. Nothing needs a migration to turn replication on.

The fork (from SPEC §"Clustered pairs")

Two ways to make one logical secret set visible cluster-wide. Both require a shared KEK — every node must resolve the same master key (shared mounted key file or shared env), because a row whose kek_id doesn't match the local provider fails closed on resolve. Ciphertext can cross the wire / sit in a shared DB safely because the KEK never does.

Option A — Shared SQL-Server ISecretStore (one source of truth)

Point every node's ISecretStore at one shared SQL-Server database instead of a per-node SQLite file. There is exactly one copy of each row; no replication, no reconciliation. The store still holds ciphertext only (the KEK stays per-node/out-of-DB). Build a SqlServerSecretStore : ISecretStore behind the existing seam (the SPEC already scoped "SQL-Server ISecretStore provider construction — build with G-7").

  • Pro: zero distributed-systems code or failure modes (no split-brain, no anti-entropy lag, no tombstone GC, no LWW clock-skew). ~1 new file mirroring SqliteSecretStore's SQL + a migrator.
  • Pro: both apps already run shared SQL Server (ScadaBridge ConfigDb + central dbo.*; OtOpcUa central config DB) and already share their Data-Protection key ring through SQL — a shared secret store is operationally identical to what ops already run and back up.
  • Pro: rotation (G-8 rewrap-all) runs once against the one store.
  • Con: availability is coupled to the shared DB. A brief outage is bridged by the resolver's in-memory TTL cache; but a node that must keep resolving secrets while partitioned from the shared DB is not served by this option (see Option B).

Option B — Akka replicator ZB.MOM.WW.Secrets.Akka (each node keeps a local store)

Each node keeps its local SQLite store; a real ISecretReplicator broadcasts each newly-written encrypted row to peers, and a cluster anti-entropy actor periodically exchanges manifests (GetManifestAsync) and pulls missing/newer rows (ApplyReplicatedAsync, LWW). Tombstones propagate deletes.

  • Pro: each node resolves from local state, so a node survives partition from its peers / from any central DB — the right answer for air-gapped or intermittently-connected sites.
  • Con: a whole new package + actor lifecycle (a cluster-singleton reconciler or per-node gossip), message serialization for StoredSecret, LWW edge cases (clock skew, tombstone retention), and — critically — it can only be proven against a live multi-node cluster, a G-2-class live-validation effort per app.

Decision

Build Option A (shared SQL-Server ISecretStore) as G-7. Specify Option B as a deferred phase-2, to be built only when an app declares a concrete requirement to resolve secrets while partitioned from the shared store (an availability SLA neither app states today).

Rationale: Option A delivers cluster-wide secrets for both apps with the least new code and no new failure modes, reusing the shared-SQL operational posture both apps already depend on for config, audit, and the DP key ring. The clustered-secrets requirement today is "every node sees the same secrets," which a shared store satisfies exactly. Partition-tolerance (Option B's only unique benefit) is a stronger, unstated requirement; paying its distributed-systems complexity now would be speculative (YAGNI). The ISecretReplicator seam stays in place, so Option B remains a drop-in later with no change to consumers or the wire/store contract.

Option A — build shape (detail in the executable plan)

  1. SqlServerSecretStore : ISecretStore — mirrors SqliteSecretStore in T-SQL: GetAsync, UpsertAsync (revision bump via MERGE/UPDATE), DeleteAsync (tombstone), ListAsync (metadata projection — never ciphertext), GetManifestAsync, ApplyReplicatedAsync (LWW under a serializable transaction), and ApplyRewrapAsync (G-8; UPDATE the 4 wrap columns only). Microsoft.Data.SqlClient, fully parameterized.
  2. SqlServerSecretsStoreMigrator — schema-versioned, idempotent (IF NOT EXISTS), same column set/semantics as SQLite; varbinary(max) for the crypto BLOBs, datetimeoffset (or ISO-8601 text, to match the SQLite round-trip exactly) for timestamps.
  3. DI selection — add SecretsOptions.Store (Sqlite default | SqlServer) + SqlServerConnectionString; AddZbSecrets binds the store + migrator from it. SQLite stays the default so single-process consumers (HistorianGateway, mxaccessgw) are unaffected.
  4. Adoption (ScadaBridge, OtOpcUa) — set Secrets:Store = SqlServer + a connection string (delivered via ${secret:} / a secret: ref like every other connstr), and ensure the same KEK on every node (shared key file or shared env). Run G-8 rewrap-all once against the shared store when the KEK rotates.

Hard constraints (both options)

  • Same KEK on every node. Non-negotiable — a mismatched kek_id fails closed on resolve.
  • Ciphertext only crosses trust boundaries. The store/wire never carries plaintext or the KEK.
  • Rotation is per independent store. G-8 rewrap-all: once for the shared SQL store (Option A); once per node for per-node SQLite (Option B).

Out of scope

  • Akka remoting auth/TLS hardening (a separate concern from secret storage).
  • Option B's actual construction — captured as the deferred phase-2 in the executable plan.