fix(host): mesh gRPC endpoints opt out of the AdminUI cookie FallbackPolicy (archreview 03/S14)
.AllowAnonymous() on DeploymentArtifactGrpcService, CentralCommandGrpcService, TelemetryStreamGrpcService and NodeCommandGrpcService — the four MapGrpcService maps that are chainable. On a login-enabled admin node the global cookie FallbackPolicy (RequireAuthenticatedUser) applies to every endpoint without explicit authorization metadata, gRPC included; the PSK bearer interceptors run INSIDE the endpoint invocation, strictly after that middleware, so a correctly-keyed node call was challenged before the interceptor's verdict could matter. Safe because all four interceptors fail closed on an unset key. The census now asserts IAllowAnonymous on all four (red before this change, green after) plus a scope guard proving the opt-out never leaks onto an unrelated endpoint. MapZbLocalDbSync (T3) is not chainable the same way and is asserted absent here on purpose, so this test cannot start passing for the wrong reason once T3 lands.
This commit is contained in:
@@ -10,11 +10,23 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why AllowAnonymous (archreview 03/S14).</b> On an admin-role node
|
||||
/// <c>AddOtOpcUaAuth</c> installs a global <c>FallbackPolicy = RequireAuthenticatedUser</c>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Do not "tidy" this into Program.cs.</b> A standing census guard
|
||||
/// (<c>MeshGrpcAnonymousMetadataTests</c>) 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 <c>FallbackPolicy</c> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MeshGrpcEndpoints
|
||||
@@ -63,7 +75,11 @@ public static class MeshGrpcEndpoints
|
||||
// with no ConfigServe:ApiKey.
|
||||
if (hasAdmin && configServeGrpcPort > 0)
|
||||
{
|
||||
app.MapGrpcService<DeploymentArtifactGrpcService>();
|
||||
// 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<DeploymentArtifactGrpcService>().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<CentralCommandGrpcService>();
|
||||
// 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<CentralCommandGrpcService>().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<TelemetryStreamGrpcService>();
|
||||
app.MapGrpcService<NodeCommandGrpcService>();
|
||||
// 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<TelemetryStreamGrpcService>().AllowAnonymous();
|
||||
app.MapGrpcService<NodeCommandGrpcService>().AllowAnonymous();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+76
@@ -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<RouteEndpoint>()
|
||||
.Single(e => e.RoutePattern.RawText == "/sentinel");
|
||||
|
||||
sentinelEndpoint.Metadata.GetMetadata<IAllowAnonymous>().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!;
|
||||
|
||||
/// <summary>Route patterns of the endpoints in <paramref name="endpoints"/> that carry <see cref="IAllowAnonymous"/>.</summary>
|
||||
private static List<string> AnonymousPatternsOf(IEnumerable<RouteEndpoint> endpoints) =>
|
||||
endpoints
|
||||
.Where(e => e.Metadata.GetMetadata<IAllowAnonymous>() is not null)
|
||||
.Select(RoutePatternOf)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user