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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user