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();
}