feat(security): opt-in authentication for /metrics (SEC-14)

New MxGateway:Metrics:RequireAuthentication (default false). When true,
/metrics requires an API key with the admin scope (verified through the
cached verifier and the shared API-key failure limiter) or a dashboard
login with a real dashboard role; anonymous localhost and
Authentication:Mode=Disabled do not qualify. An anonymous scrape gets a
plain 401 Bearer challenge, a throttled peer 429. /health stays anonymous
because the off-repo Overview dashboard polls /health/active.

MapZbMetrics returns IEndpointConventionBuilder, so the policy is attached
with RequireAuthorization.
This commit is contained in:
Joseph Doherty
2026-10-22 06:37:31 -04:00
parent ed3a2c9c8b
commit 65bca804bb
10 changed files with 619 additions and 3 deletions
+1 -1
View File
@@ -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).
+35
View File
@@ -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.
+29
View File
@@ -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_<keyId>_<secret>` 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_<keyId>_<secret>`, 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_<keyId>_<secret>
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 |
@@ -52,4 +52,7 @@ public sealed class GatewayOptions
/// login / API-key rate limiting).
/// </summary>
public SecurityOptions Security { get; init; } = new();
/// <summary>Gets Prometheus <c>/metrics</c> endpoint options.</summary>
public MetricsOptions Metrics { get; init; } = new();
}
@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>Options for the Prometheus <c>/metrics</c> scrape endpoint (<c>MxGateway:Metrics</c>).</summary>
public sealed class MetricsOptions
{
/// <summary>
/// Gets a value indicating whether <c>/metrics</c> requires an authenticated caller. When
/// <see langword="true"/>, a scrape must present either an API key carrying the <c>admin</c>
/// scope (<c>Authorization: Bearer mxgw_…</c>) or a dashboard login cookie holding a real
/// dashboard role; anonymous localhost access does not qualify. Default <see langword="false"/>
/// keeps the endpoint anonymous for existing scrapers. The <c>/health</c> endpoints stay
/// anonymous either way.
/// </summary>
public bool RequireAuthentication { get; init; }
}
@@ -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<AuthStoreHealthCheck>(
@@ -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<GatewayOptions>: resolving the options here
// would run whole-options validation during Build instead of at StartAsync.
if (endpoints.ServiceProvider
.GetRequiredService<IConfiguration>()
.GetValue<bool>("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<MxAccessGatewayService>();
endpoints.MapZbGalaxyRepository();
@@ -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;
/// <summary>Names shared by the optional <c>/metrics</c> authentication (<c>MxGateway:Metrics:RequireAuthentication</c>).</summary>
public static class MetricsAuthorizationDefaults
{
/// <summary>Authentication scheme that authenticates a metrics scrape.</summary>
public const string AuthenticationScheme = "MxGateway.Metrics";
/// <summary>Authorization policy attached to <c>/metrics</c> when authentication is required.</summary>
public const string Policy = "MxGateway.Metrics";
/// <summary>Claim type carrying one API-key scope on a principal the metrics scheme authenticated.</summary>
public const string ScopeClaimType = "mxgateway:scope";
}
/// <summary>
/// Authenticates a <c>/metrics</c> scrape from either credential the gateway already issues: an API
/// key presented as <c>Authorization: Bearer mxgw_…</c> (verified through the same cached
/// <see cref="IApiKeyVerifier"/> and per-peer failure limiter as gRPC calls), or, when no
/// authorization header is sent, the dashboard login cookie.
/// </summary>
/// <remarks>
/// One scheme fronting both credentials keeps the challenge a plain <c>401</c> with
/// <c>WWW-Authenticate: Bearer</c>, 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 <c>429</c>, mirroring the gRPC
/// <c>ResourceExhausted</c>. Failure reasons are never echoed to the caller and the presented
/// secret is never logged.
/// </remarks>
public sealed class MetricsAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IApiKeyVerifier apiKeyVerifier,
ApiKeyFailureLimiter failureLimiter,
IAuthenticationSchemeProvider schemeProvider,
GatewayMetrics metrics)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
private bool _throttled;
/// <inheritdoc />
protected override async Task<AuthenticateResult> 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<Claim> 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));
}
/// <inheritdoc />
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<AuthenticateResult> 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();
}
}
/// <summary>Requirement for <c>/metrics</c> access; see <see cref="MetricsAccessHandler"/>.</summary>
public sealed class MetricsAccessRequirement : IAuthorizationRequirement
{
}
/// <summary>
/// Grants <c>/metrics</c> to an API key carrying the <c>admin</c> scope or to a dashboard login
/// holding a real dashboard role (Administrator or Viewer). It reads no options, so neither
/// anonymous localhost nor <c>Authentication:Mode=Disabled</c> can satisfy it.
/// </summary>
/// <remarks>
/// 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
/// <c>admin</c> because metrics are an operator surface, not one of the data-plane scopes a client
/// key is normally issued with.
/// </remarks>
public sealed class MetricsAccessHandler : AuthorizationHandler<MetricsAccessRequirement>
{
/// <inheritdoc />
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;
}
}
/// <summary>Registration for the optional <c>/metrics</c> authentication.</summary>
public static class MetricsAuthorizationServiceCollectionExtensions
{
/// <summary>
/// Registers the metrics authentication scheme, the <see cref="MetricsAuthorizationDefaults.Policy"/>
/// policy and its handler. Registration is unconditional and inert: the policy only takes effect
/// when <c>MxGateway:Metrics:RequireAuthentication</c> attaches it to the endpoint.
/// </summary>
/// <param name="services">Service collection to register into.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddGatewayMetricsAuthorization(this IServiceCollection services)
{
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, MetricsAuthenticationHandler>(
MetricsAuthorizationDefaults.AuthenticationScheme,
_ => { });
services.AddAuthorizationBuilder()
.AddPolicy(
MetricsAuthorizationDefaults.Policy,
policy => policy
.AddAuthenticationSchemes(MetricsAuthorizationDefaults.AuthenticationScheme)
.AddRequirements(new MetricsAccessRequirement()));
services.AddSingleton<IAuthorizationHandler, MetricsAccessHandler>();
return services;
}
}
@@ -98,6 +98,70 @@ public sealed class GatewayApplicationTests
}
}
/// <summary>
/// With <c>MxGateway:Metrics:RequireAuthentication=true</c> an anonymous scrape is refused with a
/// plain 401 Bearer challenge (not a login redirect), while <c>/health/active</c> — polled by the
/// off-repo Overview dashboard without credentials — stays anonymous.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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();
}
}
/// <summary>
/// The dashboard-login path: with dev-only <c>Dashboard:DisableLogin</c> every request carries
/// the auto-login Administrator principal under the dashboard cookie scheme, which the metrics
/// scheme accepts when no authorization header is sent.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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();
}
}
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -1078,7 +1078,6 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
}
}
/// <summary>An <see cref="IAsyncStreamReader{T}"/> that yields no messages.</summary>
/// <typeparam name="T">The streamed message type.</typeparam>
private sealed class EmptyStreamReader<T> : IAsyncStreamReader<T>
@@ -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;
/// <summary>
/// The optional <c>/metrics</c> authentication (<c>MxGateway:Metrics:RequireAuthentication</c>,
/// SEC-14): the scheme that authenticates a scrape and the requirement that authorizes it.
/// </summary>
public sealed class MetricsAuthorizationTests
{
private const string AdminHeader = "Bearer mxgw_scraper_secret";
/// <summary>A verified API key authenticates and carries its scopes as claims.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>An invalid key fails authentication with an opaque reason.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>An anonymous scrape is challenged with a plain 401 Bearer, not a login redirect.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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());
}
/// <summary>An API key holding the admin scope satisfies the metrics requirement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Requirement_ApiKeyWithAdminScope_Succeeds()
{
Assert.True(await EvaluateAsync(ApiKeyPrincipal(GatewayScopes.Admin)));
}
/// <summary>An API key without the admin scope does not satisfy the requirement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Requirement_ApiKeyWithoutAdminScope_Fails()
{
Assert.False(await EvaluateAsync(ApiKeyPrincipal(GatewayScopes.EventsRead, GatewayScopes.InvokeRead)));
}
/// <summary>A scope claim counts only on an identity the metrics scheme minted from a verified key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
/// <summary>A dashboard login holding a real dashboard role satisfies the requirement.</summary>
/// <param name="role">The dashboard role.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
/// <summary>An unauthenticated principal (anonymous localhost included) never satisfies it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<bool> 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<Claim> 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<AuthenticateResult> AuthenticateAsync(
IApiKeyVerifier verifier,
HttpContext httpContext,
ApiKeyFailureLimiter? limiter = null)
{
MetricsAuthenticationHandler handler = await CreateHandlerAsync(verifier, httpContext, limiter);
return await handler.AuthenticateAsync();
}
private static async Task<MetricsAuthenticationHandler> 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<string>(scopes, StringComparer.Ordinal),
Constraints: null),
Failure: null);
private sealed class CountingVerifier(ApiKeyVerification result) : IApiKeyVerifier
{
/// <summary>Gets the number of verifications performed.</summary>
public int CallCount { get; private set; }
/// <summary>Gets the last authorization header presented.</summary>
public string? LastHeader { get; private set; }
/// <inheritdoc />
public Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
{
CallCount++;
LastHeader = authorizationHeader;
return Task.FromResult(result);
}
}
private sealed class StaticOptionsMonitor : IOptionsMonitor<AuthenticationSchemeOptions>
{
/// <inheritdoc />
public AuthenticationSchemeOptions CurrentValue { get; } = new();
/// <inheritdoc />
public AuthenticationSchemeOptions Get(string? name) => CurrentValue;
/// <inheritdoc />
public IDisposable? OnChange(Action<AuthenticationSchemeOptions, string?> listener) => null;
}
}