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