diff --git a/docs/Authorization.md b/docs/Authorization.md index 23c71d1..204b9f7 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -99,7 +99,7 @@ Before the verification store read, the helper asks a cheap in-process failure c - **Composite `(transport peer, key id)` partitions.** Reaching `MxGateway:Security:ApiKeyFailureLimit` failures binds the throttle to the address that produced them. The key id alone is never the partition: key ids are not secret — they ride in every token and are listed on the dashboard — so keying on them let any network peer deny a key to its legitimate holder. The key id joins the partition only after a token-shape check (literal `mxgw` prefix, at least three non-empty `_` segments, key id of at most 64 characters), and one address may mint at most 32 key-id partitions before the overflow collapses onto that address's fallback partition. - **A per-key-id aggregate** across all peers (`ApiKeyFailureAggregateLimit`, default 30), which bounds a distributed or source-rotating sprayer that never trips any single partition. -An over-limit state is a valve, not a wall: one request per `ApiKeyFailureProbeIntervalSeconds` (default 5 s) is admitted through to the real verifier, and everything else is refused with `StatusCode.ResourceExhausted` before the store read. The slot is claimed atomically, so a burst arriving together at an interval boundary still yields exactly one admission, and a slot claimed for a request that a later layer then refuses is handed back under a per-state version stamp — never by timestamp comparison, which collides whenever a concurrent failure re-arms the same state on the same clock tick. A successful verification resets both layers — which is why the reset path stays reachable while a key is under active spray. One exception: when the caller's key id was collapsed into its address's shared fallback partition by the per-peer cap, a success clears the key's aggregate but leaves that shared partition alone, since it also holds failures contributed by other key ids from the same address. The tracked partitions form a bounded LRU (`ApiKeyFailureTrackedPeers`) whose eviction prefers fully expired windows and never removes an over-limit partition below a 2x transient overshoot ceiling, so the cap bounds memory without becoming a reset button for an active block. `ResourceExhausted` reveals only that throttling is in effect, not whether any particular secret was valid, preserving the opaque-failure property. Refusals increment `mxgateway.auth.throttled`, tagged `stage=peer|aggregate` and nothing else — `/metrics` is unauthenticated, so neither key ids nor peer addresses may appear there. +An over-limit state is a valve, not a wall: one request per `ApiKeyFailureProbeIntervalSeconds` (default 5 s) is admitted through to the real verifier, and everything else is refused with `StatusCode.ResourceExhausted` before the store read. The slot is claimed atomically, so a burst arriving together at an interval boundary still yields exactly one admission, and a slot claimed for a request that a later layer then refuses is handed back under a per-state version stamp — never by timestamp comparison, which collides whenever a concurrent failure re-arms the same state on the same clock tick. A successful verification resets both layers — which is why the reset path stays reachable while a key is under active spray. One exception: when the caller's key id was collapsed into its address's shared fallback partition by the per-peer cap, a success clears the key's aggregate but leaves that shared partition alone, since it also holds failures contributed by other key ids from the same address. The tracked partitions form a bounded LRU (`ApiKeyFailureTrackedPeers`) whose eviction prefers fully expired windows and never removes an over-limit partition below a 2x transient overshoot ceiling, so the cap bounds memory without becoming a reset button for an active block. `ResourceExhausted` reveals only that throttling is in effect, not whether any particular secret was valid, preserving the opaque-failure property. Refusals increment `mxgateway.auth.throttled`, tagged `stage=peer|aggregate` and nothing else — `/metrics` is anonymous by default (`MxGateway:Metrics:RequireAuthentication` is opt-in), so neither key ids nor peer addresses may appear there. 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). diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index d8ed73d..88d555e 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -430,6 +430,41 @@ Suggested mapping: - Galaxy SQL metadata endpoints if added: `metadata:read` - worker shutdown diagnostics and key management: `admin` +## Metrics Endpoint Authentication (SEC-14) + +Decision: `/metrics` stays anonymous by default and can be put behind +authentication with `MxGateway:Metrics:RequireAuthentication=true`; `/health` +stays anonymous unconditionally. + +- **Opt-in rather than on by default.** Existing Prometheus scrapers on the + deployed hosts scrape anonymously; flipping the default would silently break + them on upgrade. Hosts whose network exposure makes the counters sensitive + enable it explicitly. +- **Health is never gated.** The family Overview dashboard (another repo) polls + `/health/active` without credentials, and liveness/readiness probes are + conventionally anonymous. Health responses carry status only. +- **Credentials reuse existing plumbing.** One `MxGateway.Metrics` scheme + accepts an API key with the `admin` scope (verified through the same cached + `IApiKeyVerifier` and the same per-peer failure limiter as gRPC, so `/metrics` + is not a way around the gRPC brute-force throttle) or, when no authorization + header is sent, the dashboard login cookie with a real dashboard role. No new + credential type or store is introduced. +- **`admin` scope, any dashboard role.** Metrics are an operator surface, so a + data-plane client key (`invoke:*`, `events:read`) is not enough. A dashboard + `Viewer` qualifies because every dashboard role already sees the same aggregate + counters on the dashboard; the exported metrics carry no session ids or tag + values. +- **No environment bypass.** The requirement reads no options, so anonymous + localhost and `Authentication:Mode=Disabled` — both of which satisfy the + dashboard Viewer policy — do not satisfy it. Only dev-only + `Dashboard:DisableLogin` (which authenticates every request as an + Administrator, and is rejected in Production) lets an unauthenticated scrape + through. +- **One scheme, plain 401.** Listing the cookie scheme on the policy directly + would challenge an anonymous scrape with a 302 redirect to `/login`; a single + scheme that falls back to the cookie internally keeps the challenge a `401` + with `WWW-Authenticate: Bearer`, which scrapers understand. + ## Worker Process Identity Decision: run workers as the gateway service identity for v1. diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index b9c8e69..8323351 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -419,6 +419,35 @@ model requires otherwise. | `MxGateway:Security:AuditRetentionDays` | `90` | Days of canonical audit history kept in the `audit_event` table. The audit drain sweeps once at startup and hourly thereafter, deleting rows older than this window; without it the table grows without bound inside the same SQLite file the authentication hot path reads. Rows whose timestamp SQLite cannot parse are never swept. Must be greater than zero — retention can be widened but not switched off. | | `MxGateway:Security:ApiKeyFailureTrackedPeers` | `4096` | Maximum distinct partitions tracked by the failure counter (a bounded LRU) so a spray of unique tokens cannot grow memory without limit. It cannot be used to flush an active block either: only a validly shaped `mxgw__` token mints a key-id partition (everything else lands on the sender's transport-peer partition), each address may mint at most 32 key-id partitions before the overflow collapses onto that address's fallback partition, and eviction prefers fully expired windows, never removing an over-limit partition until the map exceeds twice this cap. Must be greater than zero. | +## Metrics Options + +Bound from `MxGateway:Metrics`. Governs the Prometheus `/metrics` scrape endpoint +(mounted by the shared `ZB.MOM.WW.Telemetry` package). The health endpoints +(`/healthz`, `/health/ready`, `/health/active`) are not affected by this section and +always stay anonymous: the family Overview dashboard polls `/health/active` from +another repo without credentials, so gating it would break that consumer. + +| Option | Default | Description | +|--------|---------|-------------| +| `MxGateway:Metrics:RequireAuthentication` | `false` | When `true`, `/metrics` requires an authenticated caller (SEC-14). A scrape is accepted when it presents an API key carrying the `admin` scope as `Authorization: Bearer mxgw__`, or — with no authorization header — a dashboard login cookie holding a real dashboard role (`Administrator` or `Viewer`). Anonymous localhost access and `Authentication:Mode=Disabled` do **not** satisfy it. An anonymous scrape gets `401` with `WWW-Authenticate: Bearer`; an authenticated caller without `admin` scope or a dashboard role gets `403`; a peer throttled by the API-key failure limiter (`MxGateway:Security:ApiKeyFailure*`, shared with the gRPC path) gets `429`. The default keeps the endpoint anonymous so existing scrapers keep working. | + +Configure a Prometheus scraper against an authenticated endpoint with a dedicated +admin-scoped key, for example: + +```yaml +scrape_configs: + - job_name: mxgateway + authorization: + type: Bearer + credentials_file: /etc/prometheus/mxgateway.key # contains mxgw__ + static_configs: + - targets: ["gateway-host:5130"] +``` + +The setting is read from configuration when endpoints are mapped, so changing it +requires a restart. With dev-only `Dashboard:DisableLogin=true` every request is +auto-authenticated as an Administrator, which also satisfies `/metrics`. + ## Galaxy Options | Option | Default | Description | diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs index 3063604..39e72ba 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs @@ -52,4 +52,7 @@ public sealed class GatewayOptions /// login / API-key rate limiting). /// public SecurityOptions Security { get; init; } = new(); + + /// Gets Prometheus /metrics endpoint options. + public MetricsOptions Metrics { get; init; } = new(); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/MetricsOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/MetricsOptions.cs new file mode 100644 index 0000000..6507142 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/MetricsOptions.cs @@ -0,0 +1,15 @@ +namespace ZB.MOM.WW.MxGateway.Server.Configuration; + +/// Options for the Prometheus /metrics scrape endpoint (MxGateway:Metrics). +public sealed class MetricsOptions +{ + /// + /// Gets a value indicating whether /metrics requires an authenticated caller. When + /// , a scrape must present either an API key carrying the admin + /// scope (Authorization: Bearer mxgw_…) or a dashboard login cookie holding a real + /// dashboard role; anonymous localhost access does not qualify. Default + /// keeps the endpoint anonymous for existing scrapers. The /health endpoints stay + /// anonymous either way. + /// + public bool RequireAuthentication { get; init; } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index af44744..d80f859 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -113,6 +113,7 @@ public static class GatewayApplication builder.Services.AddZbSecrets(builder.Configuration, "Secrets"); builder.Services.AddSqliteAuthStore(builder.Configuration); builder.Services.AddGatewayGrpcAuthorization(); + builder.Services.AddGatewayMetricsAuthorization(); AddLoginRateLimiter(builder); builder.Services.AddHealthChecks() .AddTypeActivatedCheck( @@ -350,8 +351,19 @@ public static class GatewayApplication { endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath()); + // /health stays anonymous unconditionally: an off-repo Overview dashboard polls + // /health/active without credentials. endpoints.MapZbHealth(); - endpoints.MapZbMetrics(); + IEndpointConventionBuilder metricsEndpoint = endpoints.MapZbMetrics(); + // Read from configuration rather than IOptions: resolving the options here + // would run whole-options validation during Build instead of at StartAsync. + if (endpoints.ServiceProvider + .GetRequiredService() + .GetValue("MxGateway:Metrics:RequireAuthentication")) + { + // Opt-in (SEC-14): an admin-scoped API key or a dashboard login with a real role. + metricsEndpoint.RequireAuthorization(MetricsAuthorizationDefaults.Policy); + } endpoints.MapGrpcService(); endpoints.MapZbGalaxyRepository(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/MetricsAuthorization.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/MetricsAuthorization.cs new file mode 100644 index 0000000..4779f16 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/MetricsAuthorization.cs @@ -0,0 +1,210 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Metrics; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; + +/// Names shared by the optional /metrics authentication (MxGateway:Metrics:RequireAuthentication). +public static class MetricsAuthorizationDefaults +{ + /// Authentication scheme that authenticates a metrics scrape. + public const string AuthenticationScheme = "MxGateway.Metrics"; + + /// Authorization policy attached to /metrics when authentication is required. + public const string Policy = "MxGateway.Metrics"; + + /// Claim type carrying one API-key scope on a principal the metrics scheme authenticated. + public const string ScopeClaimType = "mxgateway:scope"; +} + +/// +/// Authenticates a /metrics scrape from either credential the gateway already issues: an API +/// key presented as Authorization: Bearer mxgw_… (verified through the same cached +/// and per-peer failure limiter as gRPC calls), or, when no +/// authorization header is sent, the dashboard login cookie. +/// +/// +/// One scheme fronting both credentials keeps the challenge a plain 401 with +/// WWW-Authenticate: Bearer, which is what a Prometheus scraper understands; listing the +/// cookie scheme on the policy directly would turn an anonymous scrape into a 302 redirect to the +/// login page. A throttled key partition answers 429, mirroring the gRPC +/// ResourceExhausted. Failure reasons are never echoed to the caller and the presented +/// secret is never logged. +/// +public sealed class MetricsAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IApiKeyVerifier apiKeyVerifier, + ApiKeyFailureLimiter failureLimiter, + IAuthenticationSchemeProvider schemeProvider, + GatewayMetrics metrics) + : AuthenticationHandler(options, logger, encoder) +{ + private bool _throttled; + + /// + protected override async Task HandleAuthenticateAsync() + { + string? authorizationHeader = Request.Headers.Authorization; + if (string.IsNullOrWhiteSpace(authorizationHeader)) + { + return await AuthenticateDashboardCookieAsync().ConfigureAwait(false); + } + + // Same throttle as the gRPC interceptor: checked before the store read so online guessing + // against /metrics cannot spend a verification per attempt or bypass the gRPC limiter. + ApiKeyThrottlePartition partition = new( + Context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + GatewayGrpcAuthorizationInterceptor.TryResolveKeyId(authorizationHeader)); + ApiKeyThrottleDecision decision = failureLimiter.Check( + partition, + out ApiKeyFailureLimiter.PartitionResolution resolution); + if (decision is ApiKeyThrottleDecision.ThrottledByPeer or ApiKeyThrottleDecision.ThrottledByAggregate) + { + _throttled = true; + metrics.RecordAuthThrottled(decision == ApiKeyThrottleDecision.ThrottledByPeer ? "peer" : "aggregate"); + return AuthenticateResult.Fail("Too many authentication attempts."); + } + + ApiKeyVerification verification = await apiKeyVerifier + .VerifyAsync(authorizationHeader, Context.RequestAborted) + .ConfigureAwait(false); + if (!verification.Succeeded || verification.Identity is null) + { + failureLimiter.RecordFailure(partition); + return AuthenticateResult.Fail("Missing or invalid API key."); + } + + failureLimiter.Reset(partition, resolution); + + List claims = [new Claim(ClaimTypes.Name, verification.Identity.KeyId)]; + foreach (string scope in verification.Identity.Scopes) + { + claims.Add(new Claim(MetricsAuthorizationDefaults.ScopeClaimType, scope)); + } + + ClaimsPrincipal principal = new(new ClaimsIdentity(claims, Scheme.Name)); + return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); + } + + /// + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + if (_throttled) + { + Response.StatusCode = StatusCodes.Status429TooManyRequests; + return Task.CompletedTask; + } + + Response.StatusCode = StatusCodes.Status401Unauthorized; + Response.Headers.WWWAuthenticate = "Bearer"; + return Task.CompletedTask; + } + + // No authorization header: fall back to the dashboard login cookie, so an operator already + // signed in to the dashboard can open /metrics in the same browser. + private async Task AuthenticateDashboardCookieAsync() + { + if (await schemeProvider.GetSchemeAsync(DashboardAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false) + is null) + { + return AuthenticateResult.NoResult(); + } + + AuthenticateResult cookie = await Context + .AuthenticateAsync(DashboardAuthenticationDefaults.AuthenticationScheme) + .ConfigureAwait(false); + return cookie.Succeeded && cookie.Principal is not null + ? AuthenticateResult.Success(new AuthenticationTicket(cookie.Principal, Scheme.Name)) + : AuthenticateResult.NoResult(); + } +} + +/// Requirement for /metrics access; see . +public sealed class MetricsAccessRequirement : IAuthorizationRequirement +{ +} + +/// +/// Grants /metrics to an API key carrying the admin scope or to a dashboard login +/// holding a real dashboard role (Administrator or Viewer). It reads no options, so neither +/// anonymous localhost nor Authentication:Mode=Disabled can satisfy it. +/// +/// +/// A Viewer login qualifies because every dashboard role already sees the same aggregate counters +/// on the dashboard itself; the metrics carry no per-session or per-tag data. An API key needs +/// admin because metrics are an operator surface, not one of the data-plane scopes a client +/// key is normally issued with. +/// +public sealed class MetricsAccessHandler : AuthorizationHandler +{ + /// + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + MetricsAccessRequirement requirement) + { + foreach (ClaimsIdentity identity in context.User.Identities) + { + if (!identity.IsAuthenticated) + { + continue; + } + + // Scope claims count only on an identity the metrics scheme minted from a verified key. + bool isApiKeyIdentity = string.Equals( + identity.AuthenticationType, + MetricsAuthorizationDefaults.AuthenticationScheme, + StringComparison.Ordinal); + if (isApiKeyIdentity + && identity.HasClaim(MetricsAuthorizationDefaults.ScopeClaimType, GatewayScopes.Admin)) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + + if (!isApiKeyIdentity + && (identity.HasClaim(identity.RoleClaimType, DashboardRoles.Admin) + || identity.HasClaim(identity.RoleClaimType, DashboardRoles.Viewer))) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + } + + return Task.CompletedTask; + } +} + +/// Registration for the optional /metrics authentication. +public static class MetricsAuthorizationServiceCollectionExtensions +{ + /// + /// Registers the metrics authentication scheme, the + /// policy and its handler. Registration is unconditional and inert: the policy only takes effect + /// when MxGateway:Metrics:RequireAuthentication attaches it to the endpoint. + /// + /// Service collection to register into. + /// The same service collection, for chaining. + public static IServiceCollection AddGatewayMetricsAuthorization(this IServiceCollection services) + { + services.AddAuthentication() + .AddScheme( + MetricsAuthorizationDefaults.AuthenticationScheme, + _ => { }); + services.AddAuthorizationBuilder() + .AddPolicy( + MetricsAuthorizationDefaults.Policy, + policy => policy + .AddAuthenticationSchemes(MetricsAuthorizationDefaults.AuthenticationScheme) + .AddRequirements(new MetricsAccessRequirement())); + services.AddSingleton(); + + return services; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs index 1ebfbad..a7676dc 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs @@ -98,6 +98,70 @@ public sealed class GatewayApplicationTests } } + /// + /// With MxGateway:Metrics:RequireAuthentication=true an anonymous scrape is refused with a + /// plain 401 Bearer challenge (not a login redirect), while /health/active — polled by the + /// off-repo Overview dashboard without credentials — stays anonymous. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Build_WhenMetricsRequireAuthentication_RefusesAnonymousScrapeButNotHealth() + { + await using WebApplication app = GatewayApplication.Build( + ["--urls=http://127.0.0.1:0", "--MxGateway:Metrics:RequireAuthentication=true"]); + await app.StartAsync(); + try + { + using HttpClient client = new() { BaseAddress = new Uri(app.Urls.First()) }; + + using HttpResponseMessage metrics = await client.GetAsync("/metrics"); + using HttpRequestMessage badKeyRequest = new(HttpMethod.Get, "/metrics"); + badKeyRequest.Headers.TryAddWithoutValidation("Authorization", "Bearer mxgw_nobody_not-a-real-secret"); + using HttpResponseMessage badKey = await client.SendAsync(badKeyRequest); + using HttpResponseMessage health = await client.GetAsync("/health/active"); + + Assert.Equal(HttpStatusCode.Unauthorized, metrics.StatusCode); + Assert.Contains(metrics.Headers.WwwAuthenticate, value => value.Scheme == "Bearer"); + Assert.Equal(HttpStatusCode.Unauthorized, badKey.StatusCode); + Assert.NotEqual(HttpStatusCode.Unauthorized, health.StatusCode); + Assert.NotEqual(HttpStatusCode.Forbidden, health.StatusCode); + } + finally + { + await app.StopAsync(); + } + } + + /// + /// The dashboard-login path: with dev-only Dashboard:DisableLogin every request carries + /// the auto-login Administrator principal under the dashboard cookie scheme, which the metrics + /// scheme accepts when no authorization header is sent. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Build_WhenMetricsRequireAuthentication_AcceptsDashboardLogin() + { + await using WebApplication app = GatewayApplication.Build( + [ + "--urls=http://127.0.0.1:0", + "--MxGateway:Metrics:RequireAuthentication=true", + "--MxGateway:Dashboard:DisableLogin=true", + ]); + await app.StartAsync(); + try + { + using HttpClient client = new() { BaseAddress = new Uri(app.Urls.First()) }; + + using HttpResponseMessage response = await client.GetAsync("/metrics"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + finally + { + await app.StopAsync(); + } + } + /// Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled. /// A task that represents the asynchronous operation. [Fact] 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 76fc2ab..64828fe 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs @@ -1078,7 +1078,6 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests } } - /// An that yields no messages. /// The streamed message type. private sealed class EmptyStreamReader : IAsyncStreamReader diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/MetricsAuthorizationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/MetricsAuthorizationTests.cs new file mode 100644 index 0000000..8d83767 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/MetricsAuthorizationTests.cs @@ -0,0 +1,249 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Metrics; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; + +using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity; + +namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization; + +/// +/// The optional /metrics authentication (MxGateway:Metrics:RequireAuthentication, +/// SEC-14): the scheme that authenticates a scrape and the requirement that authorizes it. +/// +public sealed class MetricsAuthorizationTests +{ + private const string AdminHeader = "Bearer mxgw_scraper_secret"; + + /// A verified API key authenticates and carries its scopes as claims. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Authenticate_WithValidApiKey_SucceedsWithScopeClaims() + { + CountingVerifier verifier = new(Success(GatewayScopes.Admin)); + DefaultHttpContext httpContext = ContextWithAuthorization(AdminHeader); + + AuthenticateResult result = await AuthenticateAsync(verifier, httpContext); + + Assert.True(result.Succeeded); + Assert.Equal("scraper", result.Principal!.Identity!.Name); + Assert.True(result.Principal.HasClaim(MetricsAuthorizationDefaults.ScopeClaimType, GatewayScopes.Admin)); + Assert.Equal(AdminHeader, verifier.LastHeader); + } + + /// An invalid key fails authentication with an opaque reason. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Authenticate_WithInvalidApiKey_Fails() + { + CountingVerifier verifier = new(new ApiKeyVerification(false, null, ApiKeyFailure.SecretMismatch)); + + AuthenticateResult result = await AuthenticateAsync(verifier, ContextWithAuthorization(AdminHeader)); + + Assert.False(result.Succeeded); + Assert.NotNull(result.Failure); + Assert.DoesNotContain("secret", result.Failure!.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Repeated bad keys from one peer trip the shared failure limiter, after which the verifier is + /// no longer reached and the challenge answers 429 — /metrics is not a bypass of the gRPC throttle. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Authenticate_AfterRepeatedFailures_IsThrottledBeforeVerification() + { + CountingVerifier verifier = new(new ApiKeyVerification(false, null, ApiKeyFailure.SecretMismatch)); + ApiKeyFailureLimiter limiter = new( + limit: 2, + TimeSpan.FromMinutes(1), + maxPartitions: 16, + aggregateLimit: 0, + probeInterval: TimeSpan.Zero, + TimeProvider.System); + + for (int attempt = 0; attempt < 2; attempt++) + { + await AuthenticateAsync(verifier, ContextWithAuthorization(AdminHeader), limiter); + } + + DefaultHttpContext throttled = ContextWithAuthorization(AdminHeader); + MetricsAuthenticationHandler handler = await CreateHandlerAsync(verifier, throttled, limiter); + AuthenticateResult result = await handler.AuthenticateAsync(); + await handler.ChallengeAsync(new AuthenticationProperties()); + + Assert.False(result.Succeeded); + Assert.Equal(2, verifier.CallCount); + Assert.Equal(StatusCodes.Status429TooManyRequests, throttled.Response.StatusCode); + } + + /// An anonymous scrape is challenged with a plain 401 Bearer, not a login redirect. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Challenge_WithoutCredentials_Returns401Bearer() + { + DefaultHttpContext httpContext = new(); + MetricsAuthenticationHandler handler = await CreateHandlerAsync( + new CountingVerifier(Success(GatewayScopes.Admin)), + httpContext); + + AuthenticateResult result = await handler.AuthenticateAsync(); + await handler.ChallengeAsync(new AuthenticationProperties()); + + Assert.True(result.None); + Assert.Equal(StatusCodes.Status401Unauthorized, httpContext.Response.StatusCode); + Assert.Equal("Bearer", httpContext.Response.Headers.WWWAuthenticate.ToString()); + } + + /// An API key holding the admin scope satisfies the metrics requirement. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Requirement_ApiKeyWithAdminScope_Succeeds() + { + Assert.True(await EvaluateAsync(ApiKeyPrincipal(GatewayScopes.Admin))); + } + + /// An API key without the admin scope does not satisfy the requirement. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Requirement_ApiKeyWithoutAdminScope_Fails() + { + Assert.False(await EvaluateAsync(ApiKeyPrincipal(GatewayScopes.EventsRead, GatewayScopes.InvokeRead))); + } + + /// A scope claim counts only on an identity the metrics scheme minted from a verified key. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Requirement_ScopeClaimOnForeignIdentity_Fails() + { + ClaimsPrincipal principal = new(new ClaimsIdentity( + [new Claim(MetricsAuthorizationDefaults.ScopeClaimType, GatewayScopes.Admin)], + DashboardAuthenticationDefaults.AuthenticationScheme)); + + Assert.False(await EvaluateAsync(principal)); + } + + /// A dashboard login holding a real dashboard role satisfies the requirement. + /// The dashboard role. + /// A task that represents the asynchronous operation. + [Theory] + [InlineData(DashboardRoles.Admin)] + [InlineData(DashboardRoles.Viewer)] + public async Task Requirement_DashboardLoginWithRole_Succeeds(string role) + { + ClaimsPrincipal principal = new(new ClaimsIdentity( + [new Claim(ClaimTypes.Name, "operator"), new Claim(ClaimTypes.Role, role)], + DashboardAuthenticationDefaults.AuthenticationScheme)); + + Assert.True(await EvaluateAsync(principal)); + } + + /// An unauthenticated principal (anonymous localhost included) never satisfies it. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Requirement_Unauthenticated_Fails() + { + ClaimsPrincipal principal = new(new ClaimsIdentity([new Claim(ClaimTypes.Role, DashboardRoles.Admin)])); + + Assert.False(await EvaluateAsync(principal)); + } + + private static async Task EvaluateAsync(ClaimsPrincipal principal) + { + MetricsAccessRequirement requirement = new(); + AuthorizationHandlerContext context = new([requirement], principal, resource: null); + await new MetricsAccessHandler().HandleAsync(context); + return context.HasSucceeded; + } + + private static ClaimsPrincipal ApiKeyPrincipal(params string[] scopes) + { + List claims = [new Claim(ClaimTypes.Name, "scraper")]; + claims.AddRange(scopes.Select(scope => new Claim(MetricsAuthorizationDefaults.ScopeClaimType, scope))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, MetricsAuthorizationDefaults.AuthenticationScheme)); + } + + private static async Task AuthenticateAsync( + IApiKeyVerifier verifier, + HttpContext httpContext, + ApiKeyFailureLimiter? limiter = null) + { + MetricsAuthenticationHandler handler = await CreateHandlerAsync(verifier, httpContext, limiter); + return await handler.AuthenticateAsync(); + } + + private static async Task CreateHandlerAsync( + IApiKeyVerifier verifier, + HttpContext httpContext, + ApiKeyFailureLimiter? limiter = null) + { + MetricsAuthenticationHandler handler = new( + new StaticOptionsMonitor(), + NullLoggerFactory.Instance, + UrlEncoder.Default, + verifier, + limiter ?? new ApiKeyFailureLimiter(1000, TimeSpan.FromMinutes(1), 1024, 0, TimeSpan.Zero, TimeProvider.System), + new AuthenticationSchemeProvider(Options.Create(new AuthenticationOptions())), + new GatewayMetrics()); + await handler.InitializeAsync( + new AuthenticationScheme( + MetricsAuthorizationDefaults.AuthenticationScheme, + displayName: null, + typeof(MetricsAuthenticationHandler)), + httpContext); + return handler; + } + + private static DefaultHttpContext ContextWithAuthorization(string header) + { + DefaultHttpContext context = new(); + context.Request.Headers.Authorization = header; + context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("198.51.100.7"); + return context; + } + + private static ApiKeyVerification Success(params string[] scopes) => new( + Succeeded: true, + Identity: new LibApiKeyIdentity( + KeyId: "scraper", + DisplayName: "Scraper", + Scopes: new HashSet(scopes, StringComparer.Ordinal), + Constraints: null), + Failure: null); + + private sealed class CountingVerifier(ApiKeyVerification result) : IApiKeyVerifier + { + /// Gets the number of verifications performed. + public int CallCount { get; private set; } + + /// Gets the last authorization header presented. + public string? LastHeader { get; private set; } + + /// + public Task VerifyAsync(string authorizationHeader, CancellationToken ct) + { + CallCount++; + LastHeader = authorizationHeader; + return Task.FromResult(result); + } + } + + private sealed class StaticOptionsMonitor : IOptionsMonitor + { + /// + public AuthenticationSchemeOptions CurrentValue { get; } = new(); + + /// + public AuthenticationSchemeOptions Get(string? name) => CurrentValue; + + /// + public IDisposable? OnChange(Action listener) => null; + } +}