fix(GWC-38): deny Galaxy browse when the ambient identity is absent under API-key auth

GatewayBrowseScopeProvider resolved `identityAccessor.Current?.EffectiveConstraints
?? ApiKeyConstraints.Empty`, so an absent ambient identity produced an empty
BrowseSubtrees list — which the shared GalaxyRepository library reads as "no
scoping". The control's fail state was therefore open: any future path reaching the
library's galaxy service without GatewayGrpcAuthorizationInterceptor having pushed
the identity would silently hand a browse-scoped key the full hierarchy, with
nothing failing and nothing logged. A constraint-narrowing control's absence is
invisible in normal operation, which is what made the honest "safe only because the
global interceptor runs first" comment insufficient on its own.

The provider now distinguishes the two states the old expression collapsed:

- authenticated key with no BrowseSubtrees constraint -> empty list, unscoped
  (unchanged, and now asserted in its own test rather than implied);
- no ambient identity with Authentication:Mode=ApiKey -> log a warning naming the
  broken ordering invariant and throw RpcException(PermissionDenied);
- no ambient identity with Authentication:Mode=Disabled -> unscoped, because
  nothing authenticates there so no key and no constraints exist to scope by.

The registration-order comment at GatewayApplication.cs now says the ordering is
still required but no longer silently trusted.

Tests: GatewayBrowseScopeProviderTests gains the PermissionDenied case and the
Disabled-mode case alongside the unconstrained-identity one.
GalaxyRepositoryHostWiringTests phase 1 now pushes an identity carrying no
BrowseSubtrees instead of pushing none — that, not an absent identity, is
production's unscoped case.

Docs: docs/Authorization.md's constraint section states the two-state distinction
and both fail behaviours under browse_subtrees.
This commit is contained in:
Joseph Doherty
2026-08-20 09:28:54 -04:00
parent f4919ba97c
commit b177636d7a
5 changed files with 169 additions and 22 deletions
@@ -1,5 +1,8 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.GalaxyRepository.Grpc;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
@@ -23,8 +26,11 @@ public sealed class GalaxyRepositoryHostWiringTests
{
/// <summary>
/// Verifies the full production authz-filtering chain end-to-end.
/// Phase 1: no identity pushed → <c>BrowseChildren</c> returns both children
/// (unconstrained). Phase 2: identity with <c>BrowseSubtrees = ["NonExistent"]</c>
/// Phase 1: an identity carrying no <c>BrowseSubtrees</c> pushed → <c>BrowseChildren</c>
/// returns both children (unconstrained). That, not an absent identity, is production's
/// unscoped case since GWC-38 — with <c>Authentication:Mode=ApiKey</c> an absent identity
/// now denies, which the isolated provider test pins.
/// Phase 2: identity with <c>BrowseSubtrees = ["NonExistent"]</c>
/// pushed via <see cref="GatewayRequestIdentityAccessor"/> → the real
/// <see cref="GatewayBrowseScopeProvider"/> threads the constraint into the lib
/// service → <c>BrowseChildren</c> returns empty children.
@@ -35,7 +41,16 @@ public sealed class GalaxyRepositoryHostWiringTests
{
// Arrange: construct the REAL host seam — not stubs.
GatewayRequestIdentityAccessor identityAccessor = new();
GatewayBrowseScopeProvider scopeProvider = new(identityAccessor);
GatewayBrowseScopeProvider scopeProvider = new(
identityAccessor,
Options.Create(new GatewayOptions
{
Authentication = new Server.Configuration.AuthenticationOptions
{
Mode = AuthenticationMode.ApiKey,
},
}),
NullLogger<GatewayBrowseScopeProvider>.Instance);
GalaxyObject[] objects = CreateFilterObjects();
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
@@ -55,13 +70,23 @@ public sealed class GalaxyRepositoryHostWiringTests
new GalaxyDeployNotifier(),
scopeProvider);
// Phase 1: no identity on the accessor → browse is unconstrained.
// Pump_001 and Valve_001 are both children of Line3 (GobjectId = 2).
BrowseChildrenReply unconstrained = await service.BrowseChildren(
new BrowseChildrenRequest { ParentGobjectId = 2 },
new TestServerCallContext());
// Phase 1: an authenticated identity with no BrowseSubtrees constraint → browse is
// unconstrained. Pump_001 and Valve_001 are both children of Line3 (GobjectId = 2).
ApiKeyIdentity unscopedIdentity = new(
KeyId: "test-key",
KeyPrefix: "mxgw_test",
DisplayName: "unscoped",
Scopes: new HashSet<string>(StringComparer.Ordinal) { GatewayScopes.MetadataRead },
Constraints: ApiKeyConstraints.Empty);
Assert.Equal(2, unconstrained.Children.Count);
using (identityAccessor.Push(unscopedIdentity))
{
BrowseChildrenReply unconstrained = await service.BrowseChildren(
new BrowseChildrenRequest { ParentGobjectId = 2 },
new TestServerCallContext());
Assert.Equal(2, unconstrained.Children.Count);
}
// Phase 2: push a constraint whose BrowseSubtrees do not match any seeded object.
// GatewayBrowseScopeProvider.ResolveBrowseSubtrees must return ["NonExistent"],