Merge branch 'fix/r3-01-mesh-grpc-allowanonymous' — R3-01: mesh gRPC + LocalDb sync endpoints opt out of the AdminUI cookie FallbackPolicy (PSK interceptors are the gate); real-auth-stack boundary tests + map-convention guard; rig DisableLogin parameterised (archreview 03/S14 Critical)
This commit is contained in:
@@ -908,6 +908,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.
|
||||
|
||||
**Three standing guards**, all 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 `MeshGrpcMapConventionTests`.
|
||||
- `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.
|
||||
|
||||
Reference in New Issue
Block a user