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:
@@ -14,7 +14,15 @@ public sealed class GatewayOptionsTests
|
||||
GatewayOptions options = BindOptions(new Dictionary<string, string?>());
|
||||
|
||||
Assert.Equal(AuthenticationMode.ApiKey, options.Authentication.Mode);
|
||||
Assert.Equal(@"C:\ProgramData\MxGateway\gateway-auth.db", options.Authentication.SqlitePath);
|
||||
// SEC-01: the default is derived from CommonApplicationData (C:\ProgramData on Windows,
|
||||
// /usr/share on Unix) rather than a Windows literal, so assert against the same derivation
|
||||
// to stay platform-correct.
|
||||
Assert.Equal(
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"MxGateway",
|
||||
"gateway-auth.db"),
|
||||
options.Authentication.SqlitePath);
|
||||
Assert.Equal("MxGateway:ApiKeyPepper", options.Authentication.PepperSecretName);
|
||||
Assert.True(options.Authentication.RunMigrationsOnStartup);
|
||||
|
||||
|
||||
@@ -683,4 +683,92 @@ public sealed class GatewayOptionsValidatorTests
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
// ---- SEC-11: MxGateway:Security validation ----
|
||||
|
||||
private static GatewayOptions WithSecurity(SecurityOptions security) => new() { Security = security };
|
||||
|
||||
/// <summary>Verifies the default security options pass validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WithDefaultSecurityOptions()
|
||||
{
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, WithSecurity(new SecurityOptions()));
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a zero verification-cache TTL is allowed (disables caching).</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenVerificationCacheSecondsZero()
|
||||
{
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||
null,
|
||||
WithSecurity(new SecurityOptions { ApiKeyVerificationCacheSeconds = 0 }));
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a negative verification-cache TTL fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenVerificationCacheSecondsNegative()
|
||||
{
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||
null,
|
||||
WithSecurity(new SecurityOptions { ApiKeyVerificationCacheSeconds = -1 }));
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("ApiKeyVerificationCacheSeconds"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a negative last-used coalesce window fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenLastUsedCoalesceSecondsNegative()
|
||||
{
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||
null,
|
||||
WithSecurity(new SecurityOptions { ApiKeyLastUsedCoalesceSeconds = -5 }));
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("ApiKeyLastUsedCoalesceSeconds"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a zero login rate-limit permit fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenLoginRateLimitPermitLimitZero()
|
||||
{
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||
null,
|
||||
WithSecurity(new SecurityOptions { LoginRateLimitPermitLimit = 0 }));
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("LoginRateLimitPermitLimit"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a zero login rate-limit window fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenLoginRateLimitWindowSecondsZero()
|
||||
{
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||
null,
|
||||
WithSecurity(new SecurityOptions { LoginRateLimitWindowSeconds = 0 }));
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("LoginRateLimitWindowSeconds"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a zero API-key failure limit fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenApiKeyFailureLimitZero()
|
||||
{
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||
null,
|
||||
WithSecurity(new SecurityOptions { ApiKeyFailureLimit = 0 }));
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("ApiKeyFailureLimit"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a zero tracked-peer cap fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenApiKeyFailureTrackedPeersZero()
|
||||
{
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||
null,
|
||||
WithSecurity(new SecurityOptions { ApiKeyFailureTrackedPeers = 0 }));
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("ApiKeyFailureTrackedPeers"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Net;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// SEC-11: the <c>POST /auth/login</c> fixed-window per-IP rate limiter. Exercised through the same
|
||||
/// partition factory the production policy registers, so the test pins the real permit limit and
|
||||
/// per-IP partitioning without standing up an HTTP server.
|
||||
/// </summary>
|
||||
public sealed class DashboardLoginRateLimitTests
|
||||
{
|
||||
/// <summary>A burst beyond the permit limit from one IP has its excess attempts rejected (HTTP 429 in the pipeline).</summary>
|
||||
[Fact]
|
||||
public void LoginRateLimiter_BurstBeyondPermitLimit_RejectsExcessFromSameIp()
|
||||
{
|
||||
SecurityOptions security = new()
|
||||
{
|
||||
LoginRateLimitPermitLimit = 3,
|
||||
LoginRateLimitWindowSeconds = 60,
|
||||
};
|
||||
using PartitionedRateLimiter<HttpContext> limiter =
|
||||
DashboardEndpointRouteBuilderExtensions.CreateLoginRateLimiter(security);
|
||||
HttpContext context = ContextWithIp("10.0.0.7");
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
using RateLimitLease lease = limiter.AttemptAcquire(context);
|
||||
Assert.True(lease.IsAcquired);
|
||||
}
|
||||
|
||||
using RateLimitLease rejected = limiter.AttemptAcquire(context);
|
||||
Assert.False(rejected.IsAcquired);
|
||||
}
|
||||
|
||||
/// <summary>Distinct client IPs are partitioned independently — one IP's burst does not starve another.</summary>
|
||||
[Fact]
|
||||
public void LoginRateLimiter_DistinctIps_PartitionedIndependently()
|
||||
{
|
||||
SecurityOptions security = new()
|
||||
{
|
||||
LoginRateLimitPermitLimit = 1,
|
||||
LoginRateLimitWindowSeconds = 60,
|
||||
};
|
||||
using PartitionedRateLimiter<HttpContext> limiter =
|
||||
DashboardEndpointRouteBuilderExtensions.CreateLoginRateLimiter(security);
|
||||
|
||||
using RateLimitLease first = limiter.AttemptAcquire(ContextWithIp("10.0.0.1"));
|
||||
using RateLimitLease exhaustedForFirst = limiter.AttemptAcquire(ContextWithIp("10.0.0.1"));
|
||||
using RateLimitLease second = limiter.AttemptAcquire(ContextWithIp("10.0.0.2"));
|
||||
|
||||
Assert.True(first.IsAcquired);
|
||||
Assert.False(exhaustedForFirst.IsAcquired);
|
||||
Assert.True(second.IsAcquired);
|
||||
}
|
||||
|
||||
private static HttpContext ContextWithIp(string ip)
|
||||
{
|
||||
DefaultHttpContext context = new();
|
||||
context.Connection.RemoteIpAddress = IPAddress.Parse(ip);
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -115,4 +115,63 @@ public sealed class HubTokenServiceTests
|
||||
|
||||
Assert.Null(service.Validate("this-is-not-a-protected-payload"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue/validate round-trip: a freshly minted token (default <see cref="HubTokenService.TokenLifetime"/>)
|
||||
/// validates and reconstructs the caller's identity and roles.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IssueThenValidate_FreshToken_RoundTripsIdentityAndRoles()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
new Claim(ClaimTypes.Name, "bob"),
|
||||
new Claim(ClaimTypes.NameIdentifier, "bob-id"),
|
||||
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
||||
new Claim(ClaimTypes.Role, DashboardRoles.Admin),
|
||||
],
|
||||
authenticationType: "test",
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: ClaimTypes.Role);
|
||||
|
||||
string token = service.Issue(new ClaimsPrincipal(identity));
|
||||
ClaimsPrincipal? result = service.Validate(token);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("bob", result.Identity?.Name);
|
||||
Assert.True(result.IsInRole(DashboardRoles.Viewer));
|
||||
Assert.True(result.IsInRole(DashboardRoles.Admin));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The default token lifetime is the short (5-minute) window mandated by SEC-05, not the
|
||||
/// former 30-minute window. Pins the value so a regression that widens the exposure window
|
||||
/// of an irrevocable, query-string-carried token is caught in CI.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TokenLifetime_IsFiveMinutes()
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), HubTokenService.TokenLifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A token whose lifetime has elapsed is rejected by <see cref="HubTokenService.Validate"/>.
|
||||
/// Uses the internal lifetime-issuing seam with a negative lifetime so the token is already
|
||||
/// expired at mint time — deterministic, no wall-clock delay.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_ExpiredToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(ClaimTypes.Name, "carol")],
|
||||
authenticationType: "test");
|
||||
|
||||
string expiredToken = service.Issue(
|
||||
new ClaimsPrincipal(identity),
|
||||
TimeSpan.FromMinutes(-1));
|
||||
|
||||
Assert.Null(service.Validate(expiredToken));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
-4
@@ -327,7 +327,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
() => interceptor.ServerStreamingServerHandler(
|
||||
new StreamAlarmsRequest(),
|
||||
new QueryActiveAlarmsRequest(),
|
||||
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||
(_, _, _) => Task.CompletedTask));
|
||||
@@ -347,7 +347,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
RecordingServerStreamWriter<ActiveAlarmSnapshot> streamWriter = new();
|
||||
|
||||
await interceptor.ServerStreamingServerHandler(
|
||||
new StreamAlarmsRequest(),
|
||||
new QueryActiveAlarmsRequest(),
|
||||
streamWriter,
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||
async (_, writer, _) =>
|
||||
@@ -358,6 +358,102 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
Assert.Single(streamWriter.Messages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SEC-11: once a peer has exceeded the failure limit, the interceptor short-circuits with
|
||||
/// <see cref="StatusCode.ResourceExhausted"/> BEFORE calling the verifier, so an online guessing
|
||||
/// loop stops spending a store read per attempt. A verifier that always fails is used; after the
|
||||
/// limit is reached the verifier is no longer invoked.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_ExceedsFailureLimit_ShortCircuitsBeforeVerify()
|
||||
{
|
||||
CountingFailureVerifier verifier = new(Failure(ApiKeyFailure.SecretMismatch));
|
||||
ApiKeyFailureLimiter limiter = new(
|
||||
limit: 3,
|
||||
window: TimeSpan.FromMinutes(1),
|
||||
maxPeers: 16,
|
||||
clock: TimeProvider.System);
|
||||
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
||||
verifier,
|
||||
new GatewayRequestIdentityAccessor(),
|
||||
failureLimiter: limiter);
|
||||
|
||||
// The first three attempts reach the verifier and fail (recording a failure each time).
|
||||
for (int attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
RpcException failure = await Assert.ThrowsAsync<RpcException>(
|
||||
() => interceptor.UnaryServerHandler(
|
||||
new OpenSessionRequest(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_bad-secret"),
|
||||
(_, _) => Task.FromResult(new OpenSessionReply())));
|
||||
Assert.Equal(StatusCode.Unauthenticated, failure.StatusCode);
|
||||
}
|
||||
|
||||
Assert.Equal(3, verifier.CallCount);
|
||||
|
||||
// The fourth attempt is short-circuited: ResourceExhausted, and the verifier is NOT called.
|
||||
RpcException throttled = await Assert.ThrowsAsync<RpcException>(
|
||||
() => interceptor.UnaryServerHandler(
|
||||
new OpenSessionRequest(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_bad-secret"),
|
||||
(_, _) => Task.FromResult(new OpenSessionReply())));
|
||||
|
||||
Assert.Equal(StatusCode.ResourceExhausted, throttled.StatusCode);
|
||||
Assert.Equal(3, verifier.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SEC-11: a successful verification resets the peer's failure counter, so accumulated failures
|
||||
/// from a fat-fingered secret do not lock out a client that subsequently authenticates.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_SuccessResetsFailureCounter()
|
||||
{
|
||||
ApiKeyFailureLimiter limiter = new(
|
||||
limit: 3,
|
||||
window: TimeSpan.FromMinutes(1),
|
||||
maxPeers: 16,
|
||||
clock: TimeProvider.System);
|
||||
|
||||
// Two failures against the same key id, then a success (which resets), then two more
|
||||
// failures — without the reset the fifth attempt would be blocked at the limit of 3.
|
||||
GatewayGrpcAuthorizationInterceptor failing = CreateInterceptor(
|
||||
new FakeApiKeyVerifier(Failure(ApiKeyFailure.SecretMismatch)),
|
||||
new GatewayRequestIdentityAccessor(),
|
||||
failureLimiter: limiter);
|
||||
GatewayGrpcAuthorizationInterceptor succeeding = CreateInterceptor(
|
||||
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)),
|
||||
new GatewayRequestIdentityAccessor(),
|
||||
failureLimiter: limiter);
|
||||
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
await Assert.ThrowsAsync<RpcException>(
|
||||
() => failing.UnaryServerHandler(
|
||||
new OpenSessionRequest(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_bad"),
|
||||
(_, _) => Task.FromResult(new OpenSessionReply())));
|
||||
}
|
||||
|
||||
await succeeding.UnaryServerHandler(
|
||||
new OpenSessionRequest(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_good"),
|
||||
(_, _) => Task.FromResult(new OpenSessionReply { SessionId = "s" }));
|
||||
|
||||
// Post-reset: two more failures still map to Unauthenticated (not ResourceExhausted).
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
RpcException ex = await Assert.ThrowsAsync<RpcException>(
|
||||
() => failing.UnaryServerHandler(
|
||||
new OpenSessionRequest(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_bad"),
|
||||
(_, _) => Task.FromResult(new OpenSessionReply())));
|
||||
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static MxAccessGatewayService CreateService(
|
||||
ISessionManager sessionManager,
|
||||
IGatewayRequestIdentityAccessor identityAccessor)
|
||||
@@ -377,7 +473,8 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
private static GatewayGrpcAuthorizationInterceptor CreateInterceptor(
|
||||
IApiKeyVerifier apiKeyVerifier,
|
||||
IGatewayRequestIdentityAccessor identityAccessor,
|
||||
AuthenticationMode authenticationMode = AuthenticationMode.ApiKey)
|
||||
AuthenticationMode authenticationMode = AuthenticationMode.ApiKey,
|
||||
ApiKeyFailureLimiter? failureLimiter = null)
|
||||
{
|
||||
return new GatewayGrpcAuthorizationInterceptor(
|
||||
apiKeyVerifier,
|
||||
@@ -389,7 +486,12 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
{
|
||||
Mode = authenticationMode
|
||||
}
|
||||
}));
|
||||
}),
|
||||
failureLimiter ?? new ApiKeyFailureLimiter(
|
||||
limit: 1000,
|
||||
window: TimeSpan.FromMinutes(1),
|
||||
maxPeers: 1000,
|
||||
clock: TimeProvider.System));
|
||||
}
|
||||
|
||||
private static ApiKeyVerification SuccessWithScopes(params string[] scopes)
|
||||
@@ -531,6 +633,22 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CountingFailureVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
||||
{
|
||||
/// <summary>Gets the number of times the verifier was invoked.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
/// <summary>Returns the configured result and counts the invocation.</summary>
|
||||
/// <param name="authorizationHeader">The authorization header to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The configured verification result.</returns>
|
||||
public Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeApiKeyVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
||||
{
|
||||
/// <summary>Gets whether the verifier was called.</summary>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Defaults the host environment to Development for the whole test assembly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Many tests build the full gateway host through <c>GatewayApplication.Build</c> against the dev
|
||||
/// <c>appsettings.json</c> (which ships <c>Ldap:Transport=None</c> and other dev-only defaults).
|
||||
/// An unset environment resolves to Production, where the SEC-04/06 production guards fail startup
|
||||
/// by design — so those host-building tests would trip the guards. Setting the environment to
|
||||
/// Development once, before any test runs, keeps that suite exercising app wiring rather than
|
||||
/// production-config validation. Tests that specifically assert production behavior construct
|
||||
/// <c>GatewayOptionsValidator</c> with its <c>isProduction</c> constructor (no host environment) and
|
||||
/// are unaffected; a test that needs Production can still pass <c>--environment=Production</c>, which
|
||||
/// overrides this default.
|
||||
/// </remarks>
|
||||
internal static class TestHostEnvironmentInitializer
|
||||
{
|
||||
[ModuleInitializer]
|
||||
internal static void SetDevelopmentEnvironmentDefault()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user