155 lines
10 KiB
Markdown
155 lines
10 KiB
Markdown
# 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 `GrpcSecretSyncService` logs 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.db` is 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:
|
|
|
|
1. **`deployed_secrets`** — a new LocalDb table in `SiteStorageSchema` (`name` PK, `row_json` = the
|
|
`SecretArtifact` ciphertext row as JSON, `revision`, `updated_utc`, `is_removed` 0/1,
|
|
`deployment_id`, `applied_at`), added to `SiteLocalDbSetup.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 kept `smtp_configurations` unregistered holds.
|
|
`HandleDeployArtifacts` applies it full-set: upsert every shipped row, mark rows absent from the
|
|
set `is_removed = 1` (kept, so the standby learns about the removal).
|
|
2. **`DeployedSecretsMirror`** — a per-node service (every site node, not a singleton) that reconciles
|
|
`deployed_secrets` into the node's own `ISecretStore`: `ApplyReplicatedAsync` for live rows,
|
|
`DeleteAsync` for removed rows **only if the name was previously mirrored** (a local, unreplicated
|
|
`mirrored_secrets` table 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 through `ISecretResolver`; a decrypt failure (KEK mismatch) is an error naming the secret
|
|
and the row's `KekId`. It runs at startup, every `SiteRuntime:DeployedSecretsMirrorInterval`
|
|
(default 15 s — the standby's convergence path), and on demand.
|
|
3. 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 `IAuditService` row per site deploy that carried secrets — action `DeploySiteSecrets`,
|
|
entity type `SiteSecrets`, entity id = site identifier, after-state `{ deploymentId, secrets:
|
|
[{name, revision}], removed: [name], success }`. Same config-change audit as the existing
|
|
`DeployArtifacts` row.
|
|
- **Hub sweep (library `ZB.MOM.WW.Secrets` 0.6.3, scadaproj):** every sweep outcome is observable —
|
|
in-sync sweeps at Debug plus an Information heartbeat at most once per `InSyncLogInterval`
|
|
(default 1 h), a `Recovered after {Failures} failed sweep(s)` Information line, and a public
|
|
`ISecretsHubSyncStatus` (`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.
|