fix(archreview): security authz+hub cluster (SEC-07/05/08/11) + validation hardening

SEC-07: add QueryActiveAlarmsRequest -> events:read scope arm; fix two tests that
  constructed StreamAlarmsRequest instead of QueryActiveAlarmsRequest.
SEC-05: shorten hub-token lifetime 30m -> 5m; document that the ?access_token= query
  carriage must never be request-logged.
SEC-08: gateway-side CachingApiKeyVerifier (short TTL, keyed on a hash of the presented
  secret) skips the per-call store read+last_used write; CoalescingMarkApiKeyStore bounds
  last_used writes to <=1/key/min; identity constraints are cached. Invalidation is wired
  at the gateway admin sites (revoke/rotate/delete); short TTL backstops out-of-process CLI.
SEC-11: fixed-window rate limit on POST /auth/login + a per-peer (key-id) failure limiter
  checked before VerifyAsync; new MxGateway:Security options bound + validated.

Also fixes regressions from the SEC-01/04/06 commit (c185f62) that a narrow test filter
missed (all now covered by a full-suite checkpoint):
- Rooting check is cross-platform: accepts Windows C:\/UNC forms on Unix so the shipped
  appsettings path validates on the macOS dev box, still rejecting bare filenames.
- AddGatewayConfiguration TryAdds a non-production IHostEnvironment fallback so the validator
  resolves in minimal test/tooling containers; the real host + apikey CLI register the actual
  environment first (TryAdd no-op there).
- Test assembly defaults ASPNETCORE_ENVIRONMENT=Development (ModuleInitializer) so full-host
  tests exercise wiring instead of tripping the SEC-04/06 production guards.
- GatewayOptionsTests asserts the SEC-01 CommonApplicationData-derived default (platform-correct).

archreview: SEC-07/05/08/11 (P1). Verified: NonWindows build clean; full gateway suite
747 passed / 42 failed, where all 42 are the pre-existing macOS named-pipe-harness failures
(Unix-socket path limit) and 0 are validation/regression failures.
This commit is contained in:
Joseph Doherty
2026-07-09 07:31:35 -04:00
parent 970613eebd
commit 17f16ea181
29 changed files with 1521 additions and 18 deletions
@@ -1,4 +1,6 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
@@ -6,6 +8,7 @@ using ZB.MOM.WW.Auth.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
@@ -66,6 +69,34 @@ public static class AuthStoreServiceCollectionExtensions
// migrator and the migration hosted service.
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath);
// SEC-08 hot-path decorators. Every gRPC call previously did a SQLite read plus a
// last_used_utc WRITE via IApiKeyVerifier.VerifyAsync (the library verifier couples the
// mark into verification). Two gateway-side decorators cut that cost without editing the
// external library:
// * CoalescingMarkApiKeyStore wraps the library's IApiKeyStore and coalesces the
// last_used write to at most one per key per MxGateway:Security window (default 60s),
// so the library verifier's per-call mark no longer churns the WAL.
// * CachingApiKeyVerifier wraps the library's IApiKeyVerifier with a short-TTL memory
// cache (MxGateway:Security:ApiKeyVerificationCacheSeconds, default 15s) keyed on a
// SHA-256 hash of the presented token, so a cache hit skips the store read entirely.
// Invalidation of a cached verification on a gateway-initiated revoke/rotate/delete is
// wired at the dashboard admin call site (DashboardApiKeyManagementService) via
// IApiKeyCacheInvalidator; the short TTL is the backstop for out-of-band mutations.
// Read the security knobs straight off IConfiguration rather than IOptions<GatewayOptions>:
// touching IOptions<GatewayOptions>.Value would force the whole-options validation pipeline
// (which needs IHostEnvironment) at auth-store resolution, breaking the bare-DI auth tests.
// GatewayOptions is still validated at startup, which covers these same values.
SecurityOptions security = configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>()
?? new SecurityOptions();
services.AddMemoryCache();
DecorateSingleton<IApiKeyStore>(
services,
(sp, inner) => new CoalescingMarkApiKeyStore(
inner,
security,
sp.GetService<TimeProvider>() ?? TimeProvider.System));
DecorateVerifierWithCache(services, security);
services.AddSingleton(sp =>
new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
// Resolve the logger defensively: the production host always registers ILogger<T>, but the
@@ -103,4 +134,58 @@ public static class AuthStoreServiceCollectionExtensions
return services;
}
/// <summary>
/// Replaces the last registration of <typeparamref name="TService"/> with a singleton that wraps
/// it. The wrapped (inner) service is created once, preserving singleton semantics. Used to layer
/// the SEC-08 store decorator over the external library's registration without editing it.
/// </summary>
private static void DecorateSingleton<TService>(
IServiceCollection services,
Func<IServiceProvider, TService, TService> decorator)
where TService : class
{
Func<IServiceProvider, TService> innerFactory = CaptureInnerFactory<TService>(services);
services.AddSingleton(sp => decorator(sp, innerFactory(sp)));
}
// Wraps the library's IApiKeyVerifier with CachingApiKeyVerifier and exposes the same instance
// as IApiKeyCacheInvalidator so admin call sites can drop cached verifications on revoke/rotate.
private static void DecorateVerifierWithCache(IServiceCollection services, SecurityOptions security)
{
Func<IServiceProvider, IApiKeyVerifier> innerFactory = CaptureInnerFactory<IApiKeyVerifier>(services);
services.AddSingleton(sp => new CachingApiKeyVerifier(
innerFactory(sp),
sp.GetRequiredService<IMemoryCache>(),
security));
services.AddSingleton<IApiKeyVerifier>(sp => sp.GetRequiredService<CachingApiKeyVerifier>());
services.AddSingleton<IApiKeyCacheInvalidator>(sp => sp.GetRequiredService<CachingApiKeyVerifier>());
}
// Removes the current last registration of TService and returns a factory that materialises that
// original implementation exactly once when first resolved. The removed descriptor may be a
// type, factory or instance registration.
private static Func<IServiceProvider, TService> CaptureInnerFactory<TService>(IServiceCollection services)
where TService : class
{
ServiceDescriptor descriptor = services.LastOrDefault(d => d.ServiceType == typeof(TService))
?? throw new InvalidOperationException(
$"No existing registration for {typeof(TService)} to decorate; register the library provider first.");
services.Remove(descriptor);
if (descriptor.ImplementationInstance is TService instance)
{
return _ => instance;
}
if (descriptor.ImplementationFactory is not null)
{
return sp => (TService)descriptor.ImplementationFactory(sp);
}
Type implementationType = descriptor.ImplementationType
?? throw new InvalidOperationException(
$"Registration for {typeof(TService)} has no implementation type, factory or instance to decorate.");
return sp => (TService)ActivatorUtilities.CreateInstance(sp, implementationType);
}
}
@@ -0,0 +1,159 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Caching.Memory;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>
/// Lets gateway-side admin call sites drop a cached API-key verification when they revoke, rotate
/// or delete a key, so a mutation applied through the running gateway process takes effect
/// immediately rather than after the cache TTL elapses.
/// </summary>
public interface IApiKeyCacheInvalidator
{
/// <summary>Removes every cached verification for the given key id.</summary>
/// <param name="keyId">The key id whose cached verifications should be dropped.</param>
void Invalidate(string keyId);
}
/// <summary>
/// An <see cref="IApiKeyVerifier"/> decorator that caches successful verifications for a short,
/// configurable TTL (<see cref="SecurityOptions.ApiKeyVerificationCacheSeconds"/>), keyed on a
/// SHA-256 hash of the presented token. A cache hit returns the cached result without calling the
/// inner (library) verifier, so it avoids both the per-call SQLite read and the <c>last_used</c>
/// write the library verifier couples into <see cref="IApiKeyVerifier.VerifyAsync"/>.
/// </summary>
/// <remarks>
/// <para>
/// Only successful verifications are cached. Failures and unparseable headers always fall through
/// to the inner verifier — caching a failure risks pinning a transiently-wrong negative, and the
/// SEC-11 per-peer failure counter (not this cache) is what bounds brute-force cost.
/// </para>
/// <para>
/// The cache key is the hex SHA-256 of the presented token (which embeds both the key id and the
/// secret). Hashing means the plaintext secret is never stored in memory. The hash is a cache index
/// only; the authentic constant-time secret comparison remains inside the inner verifier, reached
/// on every cache miss.
/// </para>
/// <para>
/// Correctness on mutation is provided by two mechanisms: gateway-initiated revoke/rotate/delete
/// call <see cref="Invalidate"/> directly (see <c>DashboardApiKeyManagementService</c>), and the
/// short TTL is the backstop for out-of-band mutations (a direct DB edit, or a revoke issued by the
/// separate <c>apikey</c> CLI process, whose in-memory cache is not this process's cache).
/// </para>
/// </remarks>
public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalidator
{
private const string CacheKeyPrefix = "mxgw:apikeyverif:";
private readonly IApiKeyVerifier _inner;
private readonly IMemoryCache _cache;
private readonly TimeSpan _ttl;
// keyId -> set of cache keys currently held for that id, so a revoke/rotate can evict every
// secret variant (e.g. an old secret still within TTL after a rotate).
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _keyIdIndex =
new(StringComparer.Ordinal);
/// <summary>Initializes a new instance of the <see cref="CachingApiKeyVerifier"/> class.</summary>
/// <param name="inner">The wrapped verifier (the library verifier) reached on a cache miss.</param>
/// <param name="cache">The shared memory cache.</param>
/// <param name="security">Security options carrying the verification-cache TTL.</param>
public CachingApiKeyVerifier(IApiKeyVerifier inner, IMemoryCache cache, SecurityOptions security)
: this(
inner,
cache,
TimeSpan.FromSeconds((security ?? throw new ArgumentNullException(nameof(security)))
.ApiKeyVerificationCacheSeconds))
{
}
// Test/explicit-TTL seam.
internal CachingApiKeyVerifier(IApiKeyVerifier inner, IMemoryCache cache, TimeSpan ttl)
{
ArgumentNullException.ThrowIfNull(inner);
ArgumentNullException.ThrowIfNull(cache);
_inner = inner;
_cache = cache;
_ttl = ttl;
}
/// <inheritdoc />
public async Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
{
if (_ttl <= TimeSpan.Zero || !TryComputeCacheKey(authorizationHeader, out string cacheKey))
{
return await _inner.VerifyAsync(authorizationHeader, ct).ConfigureAwait(false);
}
if (_cache.TryGetValue(cacheKey, out ApiKeyVerification? cached) && cached is not null)
{
return cached;
}
ApiKeyVerification result = await _inner.VerifyAsync(authorizationHeader, ct).ConfigureAwait(false);
if (result.Succeeded && result.Identity is not null)
{
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _ttl,
});
IndexCacheKey(result.Identity.KeyId, cacheKey);
}
return result;
}
/// <inheritdoc />
public void Invalidate(string keyId)
{
if (string.IsNullOrEmpty(keyId))
{
return;
}
if (_keyIdIndex.TryRemove(keyId, out ConcurrentDictionary<string, byte>? cacheKeys))
{
foreach (string cacheKey in cacheKeys.Keys)
{
_cache.Remove(cacheKey);
}
}
}
private void IndexCacheKey(string keyId, string cacheKey)
{
ConcurrentDictionary<string, byte> set = _keyIdIndex.GetOrAdd(
keyId,
static _ => new ConcurrentDictionary<string, byte>(StringComparer.Ordinal));
set.TryAdd(cacheKey, 0);
}
private static bool TryComputeCacheKey(string? authorizationHeader, out string cacheKey)
{
cacheKey = string.Empty;
if (string.IsNullOrEmpty(authorizationHeader))
{
return false;
}
ReadOnlySpan<char> header = authorizationHeader.AsSpan().Trim();
const string bearer = "Bearer ";
ReadOnlySpan<char> token = header.StartsWith(bearer, StringComparison.OrdinalIgnoreCase)
? header[bearer.Length..].Trim()
: header;
if (token.IsEmpty)
{
return false;
}
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(token.ToString()));
cacheKey = CacheKeyPrefix + Convert.ToHexString(hash);
return true;
}
}
@@ -0,0 +1,101 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>
/// An <see cref="IApiKeyStore"/> decorator that coalesces <see cref="MarkUsedAsync"/> writes to at
/// most one per key per <see cref="SecurityOptions.ApiKeyLastUsedCoalesceSeconds"/> window. Lookups
/// pass through unchanged.
/// </summary>
/// <remarks>
/// The library verifier couples the <c>last_used_utc</c> write into every successful verification
/// by calling <see cref="IApiKeyStore.MarkUsedAsync"/>. On the bulk-read hot path that turns a
/// per-RPC database write into the throughput ceiling. This decorator sits under the library
/// verifier: it forwards the first mark for a key, then drops subsequent marks until the window
/// elapses, so <c>last_used_utc</c> is refreshed at most once per key per window while the read
/// path stays correct. <c>last_used_utc</c> is a coarse staleness indicator, not an audit record
/// (audit rows are written separately), so bounded staleness of up to one window is acceptable.
/// </remarks>
public sealed class CoalescingMarkApiKeyStore : IApiKeyStore
{
private readonly IApiKeyStore _inner;
private readonly TimeSpan _window;
private readonly TimeProvider _clock;
// keyId -> UtcTicks of the last forwarded mark. Bounded by the number of API keys, which is
// small; no eviction needed.
private readonly ConcurrentDictionary<string, long> _lastMarkedTicks = new(StringComparer.Ordinal);
/// <summary>Initializes a new instance of the <see cref="CoalescingMarkApiKeyStore"/> class.</summary>
/// <param name="inner">The wrapped store.</param>
/// <param name="security">Security options carrying the coalescing window.</param>
/// <param name="clock">The time provider.</param>
public CoalescingMarkApiKeyStore(IApiKeyStore inner, SecurityOptions security, TimeProvider clock)
: this(
inner,
TimeSpan.FromSeconds((security ?? throw new ArgumentNullException(nameof(security)))
.ApiKeyLastUsedCoalesceSeconds),
clock)
{
}
// Test/explicit-window seam.
internal CoalescingMarkApiKeyStore(IApiKeyStore inner, TimeSpan window, TimeProvider clock)
{
ArgumentNullException.ThrowIfNull(inner);
ArgumentNullException.ThrowIfNull(clock);
_inner = inner;
_window = window;
_clock = clock;
}
/// <inheritdoc />
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
=> _inner.FindByKeyIdAsync(keyId, ct);
/// <inheritdoc />
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
=> _inner.FindActiveByKeyIdAsync(keyId, ct);
/// <inheritdoc />
public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(keyId);
if (_window <= TimeSpan.Zero)
{
return _inner.MarkUsedAsync(keyId, whenUtc, ct);
}
long now = _clock.GetUtcNow().UtcTicks;
while (true)
{
if (_lastMarkedTicks.TryGetValue(keyId, out long last))
{
if (now - last < _window.Ticks)
{
// Within the coalescing window — drop the write.
return Task.CompletedTask;
}
if (_lastMarkedTicks.TryUpdate(keyId, now, last))
{
return _inner.MarkUsedAsync(keyId, whenUtc, ct);
}
// Lost the race to another writer; re-evaluate.
continue;
}
if (_lastMarkedTicks.TryAdd(keyId, now))
{
return _inner.MarkUsedAsync(keyId, whenUtc, ct);
}
// Lost the race to another writer; re-evaluate.
}
}
}
@@ -1,3 +1,5 @@
using System.Collections.Concurrent;
using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
@@ -17,6 +19,36 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// </remarks>
public static class GatewayApiKeyIdentityMapper
{
// SEC-08: memoize the constraints deserialization keyed by the raw JSON blob so the per-call
// JSON parse on the auth hot path collapses to a dictionary lookup. Distinct blobs are bounded
// by the number of API keys (small); ApiKeyConstraints is an immutable record, so a parsed
// instance is safe to share across callers. The cap is a defensive backstop against a pathological
// spread of distinct blobs — past it, we simply parse without caching rather than grow unbounded.
private const int MaxCachedConstraintBlobs = 1024;
private static readonly ConcurrentDictionary<string, ApiKeyConstraints> ConstraintCache =
new(StringComparer.Ordinal);
private static ApiKeyConstraints DeserializeConstraints(string? constraintsJson)
{
if (string.IsNullOrWhiteSpace(constraintsJson))
{
return ApiKeyConstraints.Empty;
}
if (ConstraintCache.TryGetValue(constraintsJson, out ApiKeyConstraints? cached))
{
return cached;
}
ApiKeyConstraints parsed = ApiKeyConstraintSerializer.Deserialize(constraintsJson);
if (ConstraintCache.Count < MaxCachedConstraintBlobs)
{
ConstraintCache.TryAdd(constraintsJson, parsed);
}
return parsed;
}
/// <summary>
/// Converts a shared API-key identity into the gateway identity, deserializing the opaque
/// constraints JSON into <see cref="ApiKeyConstraints"/>.
@@ -38,6 +70,6 @@ public static class GatewayApiKeyIdentityMapper
KeyPrefix: "mxgw",
DisplayName: identity.DisplayName,
Scopes: identity.Scopes,
Constraints: ApiKeyConstraintSerializer.Deserialize(constraintsJson));
Constraints: DeserializeConstraints(constraintsJson));
}
}
@@ -0,0 +1,152 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// <summary>
/// Cheap, in-process per-peer sliding-window failure counter for the gRPC auth path (SEC-11). It is
/// checked BEFORE the API-key verification store read and short-circuits a peer that has exceeded
/// <see cref="SecurityOptions.ApiKeyFailureLimit"/> failed attempts within
/// <see cref="SecurityOptions.ApiKeyFailureWindowSeconds"/>; a successful verification resets the
/// peer's counter.
/// </summary>
/// <remarks>
/// <para>
/// The peer key is the API key id when the presented token parses, falling back to the transport
/// peer address otherwise. Keying on key id (per the NAT caveat in <c>glauth.md</c>) means a single
/// abusive credential behind a shared NAT is throttled without locking out unrelated clients on the
/// same address.
/// </para>
/// <para>
/// The tracked-peer set is a bounded LRU (<see cref="SecurityOptions.ApiKeyFailureTrackedPeers"/>) so
/// a spray of unique peer keys cannot grow memory without limit. Successful peers are removed on
/// reset, so in steady state the map holds only peers with recent failures — the common success path
/// is a lock-free dictionary miss.
/// </para>
/// </remarks>
public sealed class ApiKeyFailureLimiter
{
private readonly int _limit;
private readonly long _windowTicks;
private readonly int _maxPeers;
private readonly TimeProvider _clock;
private readonly ConcurrentDictionary<string, PeerState> _peers = new(StringComparer.Ordinal);
/// <summary>Initializes a new instance of the <see cref="ApiKeyFailureLimiter"/> class.</summary>
/// <param name="security">Security options carrying the failure-limit knobs.</param>
/// <param name="clock">The time provider.</param>
public ApiKeyFailureLimiter(SecurityOptions security, TimeProvider clock)
: this(
(security ?? throw new ArgumentNullException(nameof(security))).ApiKeyFailureLimit,
TimeSpan.FromSeconds(security.ApiKeyFailureWindowSeconds),
security.ApiKeyFailureTrackedPeers,
clock)
{
}
// Test/explicit seam.
internal ApiKeyFailureLimiter(int limit, TimeSpan window, int maxPeers, TimeProvider clock)
{
ArgumentNullException.ThrowIfNull(clock);
_limit = limit;
_windowTicks = window.Ticks;
_maxPeers = maxPeers;
_clock = clock;
}
/// <summary>Returns whether the peer has reached the failure limit within the current window.</summary>
/// <param name="peer">The peer key (key id or peer address).</param>
/// <returns><see langword="true"/> when the peer should be short-circuited.</returns>
public bool IsBlocked(string peer)
{
ArgumentNullException.ThrowIfNull(peer);
if (_limit <= 0)
{
return false;
}
if (!_peers.TryGetValue(peer, out PeerState? state))
{
return false;
}
long now = _clock.GetUtcNow().UtcTicks;
lock (state)
{
Prune(state, now);
return state.FailureTicks.Count >= _limit;
}
}
/// <summary>Records a failed verification attempt for the peer.</summary>
/// <param name="peer">The peer key (key id or peer address).</param>
public void RecordFailure(string peer)
{
ArgumentNullException.ThrowIfNull(peer);
if (_limit <= 0)
{
return;
}
long now = _clock.GetUtcNow().UtcTicks;
PeerState state = _peers.GetOrAdd(peer, static _ => new PeerState());
lock (state)
{
Prune(state, now);
state.FailureTicks.Enqueue(now);
state.LastActivityTicks = now;
}
EvictIfOverCapacity();
}
/// <summary>Clears the peer's failure count after a successful verification.</summary>
/// <param name="peer">The peer key (key id or peer address).</param>
public void Reset(string peer)
{
ArgumentNullException.ThrowIfNull(peer);
_peers.TryRemove(peer, out _);
}
private void Prune(PeerState state, long now)
{
while (state.FailureTicks.Count > 0 && now - state.FailureTicks.Peek() >= _windowTicks)
{
state.FailureTicks.Dequeue();
}
}
private void EvictIfOverCapacity()
{
// Best-effort eviction: only runs when the map exceeds the cap (rare, since only peers with
// recent failures are tracked). Removes the least-recently-active peer. Racy under
// concurrency, which is acceptable for a bound rather than an exact policy.
while (_peers.Count > _maxPeers)
{
string? oldest = null;
long oldestTicks = long.MaxValue;
foreach (KeyValuePair<string, PeerState> entry in _peers)
{
long activity = Volatile.Read(ref entry.Value.LastActivityTicks);
if (activity < oldestTicks)
{
oldestTicks = activity;
oldest = entry.Key;
}
}
if (oldest is null || !_peers.TryRemove(oldest, out _))
{
break;
}
}
}
private sealed class PeerState
{
public Queue<long> FailureTicks { get; } = new();
public long LastActivityTicks;
}
}
@@ -15,7 +15,8 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
IApiKeyVerifier apiKeyVerifier,
GatewayGrpcScopeResolver scopeResolver,
IGatewayRequestIdentityAccessor identityAccessor,
IOptions<GatewayOptions> options) : Interceptor
IOptions<GatewayOptions> options,
ApiKeyFailureLimiter failureLimiter) : Interceptor
{
/// <inheritdoc />
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
@@ -63,6 +64,19 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
// SEC-11: short-circuit a peer that has already failed too many times inside the sliding
// window BEFORE the verification store read, so online guessing cannot spend a SQLite read
// per attempt. The peer key prefers the presented key id over the transport address (NAT
// caveat). ResourceExhausted signals throttling without revealing whether any particular
// secret was valid.
string peerKey = ResolvePeerKey(authorizationHeader, context);
if (failureLimiter.IsBlocked(peerKey))
{
throw new RpcException(new Status(
StatusCode.ResourceExhausted,
"Too many authentication attempts. Try again later."));
}
// The shared verifier owns parse + pepper + lookup + revocation + constant-time compare,
// returning a discriminated failure rather than throwing. Every authentication failure maps
// to Unauthenticated with an opaque message; the client never learns which stage failed.
@@ -72,11 +86,16 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
if (!verification.Succeeded || verification.Identity is null)
{
failureLimiter.RecordFailure(peerKey);
throw new RpcException(new Status(
StatusCode.Unauthenticated,
"Missing or invalid API key."));
}
// Successful authentication clears the peer's failure count so a legitimate client that
// fat-fingered a few attempts is not penalised once it recovers.
failureLimiter.Reset(peerKey);
ApiKeyIdentity identity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
string requiredScope = scopeResolver.ResolveRequiredScope(request);
@@ -89,4 +108,30 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
return identity;
}
// Resolves the failure-limiter partition key: the presented key id (token shape
// mxgw_<keyId>_<secret>) when the header parses, otherwise the transport peer address. Keying on
// key id throttles a single abusive credential without locking out co-located clients behind NAT.
private static string ResolvePeerKey(string? authorizationHeader, ServerCallContext context)
{
if (!string.IsNullOrWhiteSpace(authorizationHeader))
{
ReadOnlySpan<char> header = authorizationHeader.AsSpan().Trim();
const string bearer = "Bearer ";
ReadOnlySpan<char> token = header.StartsWith(bearer, StringComparison.OrdinalIgnoreCase)
? header[bearer.Length..].Trim()
: header;
// mxgw_<keyId>_<secret>: the key id is the second underscore-delimited segment. The
// secret may itself contain underscores, but the key id is unaffected.
string tokenText = token.ToString();
string[] parts = tokenText.Split('_');
if (parts.Length >= 3 && parts[0].Length > 0 && parts[1].Length > 0)
{
return "key:" + parts[1];
}
}
return "peer:" + context.Peer;
}
}
@@ -20,6 +20,7 @@ public sealed class GatewayGrpcScopeResolver
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
StreamAlarmsRequest => GatewayScopes.EventsRead,
QueryActiveAlarmsRequest => GatewayScopes.EventsRead,
TestConnectionRequest or
GetLastDeployTimeRequest or
DiscoverHierarchyRequest or
@@ -19,6 +19,13 @@ public static class GrpcAuthorizationServiceCollectionExtensions
services.AddSingleton<GatewayGrpcScopeResolver>();
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
services.AddSingleton<IConstraintEnforcer, ConstraintEnforcer>();
// SEC-11 per-peer failure counter, checked before the verification store read. Bind the knobs
// from IConfiguration directly (not IOptions<GatewayOptions>) to avoid coupling this
// registration to the whole-options validation pipeline.
services.AddSingleton(sp => new ApiKeyFailureLimiter(
sp.GetRequiredService<IConfiguration>().GetSection(SecurityOptions.SectionName).Get<SecurityOptions>()
?? new SecurityOptions(),
sp.GetService<TimeProvider>() ?? TimeProvider.System));
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
services
.AddOptions<global::Grpc.AspNetCore.Server.GrpcServiceOptions>()