diff --git a/docs/Authentication.md b/docs/Authentication.md index d864bb3..29a17a3 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -101,6 +101,38 @@ return ApiKeyVerificationResult.Success(new ApiKeyIdentity( `DisplayName`, `Scopes`, and `Constraints`) and is the type downstream 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 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. diff --git a/docs/Authorization.md b/docs/Authorization.md index c693317..87c62de 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -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. +### 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 `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), AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite, StreamAlarmsRequest => GatewayScopes.EventsRead, + QueryActiveAlarmsRequest => GatewayScopes.EventsRead, TestConnectionRequest or GetLastDeployTimeRequest 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: @@ -205,7 +212,7 @@ blocking constraint; secured values and raw credentials are never logged. |----------|-------|--------------| | `SessionOpen` | `session:open` | `OpenSessionRequest` | | `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) | | `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` | diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 7304b7e..3b1eaec 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -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 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 `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) @@ -258,6 +262,23 @@ The protocol option is exposed for diagnostics and explicit deployment configuration, not for compatibility negotiation. A mismatch fails validation 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 | Option | Default | Description | diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index ed49e4b..9778a74 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -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 time-limited protector under purpose `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 - `?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 rebuilds the `ClaimsPrincipal` with the carried roles. 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 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 diff --git a/docs/Metrics.md b/docs/Metrics.md index 202b00a..64836a4 100644 --- a/docs/Metrics.md +++ b/docs/Metrics.md @@ -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. +## 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 - [Gateway Dashboard Design](./GatewayDashboardDesign.md) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs index 814e9ac..863db3b 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs @@ -1,4 +1,7 @@ using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; using ZB.MOM.WW.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Configuration; @@ -12,6 +15,14 @@ public static class GatewayConfigurationServiceCollectionExtensions public static IServiceCollection AddGatewayConfiguration( 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(new FallbackHostEnvironment()); + services.AddValidatedOptions( configuration, GatewayOptions.SectionName); @@ -19,4 +30,19 @@ public static class GatewayConfigurationServiceCollectionExtensions return services; } + + /// + /// Non-production used only when no real host registered one + /// (minimal test/tooling containers). The real host's registration always wins via TryAdd. + /// + 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(); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs index f3d41e5..3063604 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs @@ -46,4 +46,10 @@ public sealed class GatewayOptions /// Gets self-signed TLS certificate auto-generation options. public TlsOptions Tls { get; init; } = new(); + + /// + /// Gets security hot-path options (API-key verification cache / last-used coalescing and + /// login / API-key rate limiting). + /// + public SecurityOptions Security { get; init; } = new(); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index 686b168..34c50e7 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -48,6 +48,43 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase + /// Determines whether is an absolute path for any platform, + /// not just the host running the validator. This matters on the macOS dev box, where the + /// production appsettings.json ships Windows-absolute paths (C:\ProgramData\...) + /// that 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. + /// + 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) { if (string.IsNullOrWhiteSpace(value)) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/SecurityOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/SecurityOptions.cs new file mode 100644 index 0000000..c664edb --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/SecurityOptions.cs @@ -0,0 +1,65 @@ +namespace ZB.MOM.WW.MxGateway.Server.Configuration; + +/// +/// Security hot-path options bound from MxGateway:Security. Groups the API-key verification +/// cache and last_used 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. +/// +public sealed class SecurityOptions +{ + /// The configuration sub-section this binds from. + public const string SectionName = "MxGateway:Security"; + + /// + /// 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 + /// last_used 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 0 to disable caching. Default is 15 seconds. + /// + public int ApiKeyVerificationCacheSeconds { get; init; } = 15; + + /// + /// Gets the coalescing window, in seconds, for the last_used_utc write. The library + /// verifier couples the write into every verification; this decorator forwards at most one + /// MarkUsed per key per window, so a hammered key produces at most one write per window + /// rather than one per authenticated RPC. Set to 0 to forward every write. Default is + /// 60 seconds (at most one write per key per minute). + /// + public int ApiKeyLastUsedCoalesceSeconds { get; init; } = 60; + + /// + /// Gets the maximum number of POST /auth/login attempts permitted per remote IP within + /// before requests are rejected with HTTP 429. Default + /// is 10. + /// + public int LoginRateLimitPermitLimit { get; init; } = 10; + + /// + /// Gets the fixed-window length, in seconds, for the POST /auth/login per-IP rate limit. + /// Default is 60 seconds. + /// + public int LoginRateLimitWindowSeconds { get; init; } = 60; + + /// + /// Gets the number of consecutive failed API-key verifications, per peer, within + /// 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. + /// + public int ApiKeyFailureLimit { get; init; } = 10; + + /// + /// Gets the sliding-window length, in seconds, over which API-key verification failures are + /// counted per peer. Default is 60 seconds. + /// + public int ApiKeyFailureWindowSeconds { get; init; } = 60; + + /// + /// 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. + /// + public int ApiKeyFailureTrackedPeers { get; init; } = 4096; +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs index 315c05a..6215e83 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs @@ -15,7 +15,8 @@ public sealed class DashboardApiKeyManagementService( ApiKeyAdminCommands adminCommands, IApiKeyAdminStore adminStore, 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 PepperUnavailableMarker = "pepper unavailable"; @@ -100,6 +101,10 @@ public sealed class DashboardApiKeyManagementService( .RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken) .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( user, normalizedKeyId, @@ -138,6 +143,10 @@ public sealed class DashboardApiKeyManagementService( .RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken) .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; await WriteDashboardAuditAsync( @@ -182,6 +191,10 @@ public sealed class DashboardApiKeyManagementService( .DeleteAsync(normalizedKeyId, cancellationToken) .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( user, normalizedKeyId, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs index bf68cd1..dea4e5f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs @@ -1,4 +1,5 @@ using System.Text.Encodings.Web; +using System.Threading.RateLimiting; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication; using ZB.MOM.WW.MxGateway.Server.Configuration; @@ -10,6 +11,41 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// Endpoint extensions for registering the gateway dashboard routes. public static class DashboardEndpointRouteBuilderExtensions { + /// + /// The named rate-limiter policy applied to the POST /auth/login route (SEC-11). Registered + /// in GatewayApplication via . + /// + internal const string LoginRateLimiterPolicy = "dashboard-login"; + + /// + /// 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. + /// + /// The inbound request. + /// The bound security options carrying the login-limit knobs. + /// The per-IP fixed-window partition. + internal static RateLimitPartition 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 CreateLoginRateLimiter(SecurityOptions security) + => PartitionedRateLimiter.Create( + ctx => GetLoginRateLimitPartition(ctx, security)); + /// Maps all gateway dashboard routes including login, logout, and Razor components. /// The endpoint route builder. /// The route builder for chaining. @@ -40,6 +76,8 @@ public static class DashboardEndpointRouteBuilderExtensions (HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) => PostLoginAsync(httpContext, antiforgery, authenticator)) .AllowAnonymous() + // SEC-11: throttle credential stuffing before the LDAP bind is relayed to the directory. + .RequireRateLimiting(LoginRateLimiterPolicy) .WithName("DashboardLoginPost"); endpoints.MapPost( diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenAuthenticationHandler.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenAuthenticationHandler.cs index af9d9ca..0a5622e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenAuthenticationHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenAuthenticationHandler.cs @@ -10,6 +10,16 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// string (used by WebSocket upgrade requests that can't carry custom headers) /// and validating it via . /// +/// +/// Carrying the token in the ?access_token= query string is the standard SignalR pattern: +/// the WebSocket upgrade handshake cannot attach a custom Authorization 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, Referer), this value must NEVER be +/// request-logged. Serilog HTTP request logging is intentionally not enabled; if it is ever added, +/// scrub the access_token query parameter before the URL reaches a sink. The short token +/// lifetime () is the backstop that bounds exposure of a +/// leaked token. +/// public sealed class HubTokenAuthenticationHandler : AuthenticationHandler { private readonly HubTokenService _tokens; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs index 773fb74..168f818 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs @@ -26,7 +26,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public sealed class HubTokenService { 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; @@ -41,14 +49,24 @@ public sealed class HubTokenService /// Issues a bearer token carrying the user's identity and roles. /// The claims principal representing the user. /// The data-protected bearer token string. - public string Issue(ClaimsPrincipal user) + public string Issue(ClaimsPrincipal user) => Issue(user, TokenLifetime); + + /// + /// 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 and get . + /// + /// The claims principal representing the user. + /// The lifetime applied to the token; a non-positive value yields an already-expired token. + /// The data-protected bearer token string. + internal string Issue(ClaimsPrincipal user, TimeSpan lifetime) { ArgumentNullException.ThrowIfNull(user); HubTokenPayload payload = new( user.Identity?.Name, user.FindFirstValue(ClaimTypes.NameIdentifier), [.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)]); - return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime); + return _protector.Protect(JsonSerializer.Serialize(payload), lifetime); } /// Validates a token and returns the equivalent claims principal; null when invalid or expired. diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index 3dda7f5..131ef19 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -41,6 +41,9 @@ public static class GatewayApplication app.UseStaticFiles(); app.UseAuthentication(); 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.MapGatewayEndpoints(); @@ -68,6 +71,7 @@ public static class GatewayApplication builder.Services.AddGatewayConfiguration(builder.Configuration); builder.Services.AddSqliteAuthStore(builder.Configuration); builder.Services.AddGatewayGrpcAuthorization(); + AddLoginRateLimiter(builder); builder.Services.AddHealthChecks() .AddTypeActivatedCheck( "auth-store", @@ -101,6 +105,26 @@ public static class GatewayApplication 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() + ?? 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) { if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration)) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs index e9138b7..5b3bc65 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Audit; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; @@ -6,6 +8,7 @@ using ZB.MOM.WW.Auth.ApiKeys; using ZB.MOM.WW.Auth.ApiKeys.Admin; using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection; using ZB.MOM.WW.Auth.ApiKeys.Sqlite; +using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Security.Audit; namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; @@ -66,6 +69,34 @@ public static class AuthStoreServiceCollectionExtensions // migrator and the migration hosted service. services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath); + // SEC-08 hot-path decorators. Every gRPC call previously did a SQLite read plus a + // last_used_utc WRITE via IApiKeyVerifier.VerifyAsync (the library verifier couples the + // mark into verification). Two gateway-side decorators cut that cost without editing the + // external library: + // * CoalescingMarkApiKeyStore wraps the library's IApiKeyStore and coalesces the + // last_used write to at most one per key per MxGateway:Security window (default 60s), + // so the library verifier's per-call mark no longer churns the WAL. + // * CachingApiKeyVerifier wraps the library's IApiKeyVerifier with a short-TTL memory + // cache (MxGateway:Security:ApiKeyVerificationCacheSeconds, default 15s) keyed on a + // SHA-256 hash of the presented token, so a cache hit skips the store read entirely. + // Invalidation of a cached verification on a gateway-initiated revoke/rotate/delete is + // wired at the dashboard admin call site (DashboardApiKeyManagementService) via + // IApiKeyCacheInvalidator; the short TTL is the backstop for out-of-band mutations. + // Read the security knobs straight off IConfiguration rather than IOptions: + // touching IOptions.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() + ?? new SecurityOptions(); + services.AddMemoryCache(); + DecorateSingleton( + services, + (sp, inner) => new CoalescingMarkApiKeyStore( + inner, + security, + sp.GetService() ?? TimeProvider.System)); + DecorateVerifierWithCache(services, security); + services.AddSingleton(sp => new SqliteCanonicalAuditStore(sp.GetRequiredService())); // Resolve the logger defensively: the production host always registers ILogger, but the @@ -103,4 +134,58 @@ public static class AuthStoreServiceCollectionExtensions return services; } + + /// + /// Replaces the last registration of 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. + /// + private static void DecorateSingleton( + IServiceCollection services, + Func decorator) + where TService : class + { + Func innerFactory = CaptureInnerFactory(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 innerFactory = CaptureInnerFactory(services); + services.AddSingleton(sp => new CachingApiKeyVerifier( + innerFactory(sp), + sp.GetRequiredService(), + security)); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + // 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 CaptureInnerFactory(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); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/CachingApiKeyVerifier.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/CachingApiKeyVerifier.cs new file mode 100644 index 0000000..fe3ae52 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/CachingApiKeyVerifier.cs @@ -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; + +/// +/// 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. +/// +public interface IApiKeyCacheInvalidator +{ + /// Removes every cached verification for the given key id. + /// The key id whose cached verifications should be dropped. + void Invalidate(string keyId); +} + +/// +/// An decorator that caches successful verifications for a short, +/// configurable TTL (), 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 last_used +/// write the library verifier couples into . +/// +/// +/// +/// 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. +/// +/// +/// 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. +/// +/// +/// Correctness on mutation is provided by two mechanisms: gateway-initiated revoke/rotate/delete +/// call directly (see DashboardApiKeyManagementService), and the +/// short TTL is the backstop for out-of-band mutations (a direct DB edit, or a revoke issued by the +/// separate apikey CLI process, whose in-memory cache is not this process's cache). +/// +/// +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> _keyIdIndex = + new(StringComparer.Ordinal); + + /// Initializes a new instance of the class. + /// The wrapped verifier (the library verifier) reached on a cache miss. + /// The shared memory cache. + /// Security options carrying the verification-cache TTL. + 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; + } + + /// + public async Task 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; + } + + /// + public void Invalidate(string keyId) + { + if (string.IsNullOrEmpty(keyId)) + { + return; + } + + if (_keyIdIndex.TryRemove(keyId, out ConcurrentDictionary? cacheKeys)) + { + foreach (string cacheKey in cacheKeys.Keys) + { + _cache.Remove(cacheKey); + } + } + } + + private void IndexCacheKey(string keyId, string cacheKey) + { + ConcurrentDictionary set = _keyIdIndex.GetOrAdd( + keyId, + static _ => new ConcurrentDictionary(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 header = authorizationHeader.AsSpan().Trim(); + const string bearer = "Bearer "; + ReadOnlySpan 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; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/CoalescingMarkApiKeyStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/CoalescingMarkApiKeyStore.cs new file mode 100644 index 0000000..9270530 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/CoalescingMarkApiKeyStore.cs @@ -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; + +/// +/// An decorator that coalesces writes to at +/// most one per key per window. Lookups +/// pass through unchanged. +/// +/// +/// The library verifier couples the last_used_utc write into every successful verification +/// by calling . 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 last_used_utc is refreshed at most once per key per window while the read +/// path stays correct. last_used_utc is a coarse staleness indicator, not an audit record +/// (audit rows are written separately), so bounded staleness of up to one window is acceptable. +/// +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 _lastMarkedTicks = new(StringComparer.Ordinal); + + /// Initializes a new instance of the class. + /// The wrapped store. + /// Security options carrying the coalescing window. + /// The time provider. + 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; + } + + /// + public Task FindByKeyIdAsync(string keyId, CancellationToken ct) + => _inner.FindByKeyIdAsync(keyId, ct); + + /// + public Task FindActiveByKeyIdAsync(string keyId, CancellationToken ct) + => _inner.FindActiveByKeyIdAsync(keyId, ct); + + /// + 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. + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/GatewayApiKeyIdentityMapper.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/GatewayApiKeyIdentityMapper.cs index ed55248..f507a79 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/GatewayApiKeyIdentityMapper.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/GatewayApiKeyIdentityMapper.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; + using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity; namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; @@ -17,6 +19,36 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; /// 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 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; + } + /// /// Converts a shared API-key identity into the gateway identity, deserializing the opaque /// constraints JSON into . @@ -38,6 +70,6 @@ public static class GatewayApiKeyIdentityMapper KeyPrefix: "mxgw", DisplayName: identity.DisplayName, Scopes: identity.Scopes, - Constraints: ApiKeyConstraintSerializer.Deserialize(constraintsJson)); + Constraints: DeserializeConstraints(constraintsJson)); } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ApiKeyFailureLimiter.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ApiKeyFailureLimiter.cs new file mode 100644 index 0000000..730e4c4 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ApiKeyFailureLimiter.cs @@ -0,0 +1,152 @@ +using System.Collections.Concurrent; +using ZB.MOM.WW.MxGateway.Server.Configuration; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; + +/// +/// 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 +/// failed attempts within +/// ; a successful verification resets the +/// peer's counter. +/// +/// +/// +/// 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 glauth.md) means a single +/// abusive credential behind a shared NAT is throttled without locking out unrelated clients on the +/// same address. +/// +/// +/// The tracked-peer set is a bounded LRU () 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. +/// +/// +public sealed class ApiKeyFailureLimiter +{ + private readonly int _limit; + private readonly long _windowTicks; + private readonly int _maxPeers; + private readonly TimeProvider _clock; + + private readonly ConcurrentDictionary _peers = new(StringComparer.Ordinal); + + /// Initializes a new instance of the class. + /// Security options carrying the failure-limit knobs. + /// The time provider. + 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; + } + + /// Returns whether the peer has reached the failure limit within the current window. + /// The peer key (key id or peer address). + /// when the peer should be short-circuited. + 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; + } + } + + /// Records a failed verification attempt for the peer. + /// The peer key (key id or peer address). + 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(); + } + + /// Clears the peer's failure count after a successful verification. + /// The peer key (key id or peer address). + 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 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 FailureTicks { get; } = new(); + + public long LastActivityTicks; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs index 97533d5..0a187ef 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs @@ -15,7 +15,8 @@ public sealed class GatewayGrpcAuthorizationInterceptor( IApiKeyVerifier apiKeyVerifier, GatewayGrpcScopeResolver scopeResolver, IGatewayRequestIdentityAccessor identityAccessor, - IOptions options) : Interceptor + IOptions options, + ApiKeyFailureLimiter failureLimiter) : Interceptor { /// public override async Task UnaryServerHandler( @@ -63,6 +64,19 @@ public sealed class GatewayGrpcAuthorizationInterceptor( 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, // returning a discriminated failure rather than throwing. Every authentication failure maps // 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) { + failureLimiter.RecordFailure(peerKey); throw new RpcException(new Status( StatusCode.Unauthenticated, "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); string requiredScope = scopeResolver.ResolveRequiredScope(request); @@ -89,4 +108,30 @@ public sealed class GatewayGrpcAuthorizationInterceptor( return identity; } + + // Resolves the failure-limiter partition key: the presented key id (token shape + // mxgw__) 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 header = authorizationHeader.AsSpan().Trim(); + const string bearer = "Bearer "; + ReadOnlySpan token = header.StartsWith(bearer, StringComparison.OrdinalIgnoreCase) + ? header[bearer.Length..].Trim() + : header; + + // mxgw__: 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; + } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs index 08bcd83..36e756e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs @@ -20,6 +20,7 @@ public sealed class GatewayGrpcScopeResolver MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified), AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite, StreamAlarmsRequest => GatewayScopes.EventsRead, + QueryActiveAlarmsRequest => GatewayScopes.EventsRead, TestConnectionRequest or GetLastDeployTimeRequest or DiscoverHierarchyRequest or diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs index 38ccd4d..ae697f3 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs @@ -19,6 +19,13 @@ public static class GrpcAuthorizationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // SEC-11 per-peer failure counter, checked before the verification store read. Bind the knobs + // from IConfiguration directly (not IOptions) to avoid coupling this + // registration to the whole-options validation pipeline. + services.AddSingleton(sp => new ApiKeyFailureLimiter( + sp.GetRequiredService().GetSection(SecurityOptions.SectionName).Get() + ?? new SecurityOptions(), + sp.GetService() ?? TimeProvider.System)); services.AddSingleton(); services .AddOptions() diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs index 25e08ac..26e80e7 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs @@ -14,7 +14,15 @@ public sealed class GatewayOptionsTests GatewayOptions options = BindOptions(new Dictionary()); Assert.Equal(AuthenticationMode.ApiKey, options.Authentication.Mode); - Assert.Equal(@"C:\ProgramData\MxGateway\gateway-auth.db", options.Authentication.SqlitePath); + // SEC-01: the default is derived from CommonApplicationData (C:\ProgramData on Windows, + // /usr/share on Unix) rather than a Windows literal, so assert against the same derivation + // to stay platform-correct. + Assert.Equal( + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "MxGateway", + "gateway-auth.db"), + options.Authentication.SqlitePath); Assert.Equal("MxGateway:ApiKeyPepper", options.Authentication.PepperSecretName); Assert.True(options.Authentication.RunMigrationsOnStartup); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs index 159203c..d2614a3 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs @@ -683,4 +683,92 @@ public sealed class GatewayOptionsValidatorTests ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options); Assert.True(result.Succeeded); } + + // ---- SEC-11: MxGateway:Security validation ---- + + private static GatewayOptions WithSecurity(SecurityOptions security) => new() { Security = security }; + + /// Verifies the default security options pass validation. + [Fact] + public void Validate_Succeeds_WithDefaultSecurityOptions() + { + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, WithSecurity(new SecurityOptions())); + Assert.True(result.Succeeded); + } + + /// Verifies a zero verification-cache TTL is allowed (disables caching). + [Fact] + public void Validate_Succeeds_WhenVerificationCacheSecondsZero() + { + ValidateOptionsResult result = new GatewayOptionsValidator().Validate( + null, + WithSecurity(new SecurityOptions { ApiKeyVerificationCacheSeconds = 0 })); + Assert.True(result.Succeeded); + } + + /// Verifies a negative verification-cache TTL fails validation. + [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")); + } + + /// Verifies a negative last-used coalesce window fails validation. + [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")); + } + + /// Verifies a zero login rate-limit permit fails validation. + [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")); + } + + /// Verifies a zero login rate-limit window fails validation. + [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")); + } + + /// Verifies a zero API-key failure limit fails validation. + [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")); + } + + /// Verifies a zero tracked-peer cap fails validation. + [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")); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardLoginRateLimitTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardLoginRateLimitTests.cs new file mode 100644 index 0000000..523496a --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardLoginRateLimitTests.cs @@ -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; + +/// +/// SEC-11: the POST /auth/login 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. +/// +public sealed class DashboardLoginRateLimitTests +{ + /// A burst beyond the permit limit from one IP has its excess attempts rejected (HTTP 429 in the pipeline). + [Fact] + public void LoginRateLimiter_BurstBeyondPermitLimit_RejectsExcessFromSameIp() + { + SecurityOptions security = new() + { + LoginRateLimitPermitLimit = 3, + LoginRateLimitWindowSeconds = 60, + }; + using PartitionedRateLimiter 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); + } + + /// Distinct client IPs are partitioned independently — one IP's burst does not starve another. + [Fact] + public void LoginRateLimiter_DistinctIps_PartitionedIndependently() + { + SecurityOptions security = new() + { + LoginRateLimitPermitLimit = 1, + LoginRateLimitWindowSeconds = 60, + }; + using PartitionedRateLimiter 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; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs index 8ad4f87..4d726fc 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs @@ -115,4 +115,63 @@ public sealed class HubTokenServiceTests Assert.Null(service.Validate("this-is-not-a-protected-payload")); } + + /// + /// Issue/validate round-trip: a freshly minted token (default ) + /// validates and reconstructs the caller's identity and roles. + /// + [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)); + } + + /// + /// 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. + /// + [Fact] + public void TokenLifetime_IsFiveMinutes() + { + Assert.Equal(TimeSpan.FromMinutes(5), HubTokenService.TokenLifetime); + } + + /// + /// A token whose lifetime has elapsed is rejected by . + /// Uses the internal lifetime-issuing seam with a negative lifetime so the token is already + /// expired at mint time — deterministic, no wall-clock delay. + /// + [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)); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/CachingApiKeyVerifierTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/CachingApiKeyVerifierTests.cs new file mode 100644 index 0000000..5429448 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/CachingApiKeyVerifierTests.cs @@ -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; + +/// +/// SEC-08 hot-path decorators. Covers both mechanisms: +/// (read/verification coalescing plus revoke/rotate invalidation) and +/// (the last_used write coalescing that keeps the +/// per-RPC database write off the throughput ceiling). +/// +public sealed class CachingApiKeyVerifierTests +{ + private const string Header = "Bearer mxgw_operator01_super-secret"; + + /// A cache hit within the TTL returns the cached result and never calls the inner verifier. + [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); + } + + /// Different presented secrets are cached under distinct keys (no cross-secret aliasing). + [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); + } + + /// Failed verifications are never cached; every attempt reaches the inner verifier. + [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); + } + + /// A TTL of zero disables caching: the inner verifier is called on every request. + [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); + } + + /// Invalidating a key id (revoke/rotate) drops its cached verification, forcing a re-verify. + [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); + } + + /// + /// The store decorator coalesces repeated MarkUsed writes for the same key inside the + /// window down to a single forwarded write — the ≤1/min guarantee for last_used_utc. + /// + [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); + } + + /// After the window elapses the next mark is forwarded again (staleness is bounded, not frozen). + [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); + } + + /// Distinct keys are coalesced independently. + [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); + } + + /// A zero window disables coalescing: every mark is forwarded. + [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(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 VerifyAsync(string authorizationHeader, CancellationToken ct) + { + CallCount++; + return Task.FromResult(result); + } + } + + private sealed class FakeStore : IApiKeyStore + { + public int MarkUsedCount { get; private set; } + + public Task FindByKeyIdAsync(string keyId, CancellationToken ct) + => Task.FromResult(null); + + public Task FindActiveByKeyIdAsync(string keyId, CancellationToken ct) + => Task.FromResult(null); + + public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct) + { + MarkUsedCount++; + return Task.CompletedTask; + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs index 0e66945..796739b 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs @@ -327,7 +327,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests RpcException exception = await Assert.ThrowsAsync( () => interceptor.ServerStreamingServerHandler( - new StreamAlarmsRequest(), + new QueryActiveAlarmsRequest(), new RecordingServerStreamWriter(), ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _, _) => Task.CompletedTask)); @@ -347,7 +347,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests RecordingServerStreamWriter streamWriter = new(); await interceptor.ServerStreamingServerHandler( - new StreamAlarmsRequest(), + new QueryActiveAlarmsRequest(), streamWriter, ContextWithAuthorization("Bearer mxgw_operator01_secret"), async (_, writer, _) => @@ -358,6 +358,102 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests Assert.Single(streamWriter.Messages); } + /// + /// SEC-11: once a peer has exceeded the failure limit, the interceptor short-circuits with + /// 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. + /// + /// A task that represents the asynchronous operation. + [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( + () => 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( + () => 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); + } + + /// + /// 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. + /// + /// A task that represents the asynchronous operation. + [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( + () => 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( + () => failing.UnaryServerHandler( + new OpenSessionRequest(), + ContextWithAuthorization("Bearer mxgw_operator01_bad"), + (_, _) => Task.FromResult(new OpenSessionReply()))); + Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode); + } + } + private static MxAccessGatewayService CreateService( ISessionManager sessionManager, IGatewayRequestIdentityAccessor identityAccessor) @@ -377,7 +473,8 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests private static GatewayGrpcAuthorizationInterceptor CreateInterceptor( IApiKeyVerifier apiKeyVerifier, IGatewayRequestIdentityAccessor identityAccessor, - AuthenticationMode authenticationMode = AuthenticationMode.ApiKey) + AuthenticationMode authenticationMode = AuthenticationMode.ApiKey, + ApiKeyFailureLimiter? failureLimiter = null) { return new GatewayGrpcAuthorizationInterceptor( apiKeyVerifier, @@ -389,7 +486,12 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests { Mode = authenticationMode } - })); + }), + failureLimiter ?? new ApiKeyFailureLimiter( + limit: 1000, + window: TimeSpan.FromMinutes(1), + maxPeers: 1000, + clock: TimeProvider.System)); } private static ApiKeyVerification SuccessWithScopes(params string[] scopes) @@ -531,6 +633,22 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests } } + private sealed class CountingFailureVerifier(ApiKeyVerification result) : IApiKeyVerifier + { + /// Gets the number of times the verifier was invoked. + public int CallCount { get; private set; } + + /// Returns the configured result and counts the invocation. + /// The authorization header to verify. + /// Cancellation token. + /// The configured verification result. + public Task VerifyAsync(string authorizationHeader, CancellationToken ct) + { + CallCount++; + return Task.FromResult(result); + } + } + private sealed class FakeApiKeyVerifier(ApiKeyVerification result) : IApiKeyVerifier { /// Gets whether the verifier was called. diff --git a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/TestHostEnvironmentInitializer.cs b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/TestHostEnvironmentInitializer.cs new file mode 100644 index 0000000..580de0e --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/TestHostEnvironmentInitializer.cs @@ -0,0 +1,29 @@ +using System.Runtime.CompilerServices; + +namespace ZB.MOM.WW.MxGateway.Tests.TestSupport; + +/// +/// Defaults the host environment to Development for the whole test assembly. +/// +/// +/// Many tests build the full gateway host through GatewayApplication.Build against the dev +/// appsettings.json (which ships Ldap:Transport=None 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 +/// GatewayOptionsValidator with its isProduction constructor (no host environment) and +/// are unaffected; a test that needs Production can still pass --environment=Production, which +/// overrides this default. +/// +internal static class TestHostEnvironmentInitializer +{ + [ModuleInitializer] + internal static void SetDevelopmentEnvironmentDefault() + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"))) + { + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development"); + } + } +}