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
+32
View File
@@ -101,6 +101,38 @@ return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
`DisplayName`, `Scopes`, and `Constraints`) and is the type downstream `DisplayName`, `Scopes`, and `Constraints`) and is the type downstream
authorization code consumes. authorization code consumes.
### Hot-path caching and last-used coalescing
Left unmediated, every authenticated gRPC call costs a SQLite read plus a
`last_used_utc` **write** (the library verifier couples `MarkKeyUsed` into
`VerifyAsync`), which makes the auth store the throughput ceiling on the
bulk-read workload. The gateway layers two decorators over the shared library's
registrations (in `AuthStoreServiceCollectionExtensions`) — it does not edit the
library:
- **`CachingApiKeyVerifier`** wraps `IApiKeyVerifier` with an `IMemoryCache`
entry per successful verification, keyed on a SHA-256 hash of the presented
token (never the plaintext secret). A cache hit within
`MxGateway:Security:ApiKeyVerificationCacheSeconds` (default 15 s) returns the
cached result without touching the store, so both the read and the coupled
write are skipped. Only successes are cached; failures always reach the inner
verifier. On a gateway-initiated revoke/rotate/delete the dashboard admin
service calls `IApiKeyCacheInvalidator.Invalidate(keyId)`, evicting the cached
entry immediately. The short TTL is the backstop for out-of-band mutations
(a direct DB edit, or a revoke run by the separate `apikey` CLI process, whose
in-memory cache is not the running gateway's cache).
- **`CoalescingMarkApiKeyStore`** wraps `IApiKeyStore` and forwards at most one
`MarkUsed` write per key per `MxGateway:Security:ApiKeyLastUsedCoalesceSeconds`
(default 60 s), so even under a cache miss the `last_used_utc` write is bounded
to roughly one per key per minute rather than one per RPC. `last_used_utc` is a
coarse staleness hint, not an audit record (audit rows are written separately),
so bounded staleness of up to one window is acceptable.
`GatewayApiKeyIdentityMapper` additionally memoizes the constraints-JSON
deserialization by blob, so the per-call parse on the mapped identity collapses to
a dictionary lookup. Both windows are configurable and may be set to `0` to
disable the respective mechanism; see [GatewayConfiguration](./GatewayConfiguration.md).
## Storage ## Storage
The gateway keeps API key state in a dedicated SQLite database. SQLite is sufficient because credential volume is small, the gateway runs as a single process, and the file is straightforward to back up and rotate independently of the main application data. The gateway keeps API key state in a dedicated SQLite database. SQLite is sufficient because credential volume is small, the gateway runs as a single process, and the file is straightforward to back up and rotate independently of the main application data.
+9 -2
View File
@@ -89,6 +89,12 @@ The flow is:
The status codes are deliberately distinct: `Unauthenticated` signals "we do not know who you are," and `PermissionDenied` signals "we know who you are, but you cannot do this." Treating the two as the same code would make troubleshooting harder for client implementations. The status codes are deliberately distinct: `Unauthenticated` signals "we do not know who you are," and `PermissionDenied` signals "we know who you are, but you cannot do this." Treating the two as the same code would make troubleshooting harder for client implementations.
### Rate limiting the auth surface (SEC-11)
Before the verification store read, the helper checks a cheap in-process per-peer failure counter (`ApiKeyFailureLimiter`). A peer that has accumulated more than `MxGateway:Security:ApiKeyFailureLimit` failed attempts inside the sliding `ApiKeyFailureWindowSeconds` window is short-circuited with `StatusCode.ResourceExhausted` — so online guessing of API-key secrets cannot spend a SQLite read (and, in a naive design, a cache miss) per attempt. The peer is keyed on the presented key id where the token parses, falling back to the transport peer address; keying on key id throttles a single abusive credential without penalizing co-located clients behind a shared NAT. A successful verification resets the peer's counter. The counter is a bounded LRU (`ApiKeyFailureTrackedPeers`) so it cannot grow without limit. `ResourceExhausted` reveals only that throttling is in effect, not whether any particular secret was valid, preserving the opaque-failure property.
The dashboard login surface is throttled independently: `POST /auth/login` carries a fixed-window ASP.NET Core rate-limiter policy keyed per remote IP (`MxGateway:Security:LoginRateLimit*`), rejecting a burst with HTTP 429 before the LDAP bind is relayed to the directory. See [GatewayConfiguration](./GatewayConfiguration.md#security-options).
## Scope Resolution ## Scope Resolution
`GatewayGrpcScopeResolver` is a stateless singleton that switches on the runtime request type. Top-level RPC requests map directly: `GatewayGrpcScopeResolver` is a stateless singleton that switches on the runtime request type. Top-level RPC requests map directly:
@@ -104,6 +110,7 @@ public string ResolveRequiredScope(object request)
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified), MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite, AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
StreamAlarmsRequest => GatewayScopes.EventsRead, StreamAlarmsRequest => GatewayScopes.EventsRead,
QueryActiveAlarmsRequest => GatewayScopes.EventsRead,
TestConnectionRequest or TestConnectionRequest or
GetLastDeployTimeRequest or GetLastDeployTimeRequest or
DiscoverHierarchyRequest or DiscoverHierarchyRequest or
@@ -113,7 +120,7 @@ public string ResolveRequiredScope(object request)
} }
``` ```
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` shares the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so it carries `events:read`. Both alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce. The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
`MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope: `MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope:
@@ -205,7 +212,7 @@ blocking constraint; secured values and raw credentials are never logged.
|----------|-------|--------------| |----------|-------|--------------|
| `SessionOpen` | `session:open` | `OpenSessionRequest` | | `SessionOpen` | `session:open` | `OpenSessionRequest` |
| `SessionClose` | `session:close` | `CloseSessionRequest` | | `SessionClose` | `session:close` | `CloseSessionRequest` |
| `EventsRead` | `events:read` | `StreamEventsRequest`, `StreamAlarmsRequest`, `MxCommandKind.DrainEvents` | | `EventsRead` | `events:read` | `StreamEventsRequest`, `StreamAlarmsRequest`, `QueryActiveAlarmsRequest`, `MxCommandKind.DrainEvents` |
| `InvokeRead` | `invoke:read` | `MxCommandRequest` for read-style command kinds (`Register`, `AddItem`, `Advise`, `ReadBulk`, and any kind not otherwise mapped) | | `InvokeRead` | `invoke:read` | `MxCommandRequest` for read-style command kinds (`Register`, `AddItem`, `Advise`, `ReadBulk`, and any kind not otherwise mapped) |
| `InvokeWrite` | `invoke:write` | `AcknowledgeAlarmRequest`, `MxCommandKind.Write`, `MxCommandKind.Write2`, `MxCommandKind.WriteBulk`, `MxCommandKind.Write2Bulk` | | `InvokeWrite` | `invoke:write` | `AcknowledgeAlarmRequest`, `MxCommandKind.Write`, `MxCommandKind.Write2`, `MxCommandKind.WriteBulk`, `MxCommandKind.Write2Bulk` |
| `InvokeSecure` | `invoke:secure` | `MxCommandKind.WriteSecured`, `MxCommandKind.WriteSecured2`, `MxCommandKind.WriteSecuredBulk`, `MxCommandKind.WriteSecured2Bulk`, `MxCommandKind.AuthenticateUser` | | `InvokeSecure` | `invoke:secure` | `MxCommandKind.WriteSecured`, `MxCommandKind.WriteSecured2`, `MxCommandKind.WriteSecuredBulk`, `MxCommandKind.WriteSecured2Bulk`, `MxCommandKind.AuthenticateUser` |
+22 -1
View File
@@ -218,9 +218,13 @@ When the dashboard is enabled, three hubs are mapped under `/hubs/*`:
side-effect; the dashboard only sees events while a gRPC client is also side-effect; the dashboard only sees events while a gRPC client is also
subscribed to that session. subscribed to that session.
`GET /hubs/token` (cookie-only) mints a 30-minute data-protected bearer `GET /hubs/token` (cookie-only) mints a 5-minute data-protected bearer
token for the calling user; the Blazor pages use it via token for the calling user; the Blazor pages use it via
`DashboardHubConnectionFactory` to authenticate the SignalR connection. `DashboardHubConnectionFactory` to authenticate the SignalR connection.
The factory refreshes the token on every (re)connect, so the short lifetime
(SEC-05) is transparent to clients. The token is not server-side revocable;
its short lifetime bounds exposure of a captured token (see
[GatewayDashboardDesign](./GatewayDashboardDesign.md)).
## LDAP Options (Dashboard Login) ## LDAP Options (Dashboard Login)
@@ -258,6 +262,23 @@ The protocol option is exposed for diagnostics and explicit deployment
configuration, not for compatibility negotiation. A mismatch fails validation configuration, not for compatibility negotiation. A mismatch fails validation
at startup. at startup.
## Security Options
Bound from `MxGateway:Security`. These knobs tune the authentication hot path
(SEC-08 verification cache / last-used coalescing) and the auth-surface rate
limiting (SEC-11). Defaults are safe; leave them unless profiling or a threat
model requires otherwise.
| Option | Default | Description |
|--------|---------|-------------|
| `MxGateway:Security:ApiKeyVerificationCacheSeconds` | `15` | TTL, in seconds, of a cached successful API-key verification. A cache hit within this window skips the per-call SQLite read (and the coupled `last_used` write). Gateway-initiated revoke/rotate/delete invalidate the entry immediately; this TTL is the backstop for out-of-band mutations (a direct DB edit, or a revoke run by the separate `apikey` CLI process). `0` disables the cache. Must be zero or greater. |
| `MxGateway:Security:ApiKeyLastUsedCoalesceSeconds` | `60` | Coalescing window, in seconds, for the `last_used_utc` write. The library verifier writes `last_used` on every successful verification; this bounds the write to at most one per key per window, so a hammered key does not churn the WAL. `0` forwards every write. Must be zero or greater. |
| `MxGateway:Security:LoginRateLimitPermitLimit` | `10` | Maximum `POST /auth/login` attempts permitted per remote IP within `LoginRateLimitWindowSeconds` before requests are rejected with HTTP 429. Throttles LDAP credential stuffing before the bind is relayed to the directory. Must be greater than zero. |
| `MxGateway:Security:LoginRateLimitWindowSeconds` | `60` | Fixed-window length, in seconds, for the per-IP login rate limit. Must be greater than zero. |
| `MxGateway:Security:ApiKeyFailureLimit` | `10` | Failed API-key verifications, per peer, within `ApiKeyFailureWindowSeconds` that trip the in-process short-circuit. Once tripped, the gRPC auth path rejects further attempts with `ResourceExhausted` **before** the store read; a successful verification resets the peer's counter. The peer is keyed on the presented key id (falling back to the transport address) so a single abusive credential behind a shared NAT is throttled without locking out co-located clients. Must be greater than zero. |
| `MxGateway:Security:ApiKeyFailureWindowSeconds` | `60` | Sliding-window length, in seconds, over which API-key verification failures are counted per peer. Must be greater than zero. |
| `MxGateway:Security:ApiKeyFailureTrackedPeers` | `4096` | Maximum distinct peers tracked by the failure counter (a bounded LRU) so a spray of unique peer keys cannot grow memory without limit. Must be greater than zero. |
## Galaxy Options ## Galaxy Options
| Option | Default | Description | | Option | Default | Description |
+17 -3
View File
@@ -486,9 +486,13 @@ dashboard mints short-lived bearer tokens for the connection:
and role claims to JSON, encrypts with the ASP.NET Core data-protection and role claims to JSON, encrypts with the ASP.NET Core data-protection
time-limited protector under purpose time-limited protector under purpose
`ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1`, and returns the protected `ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1`, and returns the protected
string. Lifetime is 30 minutes. string. Lifetime is **5 minutes**.
3. The SignalR client passes the token as either `Authorization: Bearer …` or 3. The SignalR client passes the token as either `Authorization: Bearer …` or
`?access_token=…` (WebSocket upgrade query string). `?access_token=…` (WebSocket upgrade query string). The query-string form is
the standard SignalR carriage for WebSocket upgrades, which cannot attach a
custom header; because it is easy to capture (proxy logs, browser history),
it must never be request-logged (see the remarks on
`HubTokenAuthenticationHandler`).
4. `HubTokenAuthenticationHandler` validates the protected payload and 4. `HubTokenAuthenticationHandler` validates the protected payload and
rebuilds the `ClaimsPrincipal` with the carried roles. rebuilds the `ClaimsPrincipal` with the carried roles.
5. The hubs' `[Authorize(Policy = HubClientsPolicy)]` accepts the resulting 5. The hubs' `[Authorize(Policy = HubClientsPolicy)]` accepts the resulting
@@ -496,7 +500,17 @@ dashboard mints short-lived bearer tokens for the connection:
`DashboardHubConnectionFactory` (scoped to the Blazor circuit) wraps the `DashboardHubConnectionFactory` (scoped to the Blazor circuit) wraps the
HubConnectionBuilder and supplies a fresh token via `AccessTokenProvider` on HubConnectionBuilder and supplies a fresh token via `AccessTokenProvider` on
every (re)connect. every (re)connect, so the short 5-minute lifetime is transparent to clients.
Caveat — logout does not revoke outstanding tokens. Logout clears the dashboard
cookie, but hub bearer tokens are self-contained, data-protection-encrypted, and
carry no server-side revocation state (no jti denylist). A token captured before
logout remains valid until it expires, and a role change or key revocation does
not take effect on an already-issued token until then. The 5-minute lifetime is
the deliberate mitigation: it bounds that exposure window without the cost of a
revocation store. Server-side revocation is deferred until per-session hub ACLs
land (see the per-session-ACL note), at which point tokens gain session/role
binding and a denylist becomes worthwhile.
## Configuration ## Configuration
+12
View File
@@ -203,6 +203,18 @@ metrics.RecordEventStreamSend(publicEvent.Family.ToString(), stopwatch.Elapsed);
`Dashboard/DashboardSnapshotService.cs` calls `_metrics.GetSnapshot()` once per `GetSnapshot` invocation and projects it into the dashboard transport types together with the session registry view. The dashboard receives a single, internally consistent snapshot per tick rather than reading individual counters at separate times. See [Gateway Dashboard Design](./GatewayDashboardDesign.md) and [Dashboard Interface Design](./DashboardInterfaceDesign.md) for the projection rules and wire format. `Dashboard/DashboardSnapshotService.cs` calls `_metrics.GetSnapshot()` once per `GetSnapshot` invocation and projects it into the dashboard transport types together with the session registry view. The dashboard receives a single, internally consistent snapshot per tick rather than reading individual counters at separate times. See [Gateway Dashboard Design](./GatewayDashboardDesign.md) and [Dashboard Interface Design](./DashboardInterfaceDesign.md) for the projection rules and wire format.
## Auth Store Write Churn
The per-RPC authentication path is a hidden write source: the shared verifier
refreshes `last_used_utc` on every authenticated call. To keep that off the
auth-store throughput ceiling, the gateway caches successful verifications for a
short TTL and coalesces the `last_used_utc` write to at most one per key per
window (`MxGateway:Security:ApiKeyVerificationCacheSeconds` /
`ApiKeyLastUsedCoalesceSeconds`, defaults 15 s / 60 s). This bounds WAL churn on
the bulk-read workload without dropping any metric — auth activity is still
observable via command-throughput counters and audit rows. See
[Authentication](./Authentication.md#hot-path-caching-and-last-used-coalescing).
## Related Documentation ## Related Documentation
- [Gateway Dashboard Design](./GatewayDashboardDesign.md) - [Gateway Dashboard Design](./GatewayDashboardDesign.md)
@@ -1,4 +1,7 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.Configuration; using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Configuration;
@@ -12,6 +15,14 @@ public static class GatewayConfigurationServiceCollectionExtensions
public static IServiceCollection AddGatewayConfiguration( public static IServiceCollection AddGatewayConfiguration(
this IServiceCollection services, IConfiguration configuration) 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>( services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
configuration, GatewayOptions.SectionName); configuration, GatewayOptions.SectionName);
@@ -19,4 +30,19 @@ public static class GatewayConfigurationServiceCollectionExtensions
return services; 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> /// <summary>Gets self-signed TLS certificate auto-generation options.</summary>
public TlsOptions Tls { get; init; } = new(); 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); ValidateProtocol(options.Protocol, builder);
ValidateAlarms(options.Alarms, builder); ValidateAlarms(options.Alarms, builder);
ValidateTls(options.Tls, 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) private static void ValidateAuthentication(AuthenticationOptions options, ValidationBuilder builder)
@@ -456,12 +493,41 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
return; return;
} }
if (!Path.IsPathRooted(value)) if (!IsRootedForAnyPlatform(value))
{ {
builder.Add(message); 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) private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
{ {
if (string.IsNullOrWhiteSpace(value)) 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, ApiKeyAdminCommands adminCommands,
IApiKeyAdminStore adminStore, IApiKeyAdminStore adminStore,
IAuditWriter auditWriter, 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 UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
private const string PepperUnavailableMarker = "pepper unavailable"; private const string PepperUnavailableMarker = "pepper unavailable";
@@ -100,6 +101,10 @@ public sealed class DashboardApiKeyManagementService(
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken) .RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
.ConfigureAwait(false); .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( await WriteDashboardAuditAsync(
user, user,
normalizedKeyId, normalizedKeyId,
@@ -138,6 +143,10 @@ public sealed class DashboardApiKeyManagementService(
.RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken) .RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
.ConfigureAwait(false); .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; bool succeeded = rotated.Token is not null;
await WriteDashboardAuditAsync( await WriteDashboardAuditAsync(
@@ -182,6 +191,10 @@ public sealed class DashboardApiKeyManagementService(
.DeleteAsync(normalizedKeyId, cancellationToken) .DeleteAsync(normalizedKeyId, cancellationToken)
.ConfigureAwait(false); .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( await WriteDashboardAuditAsync(
user, user,
normalizedKeyId, normalizedKeyId,
@@ -1,4 +1,5 @@
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using ZB.MOM.WW.MxGateway.Server.Configuration; 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> /// <summary>Endpoint extensions for registering the gateway dashboard routes.</summary>
public static class DashboardEndpointRouteBuilderExtensions 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> /// <summary>Maps all gateway dashboard routes including login, logout, and Razor components.</summary>
/// <param name="endpoints">The endpoint route builder.</param> /// <param name="endpoints">The endpoint route builder.</param>
/// <returns>The route builder for chaining.</returns> /// <returns>The route builder for chaining.</returns>
@@ -40,6 +76,8 @@ public static class DashboardEndpointRouteBuilderExtensions
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) => (HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
PostLoginAsync(httpContext, antiforgery, authenticator)) PostLoginAsync(httpContext, antiforgery, authenticator))
.AllowAnonymous() .AllowAnonymous()
// SEC-11: throttle credential stuffing before the LDAP bind is relayed to the directory.
.RequireRateLimiting(LoginRateLimiterPolicy)
.WithName("DashboardLoginPost"); .WithName("DashboardLoginPost");
endpoints.MapPost( 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) /// string (used by WebSocket upgrade requests that can't carry custom headers)
/// and validating it via <see cref="HubTokenService"/>. /// and validating it via <see cref="HubTokenService"/>.
/// </summary> /// </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> public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{ {
private readonly HubTokenService _tokens; private readonly HubTokenService _tokens;
@@ -26,7 +26,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class HubTokenService public sealed class HubTokenService
{ {
private const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1"; 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; 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> /// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
/// <param name="user">The claims principal representing the user.</param> /// <param name="user">The claims principal representing the user.</param>
/// <returns>The data-protected bearer token string.</returns> /// <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); ArgumentNullException.ThrowIfNull(user);
HubTokenPayload payload = new( HubTokenPayload payload = new(
user.Identity?.Name, user.Identity?.Name,
user.FindFirstValue(ClaimTypes.NameIdentifier), user.FindFirstValue(ClaimTypes.NameIdentifier),
[.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)]); [.. 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> /// <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.UseStaticFiles();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); 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.UseAntiforgery();
app.MapGatewayEndpoints(); app.MapGatewayEndpoints();
@@ -68,6 +71,7 @@ public static class GatewayApplication
builder.Services.AddGatewayConfiguration(builder.Configuration); builder.Services.AddGatewayConfiguration(builder.Configuration);
builder.Services.AddSqliteAuthStore(builder.Configuration); builder.Services.AddSqliteAuthStore(builder.Configuration);
builder.Services.AddGatewayGrpcAuthorization(); builder.Services.AddGatewayGrpcAuthorization();
AddLoginRateLimiter(builder);
builder.Services.AddHealthChecks() builder.Services.AddHealthChecks()
.AddTypeActivatedCheck<AuthStoreHealthCheck>( .AddTypeActivatedCheck<AuthStoreHealthCheck>(
"auth-store", "auth-store",
@@ -101,6 +105,26 @@ public static class GatewayApplication
return builder; 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) private static void ConfigureSelfSignedTls(WebApplicationBuilder builder)
{ {
if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration)) if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration))
@@ -1,4 +1,6 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit; using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys; 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.Admin;
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection; using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite; using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Audit; using ZB.MOM.WW.MxGateway.Server.Security.Audit;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
@@ -66,6 +69,34 @@ public static class AuthStoreServiceCollectionExtensions
// migrator and the migration hosted service. // migrator and the migration hosted service.
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath); 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 => services.AddSingleton(sp =>
new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>())); new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
// Resolve the logger defensively: the production host always registers ILogger<T>, but the // Resolve the logger defensively: the production host always registers ILogger<T>, but the
@@ -103,4 +134,58 @@ public static class AuthStoreServiceCollectionExtensions
return services; 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; using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
@@ -17,6 +19,36 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// </remarks> /// </remarks>
public static class GatewayApiKeyIdentityMapper 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> /// <summary>
/// Converts a shared API-key identity into the gateway identity, deserializing the opaque /// Converts a shared API-key identity into the gateway identity, deserializing the opaque
/// constraints JSON into <see cref="ApiKeyConstraints"/>. /// constraints JSON into <see cref="ApiKeyConstraints"/>.
@@ -38,6 +70,6 @@ public static class GatewayApiKeyIdentityMapper
KeyPrefix: "mxgw", KeyPrefix: "mxgw",
DisplayName: identity.DisplayName, DisplayName: identity.DisplayName,
Scopes: identity.Scopes, 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, IApiKeyVerifier apiKeyVerifier,
GatewayGrpcScopeResolver scopeResolver, GatewayGrpcScopeResolver scopeResolver,
IGatewayRequestIdentityAccessor identityAccessor, IGatewayRequestIdentityAccessor identityAccessor,
IOptions<GatewayOptions> options) : Interceptor IOptions<GatewayOptions> options,
ApiKeyFailureLimiter failureLimiter) : Interceptor
{ {
/// <inheritdoc /> /// <inheritdoc />
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>( public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
@@ -63,6 +64,19 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
string? authorizationHeader = context.RequestHeaders.GetValue("authorization"); 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, // The shared verifier owns parse + pepper + lookup + revocation + constant-time compare,
// returning a discriminated failure rather than throwing. Every authentication failure maps // returning a discriminated failure rather than throwing. Every authentication failure maps
// to Unauthenticated with an opaque message; the client never learns which stage failed. // 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) if (!verification.Succeeded || verification.Identity is null)
{ {
failureLimiter.RecordFailure(peerKey);
throw new RpcException(new Status( throw new RpcException(new Status(
StatusCode.Unauthenticated, StatusCode.Unauthenticated,
"Missing or invalid API key.")); "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); ApiKeyIdentity identity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
string requiredScope = scopeResolver.ResolveRequiredScope(request); string requiredScope = scopeResolver.ResolveRequiredScope(request);
@@ -89,4 +108,30 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
return identity; 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), MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite, AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
StreamAlarmsRequest => GatewayScopes.EventsRead, StreamAlarmsRequest => GatewayScopes.EventsRead,
QueryActiveAlarmsRequest => GatewayScopes.EventsRead,
TestConnectionRequest or TestConnectionRequest or
GetLastDeployTimeRequest or GetLastDeployTimeRequest or
DiscoverHierarchyRequest or DiscoverHierarchyRequest or
@@ -19,6 +19,13 @@ public static class GrpcAuthorizationServiceCollectionExtensions
services.AddSingleton<GatewayGrpcScopeResolver>(); services.AddSingleton<GatewayGrpcScopeResolver>();
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>(); services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
services.AddSingleton<IConstraintEnforcer, ConstraintEnforcer>(); 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.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
services services
.AddOptions<global::Grpc.AspNetCore.Server.GrpcServiceOptions>() .AddOptions<global::Grpc.AspNetCore.Server.GrpcServiceOptions>()
@@ -14,7 +14,15 @@ public sealed class GatewayOptionsTests
GatewayOptions options = BindOptions(new Dictionary<string, string?>()); GatewayOptions options = BindOptions(new Dictionary<string, string?>());
Assert.Equal(AuthenticationMode.ApiKey, options.Authentication.Mode); 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.Equal("MxGateway:ApiKeyPepper", options.Authentication.PepperSecretName);
Assert.True(options.Authentication.RunMigrationsOnStartup); Assert.True(options.Authentication.RunMigrationsOnStartup);
@@ -683,4 +683,92 @@ public sealed class GatewayOptionsValidatorTests
ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options); ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options);
Assert.True(result.Succeeded); 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")); 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;
}
}
}
@@ -327,7 +327,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
RpcException exception = await Assert.ThrowsAsync<RpcException>( RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => interceptor.ServerStreamingServerHandler( () => interceptor.ServerStreamingServerHandler(
new StreamAlarmsRequest(), new QueryActiveAlarmsRequest(),
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(), new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
ContextWithAuthorization("Bearer mxgw_operator01_secret"), ContextWithAuthorization("Bearer mxgw_operator01_secret"),
(_, _, _) => Task.CompletedTask)); (_, _, _) => Task.CompletedTask));
@@ -347,7 +347,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
RecordingServerStreamWriter<ActiveAlarmSnapshot> streamWriter = new(); RecordingServerStreamWriter<ActiveAlarmSnapshot> streamWriter = new();
await interceptor.ServerStreamingServerHandler( await interceptor.ServerStreamingServerHandler(
new StreamAlarmsRequest(), new QueryActiveAlarmsRequest(),
streamWriter, streamWriter,
ContextWithAuthorization("Bearer mxgw_operator01_secret"), ContextWithAuthorization("Bearer mxgw_operator01_secret"),
async (_, writer, _) => async (_, writer, _) =>
@@ -358,6 +358,102 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
Assert.Single(streamWriter.Messages); 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( private static MxAccessGatewayService CreateService(
ISessionManager sessionManager, ISessionManager sessionManager,
IGatewayRequestIdentityAccessor identityAccessor) IGatewayRequestIdentityAccessor identityAccessor)
@@ -377,7 +473,8 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
private static GatewayGrpcAuthorizationInterceptor CreateInterceptor( private static GatewayGrpcAuthorizationInterceptor CreateInterceptor(
IApiKeyVerifier apiKeyVerifier, IApiKeyVerifier apiKeyVerifier,
IGatewayRequestIdentityAccessor identityAccessor, IGatewayRequestIdentityAccessor identityAccessor,
AuthenticationMode authenticationMode = AuthenticationMode.ApiKey) AuthenticationMode authenticationMode = AuthenticationMode.ApiKey,
ApiKeyFailureLimiter? failureLimiter = null)
{ {
return new GatewayGrpcAuthorizationInterceptor( return new GatewayGrpcAuthorizationInterceptor(
apiKeyVerifier, apiKeyVerifier,
@@ -389,7 +486,12 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
{ {
Mode = authenticationMode Mode = authenticationMode
} }
})); }),
failureLimiter ?? new ApiKeyFailureLimiter(
limit: 1000,
window: TimeSpan.FromMinutes(1),
maxPeers: 1000,
clock: TimeProvider.System));
} }
private static ApiKeyVerification SuccessWithScopes(params string[] scopes) 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 private sealed class FakeApiKeyVerifier(ApiKeyVerification result) : IApiKeyVerifier
{ {
/// <summary>Gets whether the verifier was called.</summary> /// <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");
}
}
}