docs: mesh gRPC ingress is AllowAnonymous; the PSK interceptors are the gate (R3-01)

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.
This commit is contained in:
Joseph Doherty
2026-08-20 10:27:40 -04:00
parent 4dde5cb8bf
commit a222421374
3 changed files with 85 additions and 0 deletions
+73
View File
@@ -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<T>()`.
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</
MapZbLocalDbSync(` occurrences and exactly one call to the mapper, so a map added straight into
`Program.cs` (bypassing the helper, and therefore invisible to the census) fails the build.
- `MeshGrpcAuthStackTests` / `MeshGrpcDriverOnlyPipelineTests` drive the mesh endpoints through the REAL
`AddOtOpcUaAuth` + `UseAuthentication`/`UseAuthorization` pipeline (`DisableLogin=false`) and the
driver-only (no-auth-at-all) shape respectively — the CI-shaped reproduction of the outage, with a
correctly-keyed call **succeeding** as the load-bearing assertion (a blocked call and a refused call both
"fail"; only a genuine success through the real cookie stack proves the opt-out works) and an
un-anonymised negative control proving the fixture's auth stack is actually live.
**Rig:** `docker-dev/docker-compose.yml`'s central-1/central-2 `Security__Auth__DisableLogin` is
`${OTOPCUA_DISABLE_LOGIN:-true}` — default unchanged, but `OTOPCUA_DISABLE_LOGIN=false docker compose
... up -d central-1 central-2` drives the rig through the real cookie login (`alice`/`bob`, password
`password`, against the shared GLAuth) for exactly this kind of verification, instead of every prior mesh
live gate running with the one middleware that breaks production neutralized.
See `archreview/plans/R3-01-mesh-grpc-allowanonymous-plan.md` (R3-01) for the full verification and design
record, and `docs/Configuration.md` §`MeshTransport` / §`ConfigSource`-`ConfigServe` and `docs/Telemetry.md`
for the affected listener ports.
---
## OTOPCUA0001 Analyzer — Compile-Time Guard
Per-capability resilience (retry, timeout, circuit-breaker) is applied by `CapabilityInvoker` in `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/`. A driver-capability call made **outside** the invoker bypasses resilience entirely — which in production looks like inconsistent timeouts, un-wrapped retries, and unbounded blocking.