# Conflicts: # CLAUDE.md # docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md
20 KiB
Site secrets ride the artifact deploy — design
Status: design, 2026-09-28. Plan: 2026-09-28-site-secrets-artifact-deploy.md.
1. Problem
A site resolves secrets at runtime (today: an MxGateway data connection's ApiKey: "secret:<name>",
DataConnectionLayer/Adapters/MxGatewayDataConnection.cs:101). The only way a secret reaches a site
is the pull-only gRPC secrets hub (Secrets:Replication:Mode=Grpc), which:
- copies every central secret to every participating site node every 30 s — no per-site scope;
- is invisible to the deployment model: the Sites page Artifacts current / out of date badge
(
Site.ArtifactRevisionHash, 2026-09-14) knows nothing about secrets, so a site can read Current while its data connection cannot resolve its key; - logs nothing on a successful in-sync sweep (library
GrpcSecretSyncServicelogs only when rows were pulled or on failure) and exposes no status; - is enabled only on the docker rig's site-a; vd03 and QA run with replication OFF, so each site node's
secrets.dbis filled by hand.
2. Decisions (user, 2026-09-28)
| # | Decision |
|---|---|
| U1 | A site receives only the secrets it references — never other sites' keys. |
| U2 | The artifact deploy replaces the gRPC hub as the way secrets reach sites (hub retired for ScadaBridge sites). |
| U3 | Logging = deploy log lines (central + site), audit rows, and hub sweep logging (for the transition until the hub is retired, and for other hub consumers). |
| U4 | Central deploys from one shared SQL-Server secret store used by both central nodes. |
3. Design
3.1 Which secrets a site needs — derived, not configured
SecretReferenceScanner.Scan(IEnumerable<DataConnectionArtifact>) walks each connection's
PrimaryConfigurationJson and BackupConfigurationJson and collects every JSON string value
of the form secret:<name> (prefix case-insensitive, <name> trimmed, must be a valid SecretName).
It is protocol-agnostic on purpose: a new adapter that adopts secret: references is covered with
no scanner change. Result: a distinct, ordinal-sorted name set per site, each name carrying the
data connections that reference it (for error messages and logs).
Deliberately NOT included: bootstrap secrets — the site's gRPC PSK (SB-GRPC-PSK-{siteId}), the
LocalDb replication key, the secrets master key. The site needs them before it can receive an
artifact deploy, and pushing a rotated PSK through the channel it authenticates would sever that
channel. They stay out-of-band (service environment / hand-seeded store), exactly as today on vd03/QA.
${secret:X} references inside a node's own appsettings are node-local and invisible to central —
also out of scope.
3.2 Central source of truth — the shared SQL store (U4)
New option Secrets:Store:Provider (Sqlite default | SqlServer), read only on the Central
role. SqlServer registers the library's AddZbSecretsSqlServerStore against
Secrets:SqlServer:ConnectionString (literal/env value — never ${secret:}, same bootstrap rule as
today) so both central nodes read and write one store; the /admin/secrets page edits it. This is
the store the hub's Grpc-mode central already uses — decoupled from the hub. A clustered Central left
on Sqlite boots but StartupValidator logs a Warning (same shape as the inbound API key-store
warning, row 108), because the two nodes would deploy different secret sets depending on which is
active.
3.3 The wire: ciphertext rows, never plaintext
DeployArtifactsCommand gains an additive Secrets list of SecretArtifact — the library's
StoredSecret fields verbatim (name, content type, ciphertext/nonce/tag, wrapped DEK/nonce/tag,
KekId, Revision, created/updated UTC). Central never decrypts; the site applies the row with
ISecretStore.ApplyReplicatedAsync (last-writer-wins on UpdatedUtc/Revision, the same path the
hub sweep uses). Every node must therefore share the KEK (ZB_SECRETS_MASTER_KEY / master-key
file) — exactly the hub's existing requirement. A mismatch is detected at deploy time (§3.5), not at
the next reconnect. Proto: DeployArtifactsCommandDto.secrets = 9 (SecretArtifactListDto, absent ⇒
null) and ArtifactDeploymentResponseDto gains secrets_applied (wrapper Int32Value, absent ⇒ null).
Fail-closed on a missing secret. If a referenced name is absent or tombstoned in the central
store, that site's deploy fails before anything is sent: Secret 'X' referenced by data connection(s) A, B is not in the central secret store. Shipping the rest would stamp the site Current while a
connection cannot resolve its key.
3.4 Staleness — secrets are part of the artifact hash
ArtifactRevisionHashService adds Secrets → HashableSecret { ContentDigest, KekId, Missing, Name, Revision } (alphabetical — the existing ordering test covers it). ContentDigest = SHA-256 of
ciphertext‖nonce‖tag‖wrappedDek, so a KEK rewrap (new wrapped DEK, same revision) correctly marks
sites out of date. ComputeCurrentArtifactHashesAsync hashes missing names with Missing = true, so
a site referencing a not-yet-created secret reads Out of date, never Current. The list is
null-when-empty and the serializer omits nulls, so sites with no secret references keep their
current hash — only sites that reference secrets flip to Out of date once after rollout.
3.5 Site apply — both nodes, via a replicated table
The artifact apply runs on the active node's DeploymentManagerActor singleton, but secrets.db is
a per-node file outside LocalDb. So:
deployed_secrets— a new LocalDb table inSiteStorageSchema(namePK,row_json= theSecretArtifactciphertext row as JSON,revision,updated_utc,is_removed0/1,deployment_id,applied_at), added toSiteLocalDbSetup.ReplicatedTables(becomes the 11th; LocalDb 0.3.x snapshots just that table on an already-replicating pair). No FKs (row 105 guard). Ciphertext only — the no-plaintext rule that keptsmtp_configurationsunregistered holds.HandleDeployArtifactsapplies it full-set: upsert every shipped row, mark rows absent from the setis_removed = 1(kept, so the standby learns about the removal).DeployedSecretsMirror— a per-node service (every site node, not a singleton) that reconcilesdeployed_secretsinto the node's ownISecretStore:ApplyReplicatedAsyncfor live rows,DeleteAsyncfor removed rows only if the name was previously mirrored (a local, unreplicatedmirrored_secretstable records names this mirror wrote — an out-of-band secret of the same name that was never deployed is never deleted). After applying, it trial-resolves each deployed secret throughISecretResolver; a decrypt failure (KEK mismatch) is an error naming the secret and the row'sKekId. It runs at startup, everySiteRuntime:DeployedSecretsMirrorInterval(default 15 s — the standby's convergence path), and on demand.- The active node's apply calls the mirror synchronously after writing the table and fails the
deploy response if any secret did not apply or verify; on success the response carries
SecretsApplied = n. The standby converges through LocalDb replication + its own mirror within one interval.
3.6 Mixed versions
A pre-feature site ignores proto field 9 and answers Success with secrets_applied absent. Central
treats command carried ≥ 1 secret AND SecretsApplied is null as a failure for that site
(site build predates secret deployment — upgrade the site) and does not stamp the hash. A command
with no secrets is unaffected, so an old site with no secret references deploys exactly as before.
Upgrade central first, as always.
3.7 Logging and audit (U3)
- Central, per site: Information
Deploying {Count} secret(s) to site {SiteId}: {Name}@r{Revision},…; Error on a missing secret (names + referencing connections). Never values or ciphertext. - Site, active apply and every mirror pass that changes something: Information per secret
Applied deployed secret {Name} r{Revision} (kek {KekId})/Removed deployed secret {Name}; Error on apply/verify failure; one Information summary per pass. An in-sync mirror pass logs at Debug only. - Audit: one
IAuditServicerow per site deploy that carried secrets — actionDeploySiteSecrets, entity typeSiteSecrets, entity id = site identifier, after-state{ deploymentId, secrets: [{name, revision}], removed: [name], success }. Same config-change audit as the existingDeployArtifactsrow. - Hub sweep (library
ZB.MOM.WW.Secrets0.6.3, scadaproj): every sweep outcome is observable — in-sync sweeps at Debug plus an Information heartbeat at most once perInSyncLogInterval(default 1 h), aRecovered after {Failures} failed sweep(s)Information line, and a publicISecretsHubSyncStatus(LastSweepUtc,LastSuccessUtc,ConsecutiveFailures,LastError). Useful for the rig until the hub is retired and for any other hub consumer.
3.8 Hub retirement (U2)
Once the artifact path is live on the rig, ScadaBridge stops composing the hub: the Site role no
longer registers AddZbSecretsGrpcHubClient, the Central role no longer maps the hub endpoint, and
StartupValidator refuses Secrets:Replication:Enabled=true with Mode=Grpc with a message pointing
at the artifact deploy. The library keeps the hub (other consumers); only ScadaBridge stops using it.
The rig's site-a moves from the hub to the artifact path; central keeps the shared SQL store through
Secrets:Store:Provider=SqlServer.
4. Rollout (deployment records, not code)
vd03 and QA need, per environment: the zbsecrets store in the configuration SQL Server + both
centrals on Secrets:Store:Provider=SqlServer; one KEK across every central and site node
(verify before enabling — the mirror's verify step reports a mismatch by name); existing hand-seeded
site secrets recreated at central; then Deploy Artifacts. Tracked as a deferred-work register row.
5. Out of scope
Per-site explicit secret lists; secrets for external systems / database connections (they ship literal values today); plaintext transport; moving bootstrap secrets through the deploy.
6. Implementation notes (as built)
Execution (tasks 1–11, ledger .superpowers/sdd/2026-09-28-site-secrets-artifact-deploy/progress.md)
matched this design in shape but landed several deviations, called out here so the doc stays
accurate as the spec:
- Central collection. The scanner and per-site resolve described in §3.1/§3.3 are implemented
as
SiteSecretsCollector(ZB.MOM.WW.ScadaBridge.DeploymentManager), not inline inArtifactDeploymentService. Fail-closed (§3.3) is per site, not per batch: a site with an unresolved reference is skipped (_communicationServiceis never called for it) while every other site in the same sweep proceeds normally. - Audit (§3.7). The
DeploySiteSecrets/SiteSecretsaudit row is written on bothDeployCoreAsync(the sweep) andRetryForSiteAsync(the per-site retry), including the fail-closed path on each — the design's §3.7 wording ("one row per site deploy that carried secrets") did not call out the retry path explicitly; it is in scope and covered. - Stamp gating (§3.6). Implemented as designed, plus one addition: a partial apply
(
SecretsAppliedless than the shipped count, not just the mixed-versionnullcase) is also treated as a failure and is not stamped, with its own message (Site applied {n} of {m} secrets) distinct from the "predates secret deployment" wording. - Hash (§3.4). Implemented as designed. Confirmed explicitly (not just implied by
"null-when-empty"): an empty
Secrets/MissingSecretslist and a null one hash identically, and the pre-existing golden hash for a site with no secret references is unchanged by this feature (pinned by a dedicated regression test). - Site apply (§3.5).
HandleDeployArtifactswrites the fulldeployed_secretsset and then runsDeployedSecretsMirror.ReconcileAsyncsynchronously before replying, exactly as designed.SecretsAppliedcounts only the names this deploy shipped that applied and verified without error — a mirror failure for a name this deploy did not ship (e.g. another, already-broken deployed secret, or an unrelated removal) is logged at Warning, excluded from the count, and does not fail the deploy. Removal failures are likewise Warnings, never deploy failures. DeployedSecretsMirror— apply is confirm-then-ledger, not the blindApplyReplicatedAsync§3.5 originally described. The real library store is strict last-writer-wins on(UpdatedUtc, Revision)(a tie is a no-op) and tombstones rather than hard-deletes, which the naive "apply then ledger" sequence gets wrong in two ways: a routine remove-then-re-add can no-op against a still-live tombstone while the ledger is written anyway (silently broken), and an out-of-band local secret with a genuinely newer stamp can be ledgered and later deleted by a central tombstone (destroying the operator's own secret). The mirror now always reads the row back after applying and only ledgers when the local ciphertext matches what was shipped; a tombstoned or ledger-owned row is safely re-stamped (UpdatedUtc/Revisionbumped past the local value, including across clock skew) and re-applied, while a live row with different ciphertext that the ledger does not already own is left completely untouched and reported as a distinct out-of-band-conflict failure. Ownership is decided frommirrored_secretsmembership at any revision, not from tombstone state alone — so a mirror-owned name that drifts locally (an operator's edit over an already-deployed name) is re-stamped back to the deployed value on the next pass: central is authoritative for every name it deploys. Every apply/ delete also invalidates the sharedISecretResolvercache before the verify step, and one poisoned row never blocks the rest of the pass.- Hub retirement (§3.8) — scope widened to ALL secrets replication, not just the gRPC hub.
ScadaBridge also retires the historical SQL-Server replication mode
(
Secrets:Replication:Mode=SqlServer, distinct from the still-supportedSecrets:Store:Provider=SqlServershared-store feature) for the same reason the gRPC hub goes: a site'sISecretStoremust be the plain, undecorated local SQLite store, becauseDeployedSecretsMirrorcallsDeleteAsyncdirectly and a replication decorator would publish that delete fleet-wide.Secrets:Replication:Enabledset to anything other than absent/empty/false(a deliberately strict, non-boolean-coerced check —"0"/"1"/"yes"all count as "set") now failsStartupValidator.Validateon either role. A leftoverSecrets:Replication:Modeor anySecrets:GrpcHub:*key logs a startup Warning naming both sections. The pre-host${secret:}expander was fixed in the same task set to read the shared SQL store on aStore:Provider=SqlServercentral (it previously only did this for the retired Grpc mode) — without that fix a central's boot-time${secret:}tokens and its runningISecretResolvercould resolve a token differently. ZB.MOM.WW.Secrets0.6.3 (hub sweep logging, §3.7's third bullet —EventIds 4/5/6,ISecretsHubSyncStatus) is published to the Gitea feed (2026-09-28, after merge), but ScadaBridge does not consume it — hub retirement (above) removes ScadaBridge's only dependency on the hub client, so it stays on 0.6.2. It is there for the library's other hub consumers (OtOpcUa).- Empty, not null, when a site references no secrets (Task 13 live-gate fix). The command
builder first shipped
Secrets = nullfor a site with no references ("null-when-empty", borrowed from the hash convention). But the site treatsnullas "central shipped no secrets" and touches nothing, so dropping a site's LASTsecret:reference never soft-removed the row and the secret stayed live on both nodes. Central now always ships a list (empty when there are no references); the hash is unchanged because an empty and a null list hash identically. - Final-review fixes.
- Same-revision drift is healed. A name already ledgered at the shipped revision is still read back every pass; a missing, tombstoned or edited local row is re-stamped back to the shipped ciphertext before verify (previously a local delete was misreported as a KEK mismatch and blocked the site's deploys).
- Unreadable
row_jsonis a per-name failure, not a pass-wide exception. - Site ordering: secrets are written and mirrored before the deploy's data connections are pushed to the DCL; a rotation with unchanged connection config takes effect on the connection's next (re)connect (no forced reconnect).
- One
secret:parser (SecretReferenceScanner.TryParseSecretReference) shared by the scanner andMxGatewayDataConnection, so a padded/case-variant reference resolves the shipped name. - Audit shape (§3.7). The
DeploySiteSecretsrow carriessecrets,missingandremoved. (Shipped first withoutremoved, so a removal-only deploy wrote no central row; closed by the follow-ups below.) - Deployment prerequisite: a site pair using deployed secrets must run LocalDb replication (topology guide), since on an unreplicated pair only the node active at deploy time gets them.
- Follow-ups closed after merge (2026-09-28).
- Secret-store outage is per site (review M5).
SiteSecretsCollectorreturns a store read failure asSiteSecretsCollection.StoreErrorinstead of throwing. A deploy fails only the sites that reference a secret (nothing is sent to them; they get aDeploySiteSecretsrow with the error), andComputeCurrentArtifactHashesAsyncleaves those sites out of its map, which the pending-updates evaluator reads as Out of date. Sites with no secret references never touch the store and are unaffected. Before this, oneGetAsyncexception aborted the whole sweep and the fleet-wide pending-updates check. - Removal-only deploys are audited. The site's reply gained
SecretsRemoved(protoArtifactDeploymentResponseDto.secrets_removed = 7, names only; absent/empty reads as null): thedeployed_secretsrows that deploy soft-removed. Central carries it onSiteArtifactResult.SecretsRemovedand writes theDeploySiteSecretsrow whenever it is non-empty, with aremovedlist. An old site never sends it, so its removals stay unaudited centrally, as before. - CLI exit code.
site deploy-artifactsanddeploy artifactsexit1when the summary'sfailureCountis non-zero (same ruledeploy sitealready had). - Hub-era site-a secrets (review M8). The retired hub had copied six
hub-gate-*/shared-gate-*test secrets (2026-08-07) into both rig site-a nodes' local stores. The mirror deliberately never deletes a name it did not ledger, so they were removed once by hand on the rig (both nodes stopped together; see the live-gate doc). QA and vd03 never ran the hub (Secrets:Replication:Enabled=false), so nothing like this exists there.
- Secret-store outage is per site (review M5).
- Standby mirror failures reach central (follow-up, review finding M1). A
DeployedSecretsMirrorfailure on the standby (e.g. a KEK mismatch) was only logged on that node — site health reports are sent by the active node only. Each node now records its current failure set (names + messages, never values) in a new, 12th LocalDb-replicated tablesecret_mirror_status(one row per node keyed bynode_name), written only when the set changes plus once per start-up; a per-nodeSecretMirrorStatusReporterputs every node's row on the active node's report asSiteHealthReport.SecretMirrorStatuses(protoSiteHealthReportDtofield 33, presence-wrapped), and the Central UI health card shows a Secret mirror failing badge + per-node list. Requires LocalDb replication for the standby's row to be visible, the same prerequisite as the secrets. - Rig posture.
docker/docker-compose.yml's central nodes now carrySecrets__Store__Provider=SqlServerplus a shared KEK; site-a shares that KEK (it is the secrets-via-artifact-deploy site); site-b/site-c deliberately carry a mismatched KEK so theDeployedSecretsMirrorverify-failure path has a real, provable negative on the rig rather than an assumed one.