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,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
@@ -12,6 +15,14 @@ public static class GatewayConfigurationServiceCollectionExtensions
public static IServiceCollection AddGatewayConfiguration(
this IServiceCollection services, IConfiguration configuration)
{
// GatewayOptionsValidator depends on IHostEnvironment to gate the production-only rules
// (SEC-04/06). The real host and the apikey CLI both build through WebApplicationBuilder,
// which registers IHostEnvironment before this call — so TryAdd is a no-op there. Minimal
// containers (unit tests, tooling) have none; fall back to a non-production environment so
// the validator still activates and the production guards stay off outside a real
// deployment (the fail-fast guards only make sense against a real host environment).
services.TryAddSingleton<IHostEnvironment>(new FallbackHostEnvironment());
services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
configuration, GatewayOptions.SectionName);
@@ -19,4 +30,19 @@ public static class GatewayConfigurationServiceCollectionExtensions
return services;
}
/// <summary>
/// Non-production <see cref="IHostEnvironment"/> used only when no real host registered one
/// (minimal test/tooling containers). The real host's registration always wins via TryAdd.
/// </summary>
private sealed class FallbackHostEnvironment : IHostEnvironment
{
public string EnvironmentName { get; set; } = Environments.Development;
public string ApplicationName { get; set; } = typeof(FallbackHostEnvironment).Assembly.GetName().Name ?? "MxGateway";
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
}
}
@@ -46,4 +46,10 @@ public sealed class GatewayOptions
/// <summary>Gets self-signed TLS certificate auto-generation options.</summary>
public TlsOptions Tls { get; init; } = new();
/// <summary>
/// Gets security hot-path options (API-key verification cache / last-used coalescing and
/// login / API-key rate limiting).
/// </summary>
public SecurityOptions Security { get; init; } = new();
}
@@ -48,6 +48,43 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
ValidateProtocol(options.Protocol, builder);
ValidateAlarms(options.Alarms, builder);
ValidateTls(options.Tls, builder);
ValidateSecurity(options.Security, builder);
}
private static void ValidateSecurity(SecurityOptions options, ValidationBuilder builder)
{
// Cache/coalesce windows may be 0 (disables that mechanism); negatives are invalid.
AddIfNegative(
options.ApiKeyVerificationCacheSeconds,
"MxGateway:Security:ApiKeyVerificationCacheSeconds must be greater than or equal to zero (0 disables the verification cache).",
builder);
AddIfNegative(
options.ApiKeyLastUsedCoalesceSeconds,
"MxGateway:Security:ApiKeyLastUsedCoalesceSeconds must be greater than or equal to zero (0 forwards every last_used write).",
builder);
// Rate-limit knobs must be positive: a zero permit/window/limit is a misconfiguration that
// would either reject every request or divide by zero rather than express an intent.
AddIfNotPositive(
options.LoginRateLimitPermitLimit,
"MxGateway:Security:LoginRateLimitPermitLimit must be greater than zero.",
builder);
AddIfNotPositive(
options.LoginRateLimitWindowSeconds,
"MxGateway:Security:LoginRateLimitWindowSeconds must be greater than zero.",
builder);
AddIfNotPositive(
options.ApiKeyFailureLimit,
"MxGateway:Security:ApiKeyFailureLimit must be greater than zero.",
builder);
AddIfNotPositive(
options.ApiKeyFailureWindowSeconds,
"MxGateway:Security:ApiKeyFailureWindowSeconds must be greater than zero.",
builder);
AddIfNotPositive(
options.ApiKeyFailureTrackedPeers,
"MxGateway:Security:ApiKeyFailureTrackedPeers must be greater than zero.",
builder);
}
private static void ValidateAuthentication(AuthenticationOptions options, ValidationBuilder builder)
@@ -456,12 +493,41 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
return;
}
if (!Path.IsPathRooted(value))
if (!IsRootedForAnyPlatform(value))
{
builder.Add(message);
}
}
/// <summary>
/// Determines whether <paramref name="value"/> is an absolute path for <em>any</em> platform,
/// not just the host running the validator. This matters on the macOS dev box, where the
/// production <c>appsettings.json</c> ships Windows-absolute paths (<c>C:\ProgramData\...</c>)
/// that <see cref="Path.IsPathRooted(string)"/> reports as non-rooted on Unix. The intent of the
/// rooting check is to reject bare filenames that resolve against the launch working directory,
/// so a valid Windows drive-qualified or UNC path must pass regardless of the current OS.
/// </summary>
private static bool IsRootedForAnyPlatform(string value)
{
// Rooted on the current OS (Unix "/...", or a Windows drive/UNC path when on Windows).
if (Path.IsPathRooted(value))
{
return true;
}
// Windows drive-qualified path ("C:\..." or "C:/...") checked on a non-Windows host.
if (value.Length >= 3
&& char.IsLetter(value[0])
&& value[1] == ':'
&& (value[2] == '\\' || value[2] == '/'))
{
return true;
}
// Windows UNC path ("\\server\share") checked on a non-Windows host.
return value.StartsWith(@"\\", StringComparison.Ordinal);
}
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
{
if (string.IsNullOrWhiteSpace(value))
@@ -0,0 +1,65 @@
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>
/// Security hot-path options bound from <c>MxGateway:Security</c>. Groups the API-key verification
/// cache and <c>last_used</c> write-coalescing knobs (SEC-08) with the login and API-key
/// rate-limiting knobs (SEC-11). Defaults preserve safe, low-overhead behaviour without requiring
/// operators to configure anything.
/// </summary>
public sealed class SecurityOptions
{
/// <summary>The configuration sub-section this binds from.</summary>
public const string SectionName = "MxGateway:Security";
/// <summary>
/// Gets the time-to-live, in seconds, of a cached successful API-key verification. A cache hit
/// within this window skips the per-call SQLite read (and the library verifier's coupled
/// <c>last_used</c> write). Explicit invalidation on gateway-initiated revoke/rotate is the
/// primary correctness mechanism; this short TTL is the backstop for out-of-band mutations.
/// Set to <c>0</c> to disable caching. Default is 15 seconds.
/// </summary>
public int ApiKeyVerificationCacheSeconds { get; init; } = 15;
/// <summary>
/// Gets the coalescing window, in seconds, for the <c>last_used_utc</c> write. The library
/// verifier couples the write into every verification; this decorator forwards at most one
/// <c>MarkUsed</c> per key per window, so a hammered key produces at most one write per window
/// rather than one per authenticated RPC. Set to <c>0</c> to forward every write. Default is
/// 60 seconds (at most one write per key per minute).
/// </summary>
public int ApiKeyLastUsedCoalesceSeconds { get; init; } = 60;
/// <summary>
/// Gets the maximum number of <c>POST /auth/login</c> attempts permitted per remote IP within
/// <see cref="LoginRateLimitWindowSeconds"/> before requests are rejected with HTTP 429. Default
/// is 10.
/// </summary>
public int LoginRateLimitPermitLimit { get; init; } = 10;
/// <summary>
/// Gets the fixed-window length, in seconds, for the <c>POST /auth/login</c> per-IP rate limit.
/// Default is 60 seconds.
/// </summary>
public int LoginRateLimitWindowSeconds { get; init; } = 60;
/// <summary>
/// Gets the number of consecutive failed API-key verifications, per peer, within
/// <see cref="ApiKeyFailureWindowSeconds"/> that trips the in-process short-circuit. Once tripped,
/// the gRPC auth path rejects further attempts before the store read; a successful verification
/// resets the peer's counter. Default is 10.
/// </summary>
public int ApiKeyFailureLimit { get; init; } = 10;
/// <summary>
/// Gets the sliding-window length, in seconds, over which API-key verification failures are
/// counted per peer. Default is 60 seconds.
/// </summary>
public int ApiKeyFailureWindowSeconds { get; init; } = 60;
/// <summary>
/// Gets the maximum number of distinct peers tracked by the API-key failure counter. The counter
/// is a bounded LRU so a spray of unique peer keys cannot grow memory without limit. Default is
/// 4096.
/// </summary>
public int ApiKeyFailureTrackedPeers { get; init; } = 4096;
}
@@ -15,7 +15,8 @@ public sealed class DashboardApiKeyManagementService(
ApiKeyAdminCommands adminCommands,
IApiKeyAdminStore adminStore,
IAuditWriter auditWriter,
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
IHttpContextAccessor httpContextAccessor,
IApiKeyCacheInvalidator? cacheInvalidator = null) : IDashboardApiKeyManagementService
{
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
private const string PepperUnavailableMarker = "pepper unavailable";
@@ -100,6 +101,10 @@ public sealed class DashboardApiKeyManagementService(
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
.ConfigureAwait(false);
// SEC-08: drop any cached verification for this key in THIS gateway process so the revoke
// takes effect immediately rather than after the verification-cache TTL elapses.
cacheInvalidator?.Invalidate(normalizedKeyId);
await WriteDashboardAuditAsync(
user,
normalizedKeyId,
@@ -138,6 +143,10 @@ public sealed class DashboardApiKeyManagementService(
.RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
.ConfigureAwait(false);
// SEC-08: the old secret's cached verification must not outlive the rotate — evict it so
// the superseded secret stops authenticating within this process immediately.
cacheInvalidator?.Invalidate(normalizedKeyId);
bool succeeded = rotated.Token is not null;
await WriteDashboardAuditAsync(
@@ -182,6 +191,10 @@ public sealed class DashboardApiKeyManagementService(
.DeleteAsync(normalizedKeyId, cancellationToken)
.ConfigureAwait(false);
// SEC-08: a deleted key is only reachable after a prior revoke, but evict defensively so no
// cached verification survives the delete.
cacheInvalidator?.Invalidate(normalizedKeyId);
await WriteDashboardAuditAsync(
user,
normalizedKeyId,
@@ -1,4 +1,5 @@
using System.Text.Encodings.Web;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using ZB.MOM.WW.MxGateway.Server.Configuration;
@@ -10,6 +11,41 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>Endpoint extensions for registering the gateway dashboard routes.</summary>
public static class DashboardEndpointRouteBuilderExtensions
{
/// <summary>
/// The named rate-limiter policy applied to the <c>POST /auth/login</c> route (SEC-11). Registered
/// in <c>GatewayApplication</c> via <see cref="GetLoginRateLimitPartition"/>.
/// </summary>
internal const string LoginRateLimiterPolicy = "dashboard-login";
/// <summary>
/// Builds the fixed-window rate-limit partition for a login request, keyed on the remote IP.
/// Shared by the production policy registration and the tests so both exercise identical limits.
/// </summary>
/// <param name="httpContext">The inbound request.</param>
/// <param name="security">The bound security options carrying the login-limit knobs.</param>
/// <returns>The per-IP fixed-window partition.</returns>
internal static RateLimitPartition<string> GetLoginRateLimitPartition(
HttpContext httpContext,
SecurityOptions security)
{
string partitionKey = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = security.LoginRateLimitPermitLimit,
Window = TimeSpan.FromSeconds(security.LoginRateLimitWindowSeconds),
QueueLimit = 0,
AutoReplenishment = true,
});
}
// Test seam: a standalone partitioned limiter over the production partition logic, so a test can
// acquire leases and assert the (permit+1)th is rejected without an HTTP server.
internal static PartitionedRateLimiter<HttpContext> CreateLoginRateLimiter(SecurityOptions security)
=> PartitionedRateLimiter.Create<HttpContext, string>(
ctx => GetLoginRateLimitPartition(ctx, security));
/// <summary>Maps all gateway dashboard routes including login, logout, and Razor components.</summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <returns>The route builder for chaining.</returns>
@@ -40,6 +76,8 @@ public static class DashboardEndpointRouteBuilderExtensions
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
PostLoginAsync(httpContext, antiforgery, authenticator))
.AllowAnonymous()
// SEC-11: throttle credential stuffing before the LDAP bind is relayed to the directory.
.RequireRateLimiting(LoginRateLimiterPolicy)
.WithName("DashboardLoginPost");
endpoints.MapPost(
@@ -10,6 +10,16 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// string (used by WebSocket upgrade requests that can't carry custom headers)
/// and validating it via <see cref="HubTokenService"/>.
/// </summary>
/// <remarks>
/// Carrying the token in the <c>?access_token=</c> query string is the standard SignalR pattern:
/// the WebSocket upgrade handshake cannot attach a custom <c>Authorization</c> header, so the JS
/// client appends the token to the URL. Because a query string is far easier to capture than a
/// header (proxy access logs, browser history, <c>Referer</c>), this value must NEVER be
/// request-logged. Serilog HTTP request logging is intentionally not enabled; if it is ever added,
/// scrub the <c>access_token</c> query parameter before the URL reaches a sink. The short token
/// lifetime (<see cref="HubTokenService.TokenLifetime"/>) is the backstop that bounds exposure of a
/// leaked token.
/// </remarks>
public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly HubTokenService _tokens;
@@ -26,7 +26,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class HubTokenService
{
private const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1";
private static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(30);
// Hub bearer tokens are single-purpose, data-protection-encrypted, and NOT server-side
// revocable. A short lifetime bounds the exposure window of a token captured from a proxy
// or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and
// bounds how long a stale role set survives a role change. Five minutes is transparent to
// clients because DashboardHubConnectionFactory mints a fresh token on every (re)connect;
// see docs/GatewayDashboardDesign.md. Heavier jti-denylist revocation is deliberately
// deferred until per-session hub ACLs land (SEC-25), when tokens gain session binding.
internal static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(5);
private readonly ITimeLimitedDataProtector _protector;
@@ -41,14 +49,24 @@ public sealed class HubTokenService
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
/// <param name="user">The claims principal representing the user.</param>
/// <returns>The data-protected bearer token string.</returns>
public string Issue(ClaimsPrincipal user)
public string Issue(ClaimsPrincipal user) => Issue(user, TokenLifetime);
/// <summary>
/// Issues a bearer token that expires after the supplied lifetime. Test seam so a caller can
/// mint an already-expired token deterministically without wall-clock delay; production callers
/// use <see cref="Issue(ClaimsPrincipal)"/> and get <see cref="TokenLifetime"/>.
/// </summary>
/// <param name="user">The claims principal representing the user.</param>
/// <param name="lifetime">The lifetime applied to the token; a non-positive value yields an already-expired token.</param>
/// <returns>The data-protected bearer token string.</returns>
internal string Issue(ClaimsPrincipal user, TimeSpan lifetime)
{
ArgumentNullException.ThrowIfNull(user);
HubTokenPayload payload = new(
user.Identity?.Name,
user.FindFirstValue(ClaimTypes.NameIdentifier),
[.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)]);
return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime);
return _protector.Protect(JsonSerializer.Serialize(payload), lifetime);
}
/// <summary>Validates a token and returns the equivalent claims principal; null when invalid or expired.</summary>
@@ -41,6 +41,9 @@ public static class GatewayApplication
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
// SEC-11: the rate limiter must run after routing has selected the endpoint (so its
// RequireRateLimiting policy metadata is visible) and before the endpoint executes.
app.UseRateLimiter();
app.UseAntiforgery();
app.MapGatewayEndpoints();
@@ -68,6 +71,7 @@ public static class GatewayApplication
builder.Services.AddGatewayConfiguration(builder.Configuration);
builder.Services.AddSqliteAuthStore(builder.Configuration);
builder.Services.AddGatewayGrpcAuthorization();
AddLoginRateLimiter(builder);
builder.Services.AddHealthChecks()
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
"auth-store",
@@ -101,6 +105,26 @@ public static class GatewayApplication
return builder;
}
// SEC-11: register the named fixed-window rate-limiter policy applied to POST /auth/login. The
// limit knobs are read from MxGateway:Security at startup; the per-request partition is keyed on
// the remote IP (see DashboardEndpointRouteBuilderExtensions.GetLoginRateLimitPartition).
private static void AddLoginRateLimiter(WebApplicationBuilder builder)
{
SecurityOptions security =
builder.Configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>()
?? new SecurityOptions();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy(
DashboardEndpointRouteBuilderExtensions.LoginRateLimiterPolicy,
httpContext => DashboardEndpointRouteBuilderExtensions.GetLoginRateLimitPartition(
httpContext,
security));
});
}
private static void ConfigureSelfSignedTls(WebApplicationBuilder builder)
{
if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration))
@@ -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>()