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));
}
}