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:
+85
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user