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:
@@ -0,0 +1,201 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
|
||||
using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// SEC-08 hot-path decorators. Covers both mechanisms: <see cref="CachingApiKeyVerifier"/>
|
||||
/// (read/verification coalescing plus revoke/rotate invalidation) and
|
||||
/// <see cref="CoalescingMarkApiKeyStore"/> (the <c>last_used</c> write coalescing that keeps the
|
||||
/// per-RPC database write off the throughput ceiling).
|
||||
/// </summary>
|
||||
public sealed class CachingApiKeyVerifierTests
|
||||
{
|
||||
private const string Header = "Bearer mxgw_operator01_super-secret";
|
||||
|
||||
/// <summary>A cache hit within the TTL returns the cached result and never calls the inner verifier.</summary>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_RepeatedWithinTtl_CallsInnerOnce()
|
||||
{
|
||||
FakeVerifier inner = new(Success("operator01"));
|
||||
using MemoryCache cache = NewCache();
|
||||
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(15));
|
||||
|
||||
ApiKeyVerification first = await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
ApiKeyVerification second = await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
|
||||
Assert.True(first.Succeeded);
|
||||
Assert.True(second.Succeeded);
|
||||
Assert.Equal(1, inner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>Different presented secrets are cached under distinct keys (no cross-secret aliasing).</summary>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_DifferentTokens_NotAliased()
|
||||
{
|
||||
FakeVerifier inner = new(Success("operator01"));
|
||||
using MemoryCache cache = NewCache();
|
||||
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(15));
|
||||
|
||||
await verifier.VerifyAsync("Bearer mxgw_operator01_secret-a", CancellationToken.None);
|
||||
await verifier.VerifyAsync("Bearer mxgw_operator01_secret-b", CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, inner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>Failed verifications are never cached; every attempt reaches the inner verifier.</summary>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FailedVerification_NotCached()
|
||||
{
|
||||
FakeVerifier inner = new(Failure(ApiKeyFailure.SecretMismatch));
|
||||
using MemoryCache cache = NewCache();
|
||||
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(15));
|
||||
|
||||
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, inner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>A TTL of zero disables caching: the inner verifier is called on every request.</summary>
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ZeroTtl_DisablesCache()
|
||||
{
|
||||
FakeVerifier inner = new(Success("operator01"));
|
||||
using MemoryCache cache = NewCache();
|
||||
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.Zero);
|
||||
|
||||
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, inner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>Invalidating a key id (revoke/rotate) drops its cached verification, forcing a re-verify.</summary>
|
||||
[Fact]
|
||||
public async Task Invalidate_DropsCachedEntry_ForcesReverify()
|
||||
{
|
||||
FakeVerifier inner = new(Success("operator01"));
|
||||
using MemoryCache cache = NewCache();
|
||||
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(30));
|
||||
|
||||
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
Assert.Equal(1, inner.CallCount);
|
||||
|
||||
((IApiKeyCacheInvalidator)verifier).Invalidate("operator01");
|
||||
|
||||
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
Assert.Equal(2, inner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The store decorator coalesces repeated <c>MarkUsed</c> writes for the same key inside the
|
||||
/// window down to a single forwarded write — the ≤1/min guarantee for <c>last_used_utc</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CoalescingStore_RepeatedMarksWithinWindow_ForwardsOnce()
|
||||
{
|
||||
FakeStore inner = new();
|
||||
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
|
||||
CoalescingMarkApiKeyStore store = new(inner, TimeSpan.FromMinutes(1), clock);
|
||||
|
||||
// Ten rapid authenticated calls in the same minute.
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None);
|
||||
clock.Advance(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
Assert.Equal(1, inner.MarkUsedCount);
|
||||
}
|
||||
|
||||
/// <summary>After the window elapses the next mark is forwarded again (staleness is bounded, not frozen).</summary>
|
||||
[Fact]
|
||||
public async Task CoalescingStore_AfterWindow_ForwardsAgain()
|
||||
{
|
||||
FakeStore inner = new();
|
||||
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
|
||||
CoalescingMarkApiKeyStore store = new(inner, TimeSpan.FromMinutes(1), clock);
|
||||
|
||||
await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None);
|
||||
clock.Advance(TimeSpan.FromSeconds(61));
|
||||
await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, inner.MarkUsedCount);
|
||||
}
|
||||
|
||||
/// <summary>Distinct keys are coalesced independently.</summary>
|
||||
[Fact]
|
||||
public async Task CoalescingStore_DistinctKeys_TrackedSeparately()
|
||||
{
|
||||
FakeStore inner = new();
|
||||
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
|
||||
CoalescingMarkApiKeyStore store = new(inner, TimeSpan.FromMinutes(1), clock);
|
||||
|
||||
await store.MarkUsedAsync("key-a", clock.GetUtcNow(), CancellationToken.None);
|
||||
await store.MarkUsedAsync("key-b", clock.GetUtcNow(), CancellationToken.None);
|
||||
await store.MarkUsedAsync("key-a", clock.GetUtcNow(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, inner.MarkUsedCount);
|
||||
}
|
||||
|
||||
/// <summary>A zero window disables coalescing: every mark is forwarded.</summary>
|
||||
[Fact]
|
||||
public async Task CoalescingStore_ZeroWindow_ForwardsEveryMark()
|
||||
{
|
||||
FakeStore inner = new();
|
||||
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
|
||||
CoalescingMarkApiKeyStore store = new(inner, TimeSpan.Zero, clock);
|
||||
|
||||
await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None);
|
||||
await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, inner.MarkUsedCount);
|
||||
}
|
||||
|
||||
private static MemoryCache NewCache() => new(new MemoryCacheOptions());
|
||||
|
||||
private static ApiKeyVerification Success(string keyId) => new(
|
||||
Succeeded: true,
|
||||
Identity: new LibApiKeyIdentity(
|
||||
KeyId: keyId,
|
||||
DisplayName: "Operator Key",
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal),
|
||||
Constraints: null),
|
||||
Failure: null);
|
||||
|
||||
private static ApiKeyVerification Failure(ApiKeyFailure failure) =>
|
||||
new(Succeeded: false, Identity: null, Failure: failure);
|
||||
|
||||
private sealed class FakeVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeStore : IApiKeyStore
|
||||
{
|
||||
public int MarkUsedCount { get; private set; }
|
||||
|
||||
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
||||
=> Task.FromResult<ApiKeyRecord?>(null);
|
||||
|
||||
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
|
||||
=> Task.FromResult<ApiKeyRecord?>(null);
|
||||
|
||||
public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
||||
{
|
||||
MarkUsedCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user