feat(grpc): PSK-authenticate the site gRPC control plane; drop the vestigial management receptionist registration
Phase 0 of the ClusterClient→gRPC migration
(docs/plans/2026-07-22-clusterclient-to-grpc-plan.md). Standalone hardening: it
closes a gap that exists today and is a precondition for moving command/control
onto gRPC in later phases.
T0.1 — delete the ManagementActor ClusterClientReceptionist registration.
It was built for an out-of-cluster CLI that was never written: the shipped CLI
speaks HTTP Basic to /management, which asks the actor in-process through
ManagementActorHolder. Nothing in the repo ever sent to /user/management. The
actor still runs there; only the cross-boundary advertisement is gone. Six
documents claimed the CLI used ClusterClient — including the CLI's own README
"Architecture Notes" — and are corrected here rather than left to rot.
T0.2 — record, do not port, the dead integration-routing path.
IntegrationCallRequest is unwired at BOTH ends: RouteIntegrationCallAsync has
zero callers anywhere, and RegisterLocalHandler(Integration, …) appears only in
a test, so production always answers "Integration handler not available". It is
excluded from the gRPC contract (28 of 29 commands migrate) rather than
enshrined on an additive-only wire format, and deleting it during a
transport migration would mix a behavioural change into a change whose whole
value is that behaviour is identical. See
docs/known-issues/2026-07-22-integration-call-routing-is-dead-code.md.
T0.3 — preshared-key authentication on SiteStreamService.
The service shipped with no auth at all: plaintext h2c, no interceptor, so
anything that could reach a site node's :8083 could open a live data stream or
read audit rows back via PullAuditEvents/PullSiteCalls. ControlPlaneAuthInterceptor
now gates /sitestream.SiteStreamService/ — modeled on LocalDbSyncAuthInterceptor
(constant-time compare, fail-closed, PermissionDenied) but gating a SET of
service prefixes so phases 1A/1B add services rather than interceptors. LocalDb
sync keeps its own separate key: it authenticates the pair partner, not central,
and collapsing the two would make a site's central-facing key also admit writes
into its database.
Keys are per site (SB-GRPC-PSK-<siteId>), never fleet-wide, so a compromised
site yields only its own. Central attaches them through ControlPlaneCredentials,
which binds CallCredentials to the channel — covering unary and streaming
uniformly, and letting the key resolve asynchronously, which a client
interceptor could not do without blocking. All three central→site channel
creation sites go through it (SiteStreamGrpcClient and both audit pull invokers);
the pull invokers' channel caches are re-keyed by (site, endpoint) because
credentials are per-site and bound to the channel.
Two decisions beyond the plan:
* StartupValidator now requires GrpcPsk on Site nodes. The plan specified only
the runtime gate, but fail-closed with no boot check produces a node that
joins, answers heartbeats and reports healthy while refusing every stream,
audit pull and telemetry ingest — silent and total. Same reasoning as the
existing inbound API-key pepper rule.
* Added Communication:SitePsks as a central-side key map. The plan assumed
central would read the store, seeded via a dev KEK; the docker rig
deliberately boots with no master key, so store-only resolution would leave
it unable to dial its own sites. The store stays primary — it is the only
source that can serve a site added at runtime — with the map covering
key-less hosts and one-off pins. Neither source falling back to
"unauthenticated" is the invariant.
T0.4 — dev keys on both rigs and tests.
34 tests. The seven that matter most exercise a real in-process gRPC stack over
TestServer: the unit tests on either side of the wire would both stay green if
the halves disagreed, and gRPC refuses call credentials on a plaintext channel
by default — the UnsafeUseInsecureChannelCallCredentials opt-in is only provable
by making a real call. They confirm correct key passes on unary AND streaming,
wrong key and no-credentials both get PermissionDenied, and an unresolvable key
fails the call with nothing reaching the service.
OPERATIONAL: a site node upgraded to this build without a key will not boot.
That includes the gitignored deploy/wonder-app-vd03/ overlay.
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
{
|
||||
"plan": "docs/plans/2026-07-22-clusterclient-to-grpc-plan.md",
|
||||
"design": "~/Desktop/scadaproj/scadabridge_clusterclient_to_grpc.md",
|
||||
"worktrees": {
|
||||
"feat/grpc-phase0-psk": "/Users/dohertj2/Desktop/ScadaBridge-phase0"
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T0.1",
|
||||
"phase": "0",
|
||||
"subject": "Delete the vestigial /user/management receptionist registration",
|
||||
"status": "completed",
|
||||
"activeForm": "Deleting the /user/management receptionist registration",
|
||||
"files": [
|
||||
"src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs",
|
||||
"docs/requirements/Component-Host.md",
|
||||
"docs/requirements/Component-ManagementService.md",
|
||||
"docs/requirements/Component-Communication.md",
|
||||
"docs/components/ManagementService.md",
|
||||
"docs/components/Communication.md",
|
||||
"src/ZB.MOM.WW.ScadaBridge.CLI/README.md"
|
||||
],
|
||||
"notes": "Actor stays; only the ClusterClientReceptionist.RegisterService call goes. Verified: CLI is Akka-free, so 6 docs claiming the CLI reaches ManagementActor over ClusterClient are stale and must be corrected in the same change."
|
||||
},
|
||||
{
|
||||
"id": "T0.2",
|
||||
"phase": "0",
|
||||
"subject": "Record the dead IntegrationCallRequest exclusion (28 of 29 commands migrate)",
|
||||
"status": "completed",
|
||||
"activeForm": "Recording the dead IntegrationCallRequest exclusion",
|
||||
"files": [
|
||||
"docs/known-issues/"
|
||||
],
|
||||
"notes": "Plan says 'file a Gitea issue' \u2014 outward-facing, handed to the user. In-repo half is a known-issues note + the exclusion record."
|
||||
},
|
||||
{
|
||||
"id": "T0.3",
|
||||
"phase": "0",
|
||||
"subject": "ControlPlaneAuthInterceptor + CommunicationOptions.GrpcPsk + SitePskProvider; gate SiteStream; attach PSK on central's clients",
|
||||
"status": "completed",
|
||||
"activeForm": "Building the control-plane PSK auth",
|
||||
"files": [
|
||||
"src/ZB.MOM.WW.ScadaBridge.Host/ControlPlaneAuthInterceptor.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/ISitePskProvider.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.Host/SitePskProvider.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClientFactory.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.Host/Program.cs"
|
||||
],
|
||||
"notes": "Service prefix verified from sitestream.proto: /sitestream.SiteStreamService/. Grpc 2.76 -> CallCredentials.FromInterceptor + UnsafeUseInsecureChannelCallCredentials is the async-safe attach path on h2c."
|
||||
},
|
||||
{
|
||||
"id": "T0.4",
|
||||
"phase": "0",
|
||||
"subject": "Rig dev keys (3 sites + central secret seeds) + interceptor/provider/wiring tests",
|
||||
"status": "completed",
|
||||
"activeForm": "Seeding rig dev keys and writing the auth tests",
|
||||
"files": [
|
||||
"docker/site-a-node-a/appsettings.Site.json",
|
||||
"docker/site-a-node-b/appsettings.Site.json",
|
||||
"docker/site-b-node-a/appsettings.Site.json",
|
||||
"docker/site-b-node-b/appsettings.Site.json",
|
||||
"docker/site-c-node-a/appsettings.Site.json",
|
||||
"docker/site-c-node-b/appsettings.Site.json",
|
||||
"tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthInterceptorTests.cs"
|
||||
],
|
||||
"notes": "Fail-closed from day one \u2014 every environment (incl. docker-env2 and the gitignored deploy/wonder-app-vd03 overlay) needs its key before upgrade. Ops item for the user."
|
||||
},
|
||||
{
|
||||
"id": "P0.DoD",
|
||||
"phase": "0",
|
||||
"subject": "Phase 0 DoD: suite green; rig unauthenticated => PermissionDenied, authenticated paths work; PR merged",
|
||||
"status": "in_progress",
|
||||
"activeForm": "Verifying the Phase 0 DoD",
|
||||
"blockedBy": [
|
||||
"T0.1",
|
||||
"T0.2",
|
||||
"T0.3",
|
||||
"T0.4"
|
||||
],
|
||||
"notes": "Rig half needs user approval (docker/deploy.sh recreates 8 containers)."
|
||||
},
|
||||
{
|
||||
"id": "T1A.1",
|
||||
"phase": "1A",
|
||||
"subject": "central_control.proto (7 RPCs, checked-in codegen) + CentralControlDtoMapper + round-trip golden tests",
|
||||
"status": "pending",
|
||||
"activeForm": "Authoring central_control.proto and its mappers",
|
||||
"blockedBy": [
|
||||
"P0.DoD"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1A.2",
|
||||
"phase": "1A",
|
||||
"subject": "Central hosting: AddGrpc + per-site-PSK interceptor, CentralGrpcPort h2c listener, CentralControlGrpcService, readiness gate",
|
||||
"status": "pending",
|
||||
"activeForm": "Hosting CentralControlService on central",
|
||||
"blockedBy": [
|
||||
"T1A.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1A.3",
|
||||
"phase": "1A",
|
||||
"subject": "ICentralTransport (Akka extract + Grpc impl), CentralChannelProvider, CentralTransport flag, CentralGrpcEndpoints option",
|
||||
"status": "pending",
|
||||
"activeForm": "Building the site->central transport seam",
|
||||
"blockedBy": [
|
||||
"T1A.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1A.4",
|
||||
"phase": "1A",
|
||||
"subject": "Tests: actor-with-fake-transport x7, TestServer transport tests, S&F/audit/health suites pass unmodified",
|
||||
"status": "pending",
|
||||
"activeForm": "Testing the central control plane",
|
||||
"blockedBy": [
|
||||
"T1A.2",
|
||||
"T1A.3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P1A.DoD",
|
||||
"phase": "1A",
|
||||
"subject": "1A DoD: rig site-a on Grpc proves all 5 site->central paths while site-b/c stay Akka; PR merged before 1B",
|
||||
"status": "pending",
|
||||
"activeForm": "Verifying the 1A DoD",
|
||||
"blockedBy": [
|
||||
"T1A.4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1B.1",
|
||||
"phase": "1B",
|
||||
"subject": "site_command.proto (6 oneof RPCs / 28 commands) + SiteCommandDtoMapper + round-trip golden tests",
|
||||
"status": "pending",
|
||||
"activeForm": "Authoring site_command.proto and its mappers",
|
||||
"blockedBy": [
|
||||
"P0.DoD"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1B.2",
|
||||
"phase": "1B",
|
||||
"subject": "SiteCommandDispatcher refactor (actor + SiteCommandGrpcService share it; parked stays node-local)",
|
||||
"status": "pending",
|
||||
"activeForm": "Extracting the site command dispatcher",
|
||||
"blockedBy": [
|
||||
"T1B.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1B.3",
|
||||
"phase": "1B",
|
||||
"subject": "ISiteCommandTransport in CentralCommunicationActor (Akka extract + Grpc impl), SitePairChannelProvider, SiteTransport flag",
|
||||
"status": "pending",
|
||||
"activeForm": "Building the central->site transport seam",
|
||||
"blockedBy": [
|
||||
"T1B.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1B.4",
|
||||
"phase": "1B",
|
||||
"subject": "Tests: dispatcher routing x28, actor envelope/reply plumbing, TestServer service tests, existing suites green",
|
||||
"status": "pending",
|
||||
"activeForm": "Testing the site command plane",
|
||||
"blockedBy": [
|
||||
"T1B.2",
|
||||
"T1B.3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P1B.DoD",
|
||||
"phase": "1B",
|
||||
"subject": "1B DoD: rig central on Grpc for site-a proves full command matrix incl. standby parked retry; rebased on 1A; PR merged",
|
||||
"status": "pending",
|
||||
"activeForm": "Verifying the 1B DoD",
|
||||
"blockedBy": [
|
||||
"T1B.4",
|
||||
"P1A.DoD"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P2",
|
||||
"phase": "2",
|
||||
"subject": "All sites CentralTransport=Grpc; central-kill S&F soak, failback observed, health sequences clean",
|
||||
"status": "pending",
|
||||
"activeForm": "Running the site->central cutover soak",
|
||||
"blockedBy": [
|
||||
"P1A.DoD"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P3",
|
||||
"phase": "3",
|
||||
"subject": "Central SiteTransport=Grpc all sites; full UI command matrix; site-kill mid-command clean; zero ClusterClient activity",
|
||||
"status": "pending",
|
||||
"activeForm": "Running the central->site cutover soak",
|
||||
"blockedBy": [
|
||||
"P1B.DoD"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P4.1",
|
||||
"phase": "4",
|
||||
"subject": "Flip both flag defaults to Grpc + soak; delete Akka transports, ClusterClient creation, DefaultSiteClientFactory, receptionist registrations, CentralContactPoints, then the flags",
|
||||
"status": "pending",
|
||||
"activeForm": "Deleting the ClusterClient transport",
|
||||
"blockedBy": [
|
||||
"P2",
|
||||
"P3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P4.2",
|
||||
"phase": "4",
|
||||
"subject": "Grep-gates pass + docs updated (grpc_streams.md, Component-Host.md, Component-StoreAndForward.md, known-issues cross-ref)",
|
||||
"status": "pending",
|
||||
"activeForm": "Running the deletion grep-gates and doc sweep",
|
||||
"blockedBy": [
|
||||
"P4.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P5",
|
||||
"phase": "5",
|
||||
"subject": "Live gate, 8 checks, recorded in docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md",
|
||||
"status": "pending",
|
||||
"activeForm": "Running the live gate",
|
||||
"blockedBy": [
|
||||
"P4.2"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user