feat(grpc): Phase 0 — PSK-authenticate the site gRPC control plane #25
Reference in New Issue
Block a user
Delete Branch "feat/grpc-phase0-psk"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Phase 0 of the ClusterClient→gRPC migration (
docs/plans/2026-07-22-clusterclient-to-grpc-plan.md). Prerequisite work only — no traffic moves off ClusterClient in this PR.What changed
SiteStreamServiceshipped unauthenticated: plaintext h2c, no interceptor, so anything that could reach a site node's :8083 could open a live data stream or callPullAuditEvents/PullSiteCallsand read audit rows back. NewControlPlaneAuthInterceptorgates it, fail-closed.SB-GRPC-PSK-{siteId}), so one leaked key is one site. Central resolves them at channel-build time viaISitePskProvider(its site set is dynamic); sites resolve their own key pre-host through the existing${secret:}expander./user/managementreceptionist registration. Zero callers; in-process access goes throughManagementActorHolder.IntegrationCallRequestpath indocs/known-issues/, with a wire-or-delete decision due before Phase 4.Verification
docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md. Baseline captured first, proving an unauthenticatedPullAuditEventsreturned{}before the change. After: unauthenticated →PermissionDeniedon all 3 sites; site-b's key against site-a →PermissionDenied(per-site scoping is real); correct key → success; 14 successful central→site pulls with 0 auth failures; LocalDb sync unaffected.mainand root-caused in the gate doc; this PR touches no EF/CentralUI/Transport/ManagementService file. One of them is a real production bug (Transport bundle import is broken against real MS SQL) worth its own issue.Read this before Phase 1A/1B
The first cut of this gate was inert and the green suite did not notice.
ControlPlaneAuthInterceptorhad two public constructors;Grpc.AspNetCore'sInterceptorRegistration.GetFactory()throws on that, per call, inside the pipeline. Nothing failed at startup — the node booted, joined and reported healthy — and every gated call died withUnknown / "Exception was thrown by handler", which reads as a handler bug. The tell was that correct key, wrong key and no key produced identical errors.The E2E test missed it because it pre-registered the interceptor with
AddSingleton, so DI handed back the instance and gRPC's activation path — the thing that threw — never ran.Phases 1A and 1B both add services to this same interceptor: extend
DefaultGatedPrefixes, do not add a second public constructor. The 3-arg ctor isinternalnow and a reflection test pins "exactly one public constructor".Deploy note — breaking
A site node on this build will not boot without a key (
StartupValidator, deliberate: "no key" must never degrade to "no auth"). The gitignoreddeploy/wonder-app-vd03/overlay needsScadaBridge:Communication:GrpcPskadded before its next deploy.docker-env2has its key in config but was not redeployed or gated.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.Phase 0's gate doc now carries the full suite picture, not just the rig checks. Playwright: 170 pass / 2 fail / 1 skip of 173. Both failures were run down to root cause and both are pre-existing on main, unrelated to this branch (which touches no EF, CentralUI, Transport or ManagementService file): - TransportImportTests is a REAL production bug: BundleImporter.cs:1298 opens a user-initiated transaction while the central context has EnableRetryOnFailure, so SqlServerRetryingExecutionStrategy refuses the split query inside it and bundle import fails against real MS SQL. The unit/integration suite cannot see it -- the in-memory EF provider has no retrying strategy and BeginTransaction is a no-op there. - SmsNotificationE2ETests is a stale fixture: SID 'ACtest123' (2026-06-19) vs the ^AC[0-9a-fA-F]{32}$ guard added 2026-07-10 (40088a21). Failing since then, which has also silenced everything after the toast assertion -- including the secret-non-leak check on the Auth Token. Also records that the earlier 44-failure run is void: a concurrent deploy.sh was recreating the cluster underneath it. Neither is fixed here; both are out of scope for a PSK-auth branch.Landed on
mainby fast-forward at3fa95555(no merge commit), so Gitea did not flip this to merged automatically. Closing — the four commits2ee84af1..3fa95555are onmainand pushed.Pull request closed