feat(secrets): wire the pull-only gRPC secrets hub — central hosts, sites sweep

scadaproj#3: the production secrets topology is central hosting a pull-only
gRPC hub with site nodes sweeping it. The SqlServer replicator stays in the
codebase and keeps working exactly as it did, but it is not the production
path — it needs every site node to hold a connection string to central's
database, which breaks ScadaBridge's standing rule that sites talk to central,
not to central's DB.

Selection is a new Secrets:Replication:Mode key alongside the existing
Secrets:Replication:Enabled flag. Absent or blank means SqlServer, so an
existing configuration that sets only Enabled behaves identically; an
unrecognised value is refused at startup naming the key, and refused whenever
it is present rather than only when replication is on — a typo should fail the
boot that introduced it, not some later boot that flips an unrelated flag.

Which HALF a node composes is a parameter, not a config key. Both composition
roots already know statically which they are (Program.cs is central,
SiteServiceRegistration is a site), and a role read from configuration is a
role that can be got wrong in the one direction that matters: a site hosting
the hub would serve central's whole secret inventory from inside the site
network to anything holding the shared token.

The hub is mapped onto the EXISTING central h2c control-plane listener
(ScadaBridge:Node:CentralGrpcPort, default 8083) beside CentralControlService
— the same listener and the same addressing convention sites already use, and
the same shape as the site's LocalDb sync endpoint sharing its gRPC port: two
disjoint service prefixes, two independent fail-closed gates. The
CentralControlAuthInterceptor on AddGrpc is prefix-scoped and passes hub calls
through; the hub's own SecretsHubAuthInterceptor, attached per-service by
AddZbSecretsGrpcHub, gates them on Secrets:GrpcHub:BearerToken.

Registration and mapping share one predicate (UsesGrpcHub) deliberately.
Mapping without registering would map a hub whose interceptor was never
attached — an anonymous endpoint serving every secret central holds, on a node
that looks completely healthy — so the two must not be able to drift.

gRPC mode fails CLOSED where SqlServer mode degrades: a missing endpoint or
bearer token is a startup failure, not a warning plus a local-only store. The
degraded outcome is precisely what the hub exists to prevent (a site quietly
serving secrets that never converge), and the package's own errors name the
exact key, so they are left unwrapped.

Templates are default-OFF: Enabled stays false, Mode stays SqlServer, and
Secrets:GrpcHub ships with an empty BearerToken and Endpoint. Empty is
fail-closed, not open. The token is documented as appsettings/env only — never
a ${secret:} reference, since resolving one is what the hub exists to make
possible — the same bootstrap rule the mesh pre-shared keys follow.

Known asymmetry, recorded in the template: CentralGrpcEndpoints is a LIST that
fails over across the central pair, but the hub client dials a SINGLE endpoint,
so a sweep against a downed central node stalls instead of failing over. That
is survivable — the sweep is best-effort and the site keeps serving its full
local last-known-good store — but secrets stop converging until that node is
back.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
Joseph Doherty
2026-08-07 07:09:47 -04:00
parent 57f08c213b
commit 127ec25425
4 changed files with 251 additions and 30 deletions
+25 -5
View File
@@ -255,11 +255,15 @@ try
.GetValue<bool>(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.AllowOutsideDevelopment)));
builder.Services.AddSecurity(disableLogin);
builder.Services.AddCentralUI();
// Local SQLite store by default; a local store replicating against a shared SQL-Server hub
// only when Secrets:Replication:Enabled is true AND a hub connection string is present.
// See SecretsRegistration for why the gate exists (eager options validation) and why the
// hub connection string can never itself come from the hub.
builder.Services.AddScadaBridgeSecrets(builder.Configuration);
// Local SQLite store by default. With Secrets:Replication:Enabled true it becomes either a
// local store synced against a shared SQL-Server hub (Mode=SqlServer, and only when a hub
// connection string is present) or — the production topology, scadaproj#3 — the HOST half
// of the pull-only gRPC secrets hub (Mode=Grpc), which central serves and sites sweep.
// The Central role never registers the sweep client; passing the role explicitly is what
// makes that a compile-time property of this composition root rather than a config guess.
// See SecretsRegistration for why the SQL gate exists (eager options validation), why the
// hub credential can never itself come from the hub, and why gRPC mode fails closed.
builder.Services.AddScadaBridgeSecrets(builder.Configuration, SecretsNodeRole.Central);
// Secrets UI authorization: adds the named policies secrets:manage + secrets:reveal
// (role-based) consumed by the /admin/secrets page. AddSecretsAuthorization only ADDS
// these two policies via Configure<AuthorizationOptions> — it composes additively with
@@ -555,6 +559,22 @@ try
// connection to the right pipeline by listener/protocol.
app.MapGrpcService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlGrpcService>();
// The pull-only secrets hub (scadaproj#3), on the SAME h2c listener and the same addressing
// convention — a site dials central's CentralGrpcPort, never central's database. It shares
// the listener with CentralControlService the way the site's LocalDb sync endpoint shares
// its one: two disjoint service prefixes, two independent fail-closed gates. The
// CentralControlAuthInterceptor registered on AddGrpc is prefix-scoped to
// CentralControlService and passes hub calls straight through; the hub's own
// SecretsHubAuthInterceptor — attached per-service by AddZbSecretsGrpcHub — is what gates
// it, on the shared Secrets:GrpcHub:BearerToken.
//
// A NO-OP unless this node registered the hub. That is not an optimisation: mapping the
// service without registering it would map a hub whose interceptor was never attached,
// i.e. an unauthenticated endpoint serving every secret central holds. Both this call and
// the registration above route through SecretsRegistration.UsesGrpcHub so they cannot
// drift apart.
app.MapScadaBridgeSecretsHub(app.Configuration);
app.MapStaticAssets();
app.MapCentralUI<ZB.MOM.WW.ScadaBridge.Host.Components.App>();
app.MapInboundAPI();