Two defects found in code review of the limiter rework. Probe admission was check-then-act across two lock scopes: Check() read "probe due" under lock(state), released it, then re-acquired to advance NextProbeAtTicks. A burst of requests arriving together at an interval boundary could therefore all observe the slot as due and all be admitted, handing the verifier the very burst the interval exists to bound. The claim is now a single critical section (TryConsumeProbe). The two layers are still claimed one at a time — holding two per-state locks at once would need a global lock ordering to stay deadlock-free — so a slot claimed on the composite partition is compensated via ReleaseProbe when the aggregate then refuses, which otherwise silently spent the partition's next slot and pushed the legitimate holder out by a full interval. Reset() removed whatever partition the caller resolved to, including the address's shared fallback partition when the caller's key id had been collapsed into it by the per-peer cap (or when the token was junk-shaped). That bucket also carries failures contributed by other key ids from the same address, so one successful authentication became a reset button for an in-progress spray. Reset now clears only a partition the caller owns (effectiveKeyId == presented key id); the shared bucket decays by window expiry instead, and the caller still recovers through probe admission. The key's aggregate is cleared either way, as designed. Also applied from the review: closure-free GetOrAdd overload on _partitions, and a remarks paragraph acknowledging the best-effort O(n) eviction scan under sustained overflow. Threading the resolved partition key from Check through to RecordFailure/Reset was declined: Check resolves with mint:false and RecordFailure with mint:true, and the two can legitimately differ when a concurrent caller fills the per-peer cap in between — reusing Check's key would record into the wrong partition and bypass the cap, which is not worth saving one string concat. Tests (limiter suite 11 -> 14): ProbeAdmission_UnderConcurrentArrivals_ GrantsExactlyOneSlot (200 rounds x 8 barrier-released threads at the boundary), ProbeAdmission_WhenAggregateRefuses_ReturnsTheClaimedPeerSlot, and Reset_WithOverCapKeyId_DoesNotClearSharedFallbackPartition. The latter two were confirmed as genuine reds against the unfixed code; the concurrency test is a guard — it is deterministically green on the fixed structure but did not reproduce the original nanosecond-wide window on its own.
19 KiB
Gateway gRPC Authorization
The authorization subsystem has two layers. The gRPC interceptor enforces the verb scope required by the RPC. Service-layer constraint checks then narrow what an authenticated API key can browse, read, or write inside the Galaxy.
Overview
Authorization runs as a single gRPC server interceptor registered for every call on the gateway. It pulls the authenticated identity for the current request, derives the scope that the request type requires, and either lets the call continue or fails the call with a gRPC status. The pipeline keeps service classes free of cross-cutting checks, which matches the gateway.md "thin gRPC layer" rule that service handlers translate between contracts and domain code without owning policy.
The participating types live under src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/:
GatewayGrpcAuthorizationInterceptorruns the authenticate-then-authorize pipeline for unary and server-streaming calls.GatewayGrpcScopeResolvermaps a request message (and, forMxCommandRequest, the innerMxCommandKind) to the scope string that must be present on the caller.GatewayScopesexposes the canonical scope constants used by the resolver and any downstream consumer.GatewayRequestIdentityAccessorandIGatewayRequestIdentityAccessorexpose the verified identity to handlers and any service code that runs inside the call.IConstraintEnforcerapplies optional API-key constraints against the cached Galaxy hierarchy from service bodies.GrpcAuthorizationServiceCollectionExtensionswires the components into the DI container and the gRPC pipeline.
The ApiKeyIdentity consumed here is produced by the authentication layer; see Authentication for how it is built and how scopes are persisted.
Why an Interceptor
Centralizing the policy in GatewayGrpcAuthorizationInterceptor produces three concrete benefits:
- Every RPC defined in
MxAccessGatewayServiceis covered by construction. A new RPC inherits the check the moment its request type is added toGatewayGrpcScopeResolver, instead of relying on each service method to remember to call an authorization helper. - Verb-scope policy stays centralized. Request-specific constraints still run in service bodies because they need command payloads, item handles, and Galaxy metadata that the interceptor should not inspect.
- Authentication and authorization happen in one place, so the gRPC
Statusmapping is consistent. A failed key check always returnsUnauthenticated, and a missing scope always returnsPermissionDeniedwith the offending scope name.
Interceptor Flow
GatewayGrpcAuthorizationInterceptor overrides both UnaryServerHandler and ServerStreamingServerHandler. Both call the same private AuthenticateAndAuthorizeAsync helper before invoking the continuation, then push the resolved identity onto the accessor for the duration of the call.
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(request, context).ConfigureAwait(false);
IDisposable? identityScope = identity is null ? null : identityAccessor.Push(identity);
using (identityScope)
{
return await continuation(request, context).ConfigureAwait(false);
}
}
The shared helper performs the actual decision:
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
{
return null;
}
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
.VerifyAsync(authorizationHeader, context.CancellationToken)
.ConfigureAwait(false);
if (!verificationResult.Succeeded || verificationResult.Identity is null)
{
throw new RpcException(new Status(
StatusCode.Unauthenticated,
"Missing or invalid API key."));
}
string requiredScope = scopeResolver.ResolveRequiredScope(request);
if (!verificationResult.Identity.Scopes.Contains(requiredScope))
{
throw new RpcException(new Status(
StatusCode.PermissionDenied,
$"API key is missing required scope '{requiredScope}'."));
}
return verificationResult.Identity;
The flow is:
- If
GatewayOptions.Authentication.ModeisAuthenticationMode.Disabled, the helper returnsnullimmediately. No identity is pushed onto the accessor and the continuation runs without scope enforcement. This matches theAuthenticationModeenum, which only definesApiKeyandDisabled. - Otherwise, the
authorizationrequest header is read directly offServerCallContext.RequestHeadersand handed toIApiKeyVerifier.VerifyAsync. A failed verification or a missing identity throwsRpcExceptionwithStatusCode.Unauthenticated. GatewayGrpcScopeResolver.ResolveRequiredScope(request)produces the scope string. If the identity'sScopesset does not contain it, the helper throwsRpcExceptionwithStatusCode.PermissionDeniedand embeds the missing scope name inStatus.Detailso callers can diagnose the failure.- On success, the verified
ApiKeyIdentityis returned and pushed ontoIGatewayRequestIdentityAccessorfor the lifetime of the call.
The status codes are deliberately distinct: Unauthenticated signals "we do not know who you are," and PermissionDenied signals "we know who you are, but you cannot do this." Treating the two as the same code would make troubleshooting harder for client implementations.
Rate limiting the auth surface (SEC-11, SEC-31, SEC-32)
Before the verification store read, the helper asks a cheap in-process failure counter (ApiKeyFailureLimiter) whether the attempt may proceed, so online guessing of API-key secrets cannot spend a SQLite read (and, in a naive design, a cache miss) per attempt. The counter has two layers over one sliding ApiKeyFailureWindowSeconds window:
- Composite
(transport peer, key id)partitions. ReachingMxGateway:Security:ApiKeyFailureLimitfailures binds the throttle to the address that produced them. The key id alone is never the partition: key ids are not secret — they ride in every token and are listed on the dashboard — so keying on them let any network peer deny a key to its legitimate holder. The key id joins the partition only after a token-shape check (literalmxgwprefix, at least three non-empty_segments, key id of at most 64 characters), and one address may mint at most 32 key-id partitions before the overflow collapses onto that address's fallback partition. - A per-key-id aggregate across all peers (
ApiKeyFailureAggregateLimit, default 30), which bounds a distributed or source-rotating sprayer that never trips any single partition.
An over-limit state is a valve, not a wall: one request per ApiKeyFailureProbeIntervalSeconds (default 5 s) is admitted through to the real verifier, and everything else is refused with StatusCode.ResourceExhausted before the store read. The slot is claimed atomically, so a burst arriving together at an interval boundary still yields exactly one admission. A successful verification resets both layers — which is why the reset path stays reachable while a key is under active spray. One exception: when the caller's key id was collapsed into its address's shared fallback partition by the per-peer cap, a success clears the key's aggregate but leaves that shared partition alone, since it also holds failures contributed by other key ids from the same address. The tracked partitions form a bounded LRU (ApiKeyFailureTrackedPeers) whose eviction prefers fully expired windows and never removes an over-limit partition below a 2x transient overshoot ceiling, so the cap bounds memory without becoming a reset button for an active block. ResourceExhausted reveals only that throttling is in effect, not whether any particular secret was valid, preserving the opaque-failure property. Refusals increment mxgateway.auth.throttled, tagged stage=peer|aggregate and nothing else — /metrics is unauthenticated, so neither key ids nor peer addresses may appear there.
The dashboard login surface is throttled independently: POST /auth/login carries a fixed-window ASP.NET Core rate-limiter policy keyed per remote IP (MxGateway:Security:LoginRateLimit*), rejecting a burst with HTTP 429 before the LDAP bind is relayed to the directory. See GatewayConfiguration.
Scope Resolution
GatewayGrpcScopeResolver is a stateless singleton that switches on the runtime request type. Top-level RPC requests map directly:
public string ResolveRequiredScope(object request)
{
return request switch
{
OpenSessionRequest => GatewayScopes.SessionOpen,
CloseSessionRequest => GatewayScopes.SessionClose,
StreamEventsRequest => GatewayScopes.EventsRead,
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
StreamAlarmsRequest => GatewayScopes.EventsRead,
QueryActiveAlarmsRequest => GatewayScopes.EventsRead,
TestConnectionRequest or
GetLastDeployTimeRequest or
DiscoverHierarchyRequest or
WatchDeployEventsRequest => GatewayScopes.MetadataRead,
_ => GatewayScopes.Admin
};
}
The _ => GatewayScopes.Admin fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. AcknowledgeAlarm is treated as a write — it mutates alarm state, mirroring MxCommandKind.Write* — and StreamAlarms and QueryActiveAlarms share the alarm/event surface with StreamEvents and MxCommandKind.DrainEvents, so they carry events:read (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
MxCommandRequest is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded MxCommandKind so each operation gets its own scope:
private static string ResolveCommandScope(MxCommandKind kind)
{
return kind switch
{
MxCommandKind.Write or
MxCommandKind.Write2 or
MxCommandKind.WriteBulk or
MxCommandKind.Write2Bulk => GatewayScopes.InvokeWrite,
MxCommandKind.WriteSecured or
MxCommandKind.WriteSecured2 or
MxCommandKind.WriteSecuredBulk or
MxCommandKind.WriteSecured2Bulk or
MxCommandKind.AuthenticateUser => GatewayScopes.InvokeSecure,
MxCommandKind.ArchestraUserToId or
MxCommandKind.GetSessionState or
MxCommandKind.GetWorkerInfo => GatewayScopes.MetadataRead,
MxCommandKind.DrainEvents => GatewayScopes.EventsRead,
MxCommandKind.ShutdownWorker => GatewayScopes.Admin,
_ => GatewayScopes.InvokeRead
};
}
Reads (Register, AddItem, Advise, ReadBulk, and any other unspecified kind) fall through to InvokeRead, which keeps the matrix small while still separating reads from writes, secured writes, metadata lookups, event drains, and worker shutdown. The four bulk-write families (WriteBulk, Write2Bulk, WriteSecuredBulk, WriteSecured2Bulk) are mapped explicitly so a missing arm cannot silently demote a bulk write to a read scope.
Constraint Enforcement
ApiKeyIdentity.Constraints is optional. Empty constraints preserve the
previous behavior: the key is authorized only by its verb scopes. Non-empty
constraints are stored as JSON in api_keys.constraints and are applied by
IConstraintEnforcer after the interceptor succeeds.
Supported constraints are:
| Constraint | Meaning |
|---|---|
read_subtrees |
Contained-path globs allowed for read/subscription commands. |
write_subtrees |
Contained-path globs allowed for write commands. |
read_tag_globs |
Tag-address globs allowed for read/subscription commands. |
write_tag_globs |
Tag-address globs allowed for write commands. |
max_write_classification |
Maximum Galaxy attribute security_classification a key may write. |
browse_subtrees |
Contained-path globs used to filter Galaxy browse results and deploy-event counts. |
read_alarm_only |
Read/subscription commands must target objects with alarm-bearing attributes. |
read_historized_only |
Read/subscription commands must target objects with historized attributes. |
Glob matching is anchored, case-insensitive, and supports * and ?.
Subtree and tag glob lists are alternatives: matching either list allows that
scope dimension. Empty lists mean unconstrained for that dimension.
Constraints are set when a key is created — through the apikey create-key
flags (see Authentication) or the dashboard API Keys
page create dialog (see
Gateway Dashboard Design). The
dashboard API Keys page also renders each key's effective constraints.
The service checks read constraints for AddItem, AddItem2, AddItemBulk,
SubscribeBulk, AdviseItemBulk, and ReadBulk. It checks write constraints
for Write, Write2, WriteSecured, WriteSecured2, WriteBulk,
Write2Bulk, WriteSecuredBulk, and WriteSecured2Bulk. Bulk commands run
through BulkConstraintPlan (ReadBulkConstraintPlan,
WriteBulkConstraintPlan, SubscribeBulkConstraintPlan), which preserves the
caller's input order: each entry is evaluated against the constraint surface,
and BulkConstraintPlan.MergeDeniedInto re-merges denied entries back into
their original index positions so the reply slot at entries[i] always
corresponds to the request slot at entries[i]. Successful item registrations
are tracked per session so later item-handle commands resolve back to the
original tag address. If a constrained key presents an unknown item handle,
the gateway fails closed.
Non-bulk constraint failures return gRPC PermissionDenied. Bulk read
commands preserve input order and return a failed SubscribeResult for each
denied item while still forwarding allowed items to the worker. Every denial
adds an api_key_audit entry with the key id, command kind, target, and
blocking constraint; secured values and raw credentials are never logged.
Scope Catalog
GatewayScopes is the single source of truth for scope strings. Every entry is currently mapped by either the resolver or another security component:
| Constant | Value | Required For |
|---|---|---|
SessionOpen |
session:open |
OpenSessionRequest |
SessionClose |
session:close |
CloseSessionRequest |
EventsRead |
events:read |
StreamEventsRequest, StreamAlarmsRequest, QueryActiveAlarmsRequest, MxCommandKind.DrainEvents |
InvokeRead |
invoke:read |
MxCommandRequest for read-style command kinds (Register, AddItem, Advise, ReadBulk, and any kind not otherwise mapped) |
InvokeWrite |
invoke:write |
AcknowledgeAlarmRequest, MxCommandKind.Write, MxCommandKind.Write2, MxCommandKind.WriteBulk, MxCommandKind.Write2Bulk |
InvokeSecure |
invoke:secure |
MxCommandKind.WriteSecured, MxCommandKind.WriteSecured2, MxCommandKind.WriteSecuredBulk, MxCommandKind.WriteSecured2Bulk, MxCommandKind.AuthenticateUser |
MetadataRead |
metadata:read |
MxCommandKind.ArchestraUserToId, MxCommandKind.GetSessionState, MxCommandKind.GetWorkerInfo, GalaxyRepository.TestConnection, GalaxyRepository.GetLastDeployTime, GalaxyRepository.DiscoverHierarchy, GalaxyRepository.WatchDeployEvents |
Admin |
admin |
MxCommandKind.ShutdownWorker, the default for any unrecognized request type, and the dashboard authorization policy |
The Admin constant is also referenced by DashboardAuthenticator and DashboardAuthorizationHandler so that the dashboard and the gRPC layer agree on what "admin" means.
Identity Access for Downstream Layers
Once authorization passes, GatewayGrpcAuthorizationInterceptor calls identityAccessor.Push(identity) and disposes the returned scope when the continuation completes. GatewayRequestIdentityAccessor stores the active identity in an AsyncLocal<ApiKeyIdentity?>, so the value flows across await boundaries and child tasks belonging to the same request.
public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAccessor
{
private readonly AsyncLocal<ApiKeyIdentity?> currentIdentity = new();
public ApiKeyIdentity? Current => currentIdentity.Value;
public IDisposable Push(ApiKeyIdentity identity)
{
ArgumentNullException.ThrowIfNull(identity);
ApiKeyIdentity? previousIdentity = currentIdentity.Value;
currentIdentity.Value = identity;
return new IdentityScope(this, previousIdentity);
}
}
The returned IdentityScope restores the previous value on dispose rather than clearing it. This makes the accessor safe for nested pushes, even though the current interceptor only pushes once per call. Disposing twice is a no-op because of the disposed guard inside IdentityScope.
Downstream code consumes the accessor through the IGatewayRequestIdentityAccessor interface:
public interface IGatewayRequestIdentityAccessor
{
ApiKeyIdentity? Current { get; }
IDisposable Push(ApiKeyIdentity identity);
}
MxAccessGatewayService takes IGatewayRequestIdentityAccessor as a constructor dependency and reads Current whenever it needs to attach the calling identity to a domain operation, which keeps the service free of header parsing or scope checks.
When AuthenticationMode.Disabled is configured, no identity is pushed, so Current returns null. Downstream code must tolerate that, just as it tolerates the absence of a scope check.
Registration
GrpcAuthorizationServiceCollectionExtensions.AddGatewayGrpcAuthorization is the single entry point that registers every component and inserts the interceptor into the gRPC pipeline:
public static IServiceCollection AddGatewayGrpcAuthorization(this IServiceCollection services)
{
services.AddSingleton<GatewayGrpcScopeResolver>();
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInterceptor>());
return services;
}
Singleton lifetimes are appropriate because none of the three classes hold per-request state on instance fields; the request-scoped value lives inside the AsyncLocal on GatewayRequestIdentityAccessor. GatewayApplication calls builder.Services.AddGatewayGrpcAuthorization() during startup, and the call also performs AddGrpc, so the gateway never registers gRPC without the interceptor attached.