fix(host): LocalDb sync endpoint opts out of the cookie fallback via an empty-prefix anon group

MapZbLocalDbSync returns IEndpointRouteBuilder, not an IEndpointConventionBuilder,
so .AllowAnonymous() cannot be chained onto it the way the other four mesh maps
are (T2). app.MapGroup(string.Empty).AllowAnonymous().MapZbLocalDbSync() carries
the same convention while leaving the gRPC route pattern byte-identical -
verified directly (LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical).

The census's RealMethodEndpoints filter had to move off a "route pattern starts
with /" heuristic to an exact-route-pattern-set match: inside the empty-prefix
group, grpc-dotnet's own "Unimplemented service"/"Unimplemented method" fallback
endpoints also gain a leading "/", so the heuristic let them back in and
over-counted (caught by DriverOnly_WithASyncPort_AlsoMapsTheLocalDbSyncEndpoint
and LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical going red
against the naive filter before this fix).

All five mesh gRPC endpoints now carry IAllowAnonymous (archreview 03/S14, T3).
This commit is contained in:
Joseph Doherty
2026-08-20 09:51:33 -04:00
parent 2f90156408
commit e3cf363703
2 changed files with 39 additions and 17 deletions
@@ -64,9 +64,16 @@ public static class MeshGrpcEndpoints
// default-OFF or admin-only graph carries no sync surface whatsoever. Mapping it would be
// safe even unauthenticated — LocalDbSyncAuthInterceptor fail-closes with no ApiKey
// configured — but not mapping it at all is the stronger default.
//
// AllowAnonymous (03/S14) via an empty-prefix MapGroup: MapZbLocalDbSync returns
// IEndpointRouteBuilder, NOT an IEndpointConventionBuilder, so .AllowAnonymous() cannot be
// chained onto it the way the other four maps below are. An empty-prefix group carries the
// AllowAnonymous convention to whatever it maps while leaving the gRPC route pattern
// byte-identical to the ungrouped map (verified by
// LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical).
if (hasDriver && syncListenPort > 0)
{
app.MapZbLocalDbSync();
app.MapGroup(string.Empty).AllowAnonymous().MapZbLocalDbSync();
}
// Central config-serve artifact endpoint (per-cluster mesh Phase 3). Mapped only on
@@ -26,11 +26,13 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <para>
/// <b>Filters out grpc-dotnet's own catch-all infrastructure.</b> Every <c>MapGrpcService</c>
/// call also registers a per-service "Unimplemented method for X" fallback, and the FIRST
/// such call in an app additionally registers one shared "Unimplemented service" catch-all —
/// neither carries a route pattern beginning with <c>/</c>, and neither ever reaches real
/// logic (they always answer <c>Unimplemented</c> regardless of caller identity), so their
/// authorization metadata carries no security meaning. <see cref="RealMethodEndpoints"/>
/// keeps only the five real, callable RPC methods.
/// such call in an app additionally registers one shared "Unimplemented service" catch-all.
/// Neither ever reaches real logic (they always answer <c>Unimplemented</c> regardless of
/// caller identity), so their authorization metadata carries no security meaning.
/// <see cref="RealMethodEndpoints"/> keeps only the five real, callable RPC methods, matched
/// against the exact known route-pattern set rather than a route-pattern-shape heuristic —
/// see that method's remarks for why a shape-based filter is NOT safe once LocalDbSync's
/// empty-prefix <c>MapGroup</c> (T3) is in play.
/// </para>
/// <para>
/// <b>Guard-of-the-guard.</b> Every assertion below pins an EXACT expected count and the
@@ -140,19 +142,30 @@ public sealed class MeshGrpcAnonymousMetadataTests
}
[Fact]
public void Fused_AllFourGrpcServiceMapsCarryAllowAnonymous_LocalDbSyncIsNotYetOwnedHere()
public void Fused_AllFiveMeshEndpoints_CarryAllowAnonymous()
{
// 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.
// Includes LocalDbSync (T3): MapZbLocalDbSync returns IEndpointRouteBuilder, not an
// IEndpointConventionBuilder, so .AllowAnonymous() cannot be chained onto it the way the
// other four are — it needs the empty-prefix MapGroup wrapper instead.
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: true, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056));
AnonymousPatternsOf(endpoints).ShouldBe(
[ArtifactFetch, CentralReportApply, TelemetrySubscribe, NodeExecute], ignoreOrder: true);
AnonymousPatternsOf(endpoints).ShouldBe(AllFivePrefixes, ignoreOrder: true);
}
[Fact]
public void LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical()
{
// The empty-prefix MapGroup wrapper (T3) must be pure convention-carrying — it must not
// change the gRPC route pattern LocalDbSyncAuthInterceptor and the sync client both key on.
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 0, telemetryListenPort: 0));
var sync = endpoints.ShouldHaveSingleItem();
sync.RoutePattern.RawText.ShouldBe(LocalDbSyncSync);
sync.Metadata.GetMetadata<IAllowAnonymous>().ShouldNotBeNull();
}
[Fact]
@@ -205,14 +218,16 @@ public sealed class MeshGrpcAnonymousMetadataTests
/// <summary>
/// The real, callable RPC method endpoints — excludes grpc-dotnet's per-app "Unimplemented
/// service" catch-all and per-service "Unimplemented method for X" fallback (see class
/// remarks). Both carry a route pattern that never starts with <c>/</c>, which is what this
/// filters on.
/// remarks). Filtered against the exact set of known method route patterns rather than a
/// "starts with /" heuristic: inside the empty-prefix <c>MapGroup</c> the LocalDbSync map
/// (T3) uses, grpc-dotnet's own fallback patterns ALSO gain a leading <c>/</c>, so a
/// prefix-shaped filter would let them back in.
/// </summary>
private static List<RouteEndpoint> RealMethodEndpoints(WebApplication app) =>
((IEndpointRouteBuilder)app).DataSources
.SelectMany(d => d.Endpoints)
.OfType<RouteEndpoint>()
.Where(e => e.RoutePattern.RawText?.StartsWith('/') == true)
.Where(e => AllFivePrefixes.Contains(e.RoutePattern.RawText))
.ToList();
private static string RoutePatternOf(RouteEndpoint e) => e.RoutePattern.RawText!;