diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshGrpcEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshGrpcEndpoints.cs index c99c1194..c12e77f3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshGrpcEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshGrpcEndpoints.cs @@ -10,11 +10,23 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; /// /// /// +/// Why AllowAnonymous (archreview 03/S14). On an admin-role node +/// AddOtOpcUaAuth installs a global FallbackPolicy = RequireAuthenticatedUser +/// under the cookie scheme, which the authorization middleware applies to EVERY endpoint +/// without explicit authorization metadata — gRPC included. These endpoints authenticate +/// with a per-cluster PSK bearer inside a gRPC interceptor, which runs INSIDE the endpoint +/// invocation, i.e. after that middleware. Without this opt-out a correctly-keyed node call +/// is challenged by the cookie handler before the interceptor's verdict matters: artifact +/// fetches fail, deploy acks are dropped, deployments seal TimedOut. Anonymous is safe here +/// because all five interceptors fail CLOSED — an unset key rejects every call and the +/// presented token is compared with FixedTimeEquals. +/// +/// /// Do not "tidy" this into Program.cs. A standing census guard /// (MeshGrpcAnonymousMetadataTests) drives THIS method, so a map that moves back /// inline leaves the guard asserting a shape production no longer has — which is exactly /// how the AdminUI cookie FallbackPolicy silently blocked every mesh gRPC call on a -/// login-enabled admin node for seven phases of live gates (archreview 03/S14). +/// login-enabled admin node for seven phases of live gates. /// /// public static class MeshGrpcEndpoints @@ -63,7 +75,11 @@ public static class MeshGrpcEndpoints // with no ConfigServe:ApiKey. if (hasAdmin && configServeGrpcPort > 0) { - app.MapGrpcService(); + // AllowAnonymous (03/S14): this is every FetchAndCache node's artifact fetch. Without the + // opt-out the AdminUI cookie FallbackPolicy 401s/redirects it before + // ConfigServeAuthInterceptor's verdict matters — a fleet-wide deploy-plane outage on any + // login-enabled admin node. + app.MapGrpcService().AllowAnonymous(); // Central command ingress (mesh gRPC command plane): the node→central deployment ack. It // rides the SAME listener rather than claiming a port of its own — a second cleartext h2c @@ -73,8 +89,9 @@ public static class MeshGrpcEndpoints // mode on a running fleet requires a central restart before any node can ack — the same // reasoning that registers CentralCommunicationActor in both modes. // CentralCommandAuthInterceptor (added at AddGrpc in Program.cs) is the ONLY inbound auth - // and fail-closes per cluster. - app.MapGrpcService(); + // and fail-closes per cluster. AllowAnonymous (03/S14): this is every node's deploy ack, + // from every cluster — same reasoning as the artifact fetch above. + app.MapGrpcService().AllowAnonymous(); } // Driver-node live-telemetry stream (per-cluster mesh Phase 5). Central dials in; gated by @@ -90,8 +107,12 @@ public static class MeshGrpcEndpoints // closed endpoint, not an open one. if (hasDriver && telemetryListenPort > 0) { - app.MapGrpcService(); - app.MapGrpcService(); + // AllowAnonymous (03/S14) on both: on a fused admin+driver node (the docker-dev central + // pair, and any production fused central) these sit behind the SAME cookie FallbackPolicy + // as the ConfigServe endpoints above and would be 401/redirected identically without the + // opt-out — central's own telemetry dialer and node-command dispatch to its own pair. + app.MapGrpcService().AllowAnonymous(); + app.MapGrpcService().AllowAnonymous(); } } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAnonymousMetadataTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAnonymousMetadataTests.cs index 1c734779..bdaf71d7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAnonymousMetadataTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAnonymousMetadataTests.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -112,6 +113,74 @@ public sealed class MeshGrpcAnonymousMetadataTests endpoints.ShouldBeEmpty(); } + // --------------------------------------------------------------------------------------- + // 03/S14 — every mesh endpoint carries IAllowAnonymous, so the AdminUI cookie FallbackPolicy + // (RequireAuthenticatedUser) never reaches it; the PSK interceptors are the ONLY gate. T3 + // (LocalDbSync's empty-prefix group) is asserted separately below. + // --------------------------------------------------------------------------------------- + + [Fact] + public void AdminOnly_BothConfigServeEndpoints_CarryAllowAnonymous() + { + var endpoints = RealMethodEndpoints(BuildApp( + hasAdmin: true, hasDriver: false, + syncListenPort: 0, configServeGrpcPort: 4055, telemetryListenPort: 0)); + + AnonymousPatternsOf(endpoints).ShouldBe([ArtifactFetch, CentralReportApply], ignoreOrder: true); + } + + [Fact] + public void DriverOnly_BothTelemetryAndNodeCommandEndpoints_CarryAllowAnonymous() + { + var endpoints = RealMethodEndpoints(BuildApp( + hasAdmin: false, hasDriver: true, + syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 4056)); + + AnonymousPatternsOf(endpoints).ShouldBe([TelemetrySubscribe, NodeExecute], ignoreOrder: true); + } + + [Fact] + public void Fused_AllFourGrpcServiceMapsCarryAllowAnonymous_LocalDbSyncIsNotYetOwnedHere() + { + // T3 owns the LocalDbSync map (MapZbLocalDbSync returns IEndpointRouteBuilder, not + // IEndpointConventionBuilder, so .AllowAnonymous() cannot be chained onto it the same way). + // Asserting its ABSENCE here — rather than skipping it — is what makes this test go red the + // moment T3's fix lands with the wrong scope (e.g. anonymising nothing, or the group leaking + // beyond LocalDbSync) instead of silently starting to pass for the wrong reason. + var endpoints = RealMethodEndpoints(BuildApp( + hasAdmin: true, hasDriver: true, + syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056)); + + AnonymousPatternsOf(endpoints).ShouldBe( + [ArtifactFetch, CentralReportApply, TelemetrySubscribe, NodeExecute], ignoreOrder: true); + } + + [Fact] + public void AllowAnonymous_NeverLeaksBeyondTheFiveKnownMeshEndpoints() + { + // Scope guard (theme 2): an endpoint mapped ALONGSIDE the mesh services, through no seam this + // extension touches, must never come out anonymous. If it did, some future edit widened the + // AllowAnonymous convention beyond the five known prefixes — the exact failure class the + // fallback-policy opt-out exists to bound. + var builder = WebApplication.CreateBuilder(); + builder.Environment.ApplicationName = typeof(MeshGrpcAnonymousMetadataTests).Assembly.GetName().Name!; + builder.Services.AddGrpc(); + var app = builder.Build(); + + app.MapOtOpcUaMeshGrpcServices( + hasAdmin: true, hasDriver: true, + syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056); + var sentinel = app.MapGet("/sentinel", () => "ok"); + + var sentinelEndpoint = ((IEndpointRouteBuilder)app).DataSources + .SelectMany(d => d.Endpoints) + .OfType() + .Single(e => e.RoutePattern.RawText == "/sentinel"); + + sentinelEndpoint.Metadata.GetMetadata().ShouldBeNull(); + _ = sentinel; // the builder handle itself is not the assertion target + } + // --------------------------------------------------------------------------------------- // harness // --------------------------------------------------------------------------------------- @@ -147,4 +216,11 @@ public sealed class MeshGrpcAnonymousMetadataTests .ToList(); private static string RoutePatternOf(RouteEndpoint e) => e.RoutePattern.RawText!; + + /// Route patterns of the endpoints in that carry . + private static List AnonymousPatternsOf(IEnumerable endpoints) => + endpoints + .Where(e => e.Metadata.GetMetadata() is not null) + .Select(RoutePatternOf) + .ToList(); }