feat(secrets)!: retire the gRPC secrets hub in ScadaBridge — sites get secrets via Deploy Artifacts; rig central on the shared SQL store

This commit is contained in:
Joseph Doherty
2026-09-28 19:52:26 -04:00
parent 52cd4fb096
commit 2bac13170c
11 changed files with 333 additions and 1181 deletions
+71 -65
View File
@@ -17,59 +17,53 @@
x-secrets-store-env: &secrets-store-env
Secrets__SqlitePath: "/data/scadabridge-secrets.db"
# ── Clustered secret replication: the pull-only gRPC hub (scadaproj#3) ─────────
# ── Secrets:Store:Provider=SqlServer: central shares one secret store directly (Task 5) ──
#
# Central hosts the hub on its EXISTING h2c control-plane listener (CentralGrpcPort
# 8083, alongside CentralControlService); each site node sweeps it on an interval and
# writes what it pulls into its OWN local SQLite store. Nothing but ciphertext crosses
# the wire, so every participating node must resolve the SAME KEK.
# Task 11 retired secrets replication entirely — both the pull-only gRPC hub (central
# hosted, sites swept it) and the SQL-Server ReplicatingSecretStore mode that preceded it.
# Sites now receive secrets exclusively via Deploy Artifacts; there is no hub and nothing
# for a site to sweep. What remains, and is unrelated to replication, is Secrets:Store:
# Provider=SqlServer: it points BOTH central nodes at ONE shared SQL-Server database
# directly — no hub, no follower sweep, just two nodes reading and writing the same rows —
# so a clustered central pair can never serve sites a drifted secret set. central-a/b set
# it explicitly below; site nodes always ignore the key and keep their local SQLite store.
#
# ENABLED ON FOUR NODES ONLY: the central pair (hub) and the site-a pair (followers).
# site-b and site-c are deliberately left without it, so the default-OFF posture is
# proven side by side on one rig — exactly as site-a is the rig's only LocalDb-replicated
# pair. A node with no Secrets__* override keeps the shipped appsettings default
# (Replication:Enabled=false, Mode=SqlServer) and composes a plain local store.
#
# ALL VALUES HERE ARE DEV-ONLY and committed under the same exception as the mesh PSKs
# and the ApiKeyPepper above: a local docker rig needs a working credential in source
# control to boot. Production supplies the KEK out of band (ZB_SECRETS_MASTER_KEY, never
# committed) and the hub token from appsettings/env — NEVER as a ${secret:} reference,
# since resolving one is what the hub exists to make possible.
x-secrets-hub-env: &secrets-hub-env
# DEV-ONLY KEK — NOT a real key. Identical on all four participating nodes: only
# ciphertext replicates, so a node with a different KEK fails closed on resolve with a
# kek_id mismatch that reads like corruption but is a deployment error.
ZB_SECRETS_MASTER_KEY: "zZiBWuoaVMbJmGXToLk9Lakw0iJozXoL/7Gxac3GwJ4="
Secrets__Replication__Enabled: "true"
Secrets__Replication__Mode: "Grpc"
# DEV-ONLY shared bearer token — NOT a real secret. Presented by every follower and
# verified by the hub's fail-closed SecretsHubAuthInterceptor. Must be IDENTICAL on the
# hub and every follower; an unset token is a startup failure on both halves.
Secrets__GrpcHub__BearerToken: "secrets-hub-docker-dev-token"
# Central-only half: the SHARED SQL-Server secret store (scadaproj#4, Secrets 0.5.0).
# In Grpc mode BOTH central nodes read and write ONE copy of every row in this database,
# so the two hub instances serve identical manifests by construction — that is what makes
# the site-side FallbackEndpoints below safe. ZbSecretsHub is a dedicated database on the
# rig's existing scadabridge-mssql container (default zbsecrets schema, created by the
# boot-time SqlServerSecretsStoreMigrator; the database itself + the scadabridge_app grant
# were provisioned once via sqlcmd — see docs/plans/2026-08-07-secrets-central-shared-store-live-gate.md).
# Same DEV-ONLY credentials as the ScadaBridge__Database__* strings above. Must be a
# LITERAL value, never a ${secret:} reference — the pre-host expander needs this string to
# reach the store that would resolve it (bootstrap circularity; registration rejects it).
# Site nodes must NEVER carry this key: sites talk to central, not to central's database.
x-secrets-hub-central-env: &secrets-hub-central-env
# ZbSecretsHub is a dedicated database on the rig's existing scadabridge-mssql container
# (default zbsecrets schema, created by the boot-time SqlServerSecretsStoreMigrator; the
# database itself + the scadabridge_app grant were provisioned once via sqlcmd — see
# docs/plans/2026-08-07-secrets-central-shared-store-live-gate.md). Same DEV-ONLY
# credentials as the ScadaBridge__Database__* strings below. Must be a LITERAL value,
# never a ${secret:} reference — the pre-host expander needs this string to reach the
# store that would resolve it (bootstrap circularity; registration rejects it). Site nodes
# must NEVER carry this key: sites talk to central, not to central's database.
x-secrets-central-store-env: &secrets-central-store-env
Secrets__Store__Provider: "SqlServer"
Secrets__SqlServer__ConnectionString: "Server=scadabridge-mssql,1433;Database=ZbSecretsHub;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
# Site half of the same section. The hub client dials Endpoint first and fails over, per
# call, to FallbackEndpoints in order (sticky on whichever answered last, Secrets 0.5.0).
# Listing central-b is safe ONLY because both centrals serve the one shared SQL store
# above — never list endpoints backed by independent stores: failing over to an emptier
# hub is a silent convergence stop, the exact defect (scadaproj#4) the shared store
# exists to prevent.
x-secrets-hub-site-env: &secrets-hub-site-env
Secrets__GrpcHub__Endpoint: "http://scadabridge-central-a:8083"
Secrets__GrpcHub__FallbackEndpoints__0: "http://scadabridge-central-b:8083"
# ── Deploy-artifact secret KEKs ──────────────────────────────────────────────────────
#
# Every node that HANDLES a deployed secret — central (it encrypts what ships in the
# artifact) and site-a (it decrypts what it receives) — must resolve the SAME
# ZB_SECRETS_MASTER_KEY: only ciphertext ever crosses the wire, so a node with a different
# KEK fails closed on resolve with a kek_id mismatch that reads like corruption but is a
# deployment error. site-b/site-c deliberately carry a DIFFERENT dev KEK (below) so that
# mismatch is a real, provable negative on this rig rather than an assumption — see
# Task 13.
#
# ALL VALUES HERE ARE DEV-ONLY and committed under the same exception as the mesh PSKs
# and the ApiKeyPepper below: a local docker rig needs a working credential in source
# control to boot. Production supplies the KEK out of band (ZB_SECRETS_MASTER_KEY, never
# committed).
x-secrets-shared-kek-env: &secrets-shared-kek-env
# DEV-ONLY KEK — NOT a real key. Identical on central-a/b and site-a-a/b.
ZB_SECRETS_MASTER_KEY: "zZiBWuoaVMbJmGXToLk9Lakw0iJozXoL/7Gxac3GwJ4="
# A DIFFERENT DEV-ONLY KEK — NOT a real key — deliberately mismatched from the one above.
# site-b and site-c never receive a deployed secret from this rig's exercised paths today,
# but carrying a mismatched key here (rather than none) means a future deploy targeting
# them fails closed with a named kek_id mismatch instead of an untested code path.
x-secrets-mismatched-kek-env: &secrets-mismatched-kek-env
ZB_SECRETS_MASTER_KEY: "esKrmqV2RBmMq/+6T/5zK2BcalRy5gzDXhoG9Qydg6Q="
services:
central-a:
@@ -87,9 +81,9 @@ services:
stop_grace_period: 30s
container_name: scadabridge-central-a
environment:
# Hub half of the pull-only gRPC secrets hub + the central-only shared SQL store
# (anchors at the top of this file).
<<: [*secrets-store-env, *secrets-hub-env, *secrets-hub-central-env]
# Central-only shared SQL secret store (Secrets:Store:Provider=SqlServer, Task 5) + the
# deploy-artifact secret KEK (anchors at the top of this file).
<<: [*secrets-store-env, *secrets-shared-kek-env, *secrets-central-store-env]
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
@@ -154,9 +148,9 @@ services:
stop_grace_period: 30s
container_name: scadabridge-central-b
environment:
# Hub half of the pull-only gRPC secrets hub + the central-only shared SQL store
# (anchors at the top of this file).
<<: [*secrets-store-env, *secrets-hub-env, *secrets-hub-central-env]
# Central-only shared SQL secret store (Secrets:Store:Provider=SqlServer, Task 5) + the
# deploy-artifact secret KEK (anchors at the top of this file).
<<: [*secrets-store-env, *secrets-shared-kek-env, *secrets-central-store-env]
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
@@ -221,9 +215,11 @@ services:
stop_grace_period: 30s
container_name: scadabridge-site-a-a
environment:
# Follower half of the pull-only gRPC secrets hub (anchors at the top of this
# file). site-b and site-c deliberately carry neither.
<<: [*secrets-store-env, *secrets-hub-env, *secrets-hub-site-env]
# The shared deploy-artifact secret KEK (anchors at the top of this file) — this node
# must decrypt what central ships in a deploy artifact. site-b and site-c deliberately
# carry a DIFFERENT (mismatched) KEK instead, so that failure mode is provable on this
# rig rather than assumed (Task 13).
<<: [*secrets-store-env, *secrets-shared-kek-env]
SCADABRIDGE_CONFIG: Site
# Register row 63. The central containers have always set this; the site containers
# never did, so the Development-only DI scope validation (register row 50,
@@ -258,9 +254,11 @@ services:
stop_grace_period: 30s
container_name: scadabridge-site-a-b
environment:
# Follower half of the pull-only gRPC secrets hub (anchors at the top of this
# file). site-b and site-c deliberately carry neither.
<<: [*secrets-store-env, *secrets-hub-env, *secrets-hub-site-env]
# The shared deploy-artifact secret KEK (anchors at the top of this file) — this node
# must decrypt what central ships in a deploy artifact. site-b and site-c deliberately
# carry a DIFFERENT (mismatched) KEK instead, so that failure mode is provable on this
# rig rather than assumed (Task 13).
<<: [*secrets-store-env, *secrets-shared-kek-env]
SCADABRIDGE_CONFIG: Site
# Register row 63. The central containers have always set this; the site containers
# never did, so the Development-only DI scope validation (register row 50,
@@ -295,7 +293,9 @@ services:
stop_grace_period: 30s
container_name: scadabridge-site-b-a
environment:
<<: *secrets-store-env
# A DIFFERENT (mismatched) deploy-artifact secret KEK from central/site-a's — see the
# anchor comment at the top of this file (Task 13's mismatch negative).
<<: [*secrets-store-env, *secrets-mismatched-kek-env]
SCADABRIDGE_CONFIG: Site
# Register row 63. The central containers have always set this; the site containers
# never did, so the Development-only DI scope validation (register row 50,
@@ -329,7 +329,9 @@ services:
stop_grace_period: 30s
container_name: scadabridge-site-b-b
environment:
<<: *secrets-store-env
# A DIFFERENT (mismatched) deploy-artifact secret KEK from central/site-a's — see the
# anchor comment at the top of this file (Task 13's mismatch negative).
<<: [*secrets-store-env, *secrets-mismatched-kek-env]
SCADABRIDGE_CONFIG: Site
# Register row 63. The central containers have always set this; the site containers
# never did, so the Development-only DI scope validation (register row 50,
@@ -363,7 +365,9 @@ services:
stop_grace_period: 30s
container_name: scadabridge-site-c-a
environment:
<<: *secrets-store-env
# A DIFFERENT (mismatched) deploy-artifact secret KEK from central/site-a's — see the
# anchor comment at the top of this file (Task 13's mismatch negative).
<<: [*secrets-store-env, *secrets-mismatched-kek-env]
SCADABRIDGE_CONFIG: Site
# Register row 63. The central containers have always set this; the site containers
# never did, so the Development-only DI scope validation (register row 50,
@@ -397,7 +401,9 @@ services:
stop_grace_period: 30s
container_name: scadabridge-site-c-b
environment:
<<: *secrets-store-env
# A DIFFERENT (mismatched) deploy-artifact secret KEK from central/site-a's — see the
# anchor comment at the top of this file (Task 13's mismatch negative).
<<: [*secrets-store-env, *secrets-mismatched-kek-env]
SCADABRIDGE_CONFIG: Site
# Register row 63. The central containers have always set this; the site containers
# never did, so the Development-only DI scope validation (register row 50,