From a222421374a251e7bbf5265b3328f3821877ca59 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 20 Aug 2026 10:27:40 -0400 Subject: [PATCH] docs: mesh gRPC ingress is AllowAnonymous; the PSK interceptors are the gate (R3-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New docs/security.md section "Mesh gRPC ingress is AllowAnonymous by design" (archreview 03/S14): what the cookie FallbackPolicy covers, why it can block a correctly-PSK'd call before the interceptor's verdict matters, why the AllowAnonymous opt-out is safe (all five interceptors fail-closed), the affected-endpoint table (fused-vs-driver-only distinction), and the two standing guards (MeshGrpcAnonymousMetadataTests, MeshGrpcMapConventionTests) plus the auth-stack/driver-only boundary tests. One cross-reference line each in docs/Configuration.md (§MeshTransport, §ConfigSource/ConfigServe) and docs/Telemetry.md, so an operator reading about the listener ports meets the constraint where they'd look for it. --- docs/Configuration.md | 4 +++ docs/Telemetry.md | 8 +++++ docs/security.md | 73 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/docs/Configuration.md b/docs/Configuration.md index 2bccc843..2adcc05f 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -190,6 +190,8 @@ On the docker-dev rig every node carries these keys already; the rig's compose d See the [ClusterClient→gRPC migration design](plans/2026-08-06-clusterclient-to-grpc-design.md), [implementation plan](plans/2026-08-06-clusterclient-to-grpc.md), [tracking doc](plans/2026-08-06-clusterclient-to-grpc-tracking.md) and [live gate](plans/2026-08-06-clusterclient-to-grpc-live-gate.md) for the migration's phase-by-phase history — those are historical records of the cutover and are not updated further by this sweep. +**On an admin-role node, `NodeCommandService` and `CentralCommandService` are `AllowAnonymous` by design** — the AdminUI cookie `FallbackPolicy` would otherwise 401/redirect a correctly-PSK'd call before the interceptor above ever ran (archreview 03/S14). See [`security.md` § Mesh gRPC ingress is `AllowAnonymous` by design](security.md#mesh-grpc-ingress-is-allowanonymous-by-design). + ### `ConfigSource` / `ConfigServe` (config fetch-and-cache) - **Purpose:** [per-cluster mesh](plans/2026-07-21-per-cluster-mesh-design.md) **Phase 3**. `ConfigSource:Mode` selects where a **driver** node reads its deployed configuration: `Direct` (read `Deployment.ArtifactBlob` from central SQL, today's behaviour) or `FetchAndCache` (fetch the artifact bytes from central over a gRPC stream, verify SHA-256, cache in LocalDb, and read **only** the cache — no central-SQL config read). `ConfigServe` is the **central** (admin-role) serve side that a `FetchAndCache` node dials. @@ -217,6 +219,8 @@ See the [ClusterClient→gRPC migration design](plans/2026-08-06-clusterclient-t **`RevisionHash == SHA-256(artifact bytes)` is load-bearing.** `ConfigComposer` computes the deployment's revision hash as the lowercase-hex SHA-256 of the exact artifact bytes, so the node verifies a fetch against the `RevisionHash` the dispatch already carries — no hash rides in the proto. If a future change makes the revision hash anything else, every fetch fails verification. +**`DeploymentArtifactService` is `AllowAnonymous` by design** — same reasoning as `NodeCommandService`/`CentralCommandService` above: the AdminUI cookie `FallbackPolicy` on an admin-role node would otherwise block a correctly-keyed fetch before `ConfigServeAuthInterceptor`'s verdict matters. See [`security.md` § Mesh gRPC ingress is `AllowAnonymous` by design](security.md#mesh-grpc-ingress-is-allowanonymous-by-design). + #### Artifact compression (2026-08-15) The artifact blob is **gzip-compressed, inside the hashed bytes**. `ConfigComposer.Pack` gzips the diff --git a/docs/Telemetry.md b/docs/Telemetry.md index d1364b46..5caae91a 100644 --- a/docs/Telemetry.md +++ b/docs/Telemetry.md @@ -190,6 +190,14 @@ transports. ScadaBridge itself has since closed that gap with this identical interceptor pattern, so Phase 5 ships authenticated from the start rather than deferring auth to a later hardening pass. See the design doc's superseded note for the full history. +**On a fused `admin,driver` node, `TelemetryStreamService` and `NodeCommandService` are `AllowAnonymous` +by design** — a *different* gate from the interceptor above. `TelemetryStreamAuthInterceptor` is the PSK +check; on an admin-role node the AdminUI cookie `FallbackPolicy` sits in front of it and would 401/redirect +a correctly-keyed call before the interceptor's verdict matters, because it runs in the authorization +middleware, strictly *before* the interceptor executes (archreview 03/S14). A driver-only node never +registers that middleware at all, so it is structurally unaffected either way. See +[`security.md` § Mesh gRPC ingress is `AllowAnonymous` by design](security.md#mesh-grpc-ingress-is-allowanonymous-by-design). + ## Upgrading an existing deployment: populate `ClusterNode.GrpcPort` first (#493) `GrpcPort` is a **nullable column added by Phase 5**, and nothing backfills it onto rows that diff --git a/docs/security.md b/docs/security.md index 6872fbaf..bd44294b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -846,6 +846,79 @@ Responses: `202 Accepted` (`{ outcome, deploymentId, revisionHash }`) when a dep --- +## Mesh gRPC ingress is `AllowAnonymous` by design + +Archreview 03/S14 (Critical, closed R3-01). On an admin-role node `AddOtOpcUaAuth` installs a global +`FallbackPolicy = RequireAuthenticatedUser` bound to the cookie scheme (the same fallback that protects +every AdminUI page above). ASP.NET Core's authorization middleware applies that fallback to **every +endpoint that carries no authorization metadata of its own** — gRPC endpoints included. The five mesh +gRPC endpoints (config-serve artifact fetch, central command ack, telemetry stream, node command, LocalDb +sync) authenticate with a per-cluster/per-node **PSK bearer inside a gRPC interceptor**, and that +interceptor runs *inside* the endpoint invocation — i.e. strictly *after* the authorization middleware. +Without an explicit `.AllowAnonymous()` opt-out, a correctly-keyed node call is challenged by the cookie +handler before the interceptor's verdict ever matters: on a login-enabled admin node this is a fleet-wide +deploy-plane outage (artifact fetches fail, deploy acks are dropped, deployments seal `TimedOut`), and it +went unnoticed for seven phases of prior live gates because the docker-dev rig always ran +`Security__Auth__DisableLogin=true` (an always-succeeding handler registered *under the cookie scheme +name*, so the fallback trivially passes) and every prior mesh-gRPC test built its own auth-free pipeline +by hand. + +**Why `AllowAnonymous` is the correct fix, not a hole.** All five interceptors are fail-closed: an unset +key rejects every call, and the presented token is compared with `CryptographicOperations.FixedTimeEquals`. +`Program.cs:574` states the design contract directly — "the interceptor is the ONLY inbound auth on any +endpoint (the libraries verify nothing)" — which was already true of the gRPC pipeline; the missing opt-out +made it false of the HTTP pipeline in front of it. The pattern itself predates this fix: static assets +(`MapStaticAssets().AllowAnonymous()`), the metrics endpoint (`MapZbMetrics().AllowAnonymous()`), and the +headless deploy API above are all `AllowAnonymous` for the identical reason — each is gated by something +other than the cookie, and the fallback would otherwise 401/redirect it regardless. + +| Endpoint | Role gate | Affected without `AllowAnonymous`, on a login-enabled node | +|---|---|---| +| `DeploymentArtifactService.Fetch` | `hasAdmin && ConfigServe:GrpcListenPort > 0` | Always — every `FetchAndCache` node's artifact fetch. | +| `CentralCommandService.ReportApply` | same | Always — every node's deploy ack, from every cluster. | +| `TelemetryStreamService.Subscribe` | `hasDriver && Telemetry:GrpcListenPort > 0` | Only on a **fused** `admin,driver` node (e.g. docker-dev's central pair) — a driver-only node never registers the cookie stack at all, so it is structurally unaffected either way. | +| `NodeCommandService.Execute` | same | Only on a fused node, same reasoning. | +| `localdb_sync.v1.LocalDbSync/Sync` | `hasDriver && LocalDb:SyncListenPort > 0` | Only on a fused node that also sets a sync port — no node in the fleet currently has that shape, but the map is anonymised anyway. | + +**A fused central pair is affected on its OWN dispatch, not only inbound from sites.** `CentralCommunicationActor` dials every enabled `ClusterNode` row, its own pair included, and central's telemetry dialer dials its own pair too — so a login-enabled fused central breaks its own command/telemetry plane, not just the sites'. + +**Mapping mechanics.** All five maps live in one place, `MeshGrpcEndpoints.MapOtOpcUaMeshGrpcServices` +(`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshGrpcEndpoints.cs`) — `Program.cs` calls it once and +carries no map sites of its own. Four maps chain `.AllowAnonymous()` directly onto `MapGrpcService()`. +The fifth, `MapZbLocalDbSync()`, returns `IEndpointRouteBuilder` rather than an +`IEndpointConventionBuilder`, so it cannot take the same chain; it is wrapped in an empty-prefix +`app.MapGroup(string.Empty).AllowAnonymous().MapZbLocalDbSync()`, which carries the convention without +changing the wire route pattern. + +**Two standing guards**, both in `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/`, so a future map +cannot silently reopen this: + +- `MeshGrpcAnonymousMetadataTests` — an endpoint-metadata census that calls the SAME + `MapOtOpcUaMeshGrpcServices` production helper across all three role shapes (admin-only, driver-only, + fused) and asserts every mesh endpoint carries `IAllowAnonymous`, with an exact expected count per shape + and a scope check that the opt-out never leaks onto an unrelated endpoint. +- `MeshGrpcMapConventionTests` — a `Program.cs` source scan asserting zero direct `MapGrpcService