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,26 +1,72 @@
using Grpc.Core;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.GalaxyRepository.Grpc;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// <summary>Scopes Galaxy browse results to the calling API key's BrowseSubtrees constraint.</summary>
public sealed class GatewayBrowseScopeProvider(IGatewayRequestIdentityAccessor identityAccessor)
/// <remarks>
/// Distinguishes two states the pre-GWC-38 code collapsed into one: "authenticated, no
/// BrowseSubtrees constraint" (legitimately unscoped — the common case) and "no ambient identity
/// at all" (the interceptor-ordering invariant below is broken). The second used to resolve to the
/// full hierarchy silently, which made a scoping bypass invisible; it now denies loudly.
/// </remarks>
/// <param name="identityAccessor">Accessor for the ambient per-call API-key identity.</param>
/// <param name="options">Gateway options supplying <c>MxGateway:Authentication:Mode</c>.</param>
/// <param name="logger">Logger used to name the broken invariant when identity is absent.</param>
public sealed class GatewayBrowseScopeProvider(
IGatewayRequestIdentityAccessor identityAccessor,
IOptions<GatewayOptions> options,
ILogger<GatewayBrowseScopeProvider> logger)
: IGalaxyBrowseScopeProvider
{
/// <summary>Resolves the Galaxy browse subtrees the calling API key is scoped to, from the ambient request identity.</summary>
/// <param name="context">The gRPC server call context for the in-flight browse request.</param>
/// <returns>The caller's configured browse subtrees, or <see langword="null"/>/empty when the caller is unscoped (full hierarchy).</returns>
/// <exception cref="RpcException">
/// <see cref="StatusCode.PermissionDenied"/> when API-key authentication is enabled but no
/// ambient identity is present — the invariant described below has been broken and the
/// request's scope cannot be established.
/// </exception>
public IReadOnlyList<string>? ResolveBrowseSubtrees(ServerCallContext context)
{
// Invariant: the caller identity is the ambient one pushed by
// GatewayGrpcAuthorizationInterceptor earlier in this same call. That interceptor is
// registered globally and therefore runs before the library's galaxy service, so
// Current is populated for any authenticated galaxy RPC. If it is somehow absent we
// return empty BrowseSubtrees, which the library treats as "no scoping" (full
// hierarchy) — safe only because the global interceptor has already authenticated and
// authorized the request first. Do not reorder the interceptor without revisiting this.
ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
return constraints.BrowseSubtrees;
// Current is populated for any authenticated galaxy RPC. Do not reorder the interceptor
// without revisiting this.
ApiKeyIdentity? identity = identityAccessor.Current;
if (identity is not null)
{
return identity.EffectiveConstraints.BrowseSubtrees;
}
// Mode=Disabled is the one configuration in which identity-absent is expected rather than
// broken: nothing authenticates, so no key and no constraints exist to scope by, and the
// deployment has already accepted an unauthenticated gRPC surface. Empty BrowseSubtrees is
// what the library reads as "no scoping".
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
{
return ApiKeyConstraints.Empty.BrowseSubtrees;
}
// Authentication is on, so an identity MUST have been pushed before the galaxy service ran.
// Failing open here would hand a browse-scoped key the full hierarchy with nothing logged
// and nothing failing — a constraint-narrowing control's absence is invisible in normal
// operation. Deny instead, and name the invariant so the misconfiguration is diagnosable
// from one log line rather than from a scoping audit nobody thought to run.
logger.LogWarning(
"Galaxy browse scope could not be resolved for {Method}: no ambient API-key identity "
+ "while Authentication:Mode is {Mode}. GatewayGrpcAuthorizationInterceptor must run "
+ "before the Galaxy service on every call; the request is denied rather than served "
+ "unscoped.",
context.Method,
options.Value.Authentication.Mode);
throw new RpcException(new Status(
StatusCode.PermissionDenied,
"Browse scope could not be established for this request."));
}
}