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:
Joseph Doherty
2026-08-20 09:46:30 -04:00
parent ac9a71bcbd
commit b05ebd6353
2 changed files with 103 additions and 6 deletions
@@ -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();
}