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
@@ -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]