fix(metrics-auth): DisableLogin no longer opens /metrics; log correlation pair; host-level tests
- The metrics scheme skips its dashboard-cookie fallback while Dashboard:DisableLogin is on, so the dev auto-login handler cannot satisfy RequireAuthentication=true. - Invoke logs client_correlation_id <-> worker envelope id once at Debug. - MetricsEndpointAuthenticationTests drives the real pipeline: anonymous loopback 401 (no /login redirect), Viewer cookie 200, admin key 200, non-admin key 403, DisableLogin 401, /health/active 200. - Docs: DisableLogin behaviour; the /metrics limiter partition is per IP vs gRPC per connection, so only the per-key aggregate is shared.
This commit is contained in:
@@ -148,6 +148,19 @@ public sealed class MxAccessGatewayService(
|
||||
// the same value the constraint-denied replies carry — so a client can match every
|
||||
// reply to its request. The worker reply carries the gateway's internal envelope id,
|
||||
// which is a gateway↔worker routing detail and is overwritten here, empty included.
|
||||
// Record the pairing once, before the envelope id is overwritten, so a client error
|
||||
// report quoting its correlation id can be matched to gateway and worker log lines
|
||||
// (which carry the envelope id). Ids and the command kind only — never values.
|
||||
if (logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Invoke {CommandKind} for session {SessionId}: client correlation id {ClientCorrelationId} maps to worker envelope correlation id {WorkerCorrelationId}.",
|
||||
command.Kind,
|
||||
request.SessionId,
|
||||
request.ClientCorrelationId,
|
||||
publicReply.CorrelationId);
|
||||
}
|
||||
|
||||
publicReply.CorrelationId = request.ClientCorrelationId;
|
||||
|
||||
session.TrackCommandReply(commandToInvoke, publicReply);
|
||||
|
||||
@@ -4,6 +4,7 @@ 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.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||
|
||||
@@ -34,7 +35,9 @@ public static class MetricsAuthorizationDefaults
|
||||
/// 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.
|
||||
/// secret is never logged. The limiter's per-peer partition here is the remote IP, whereas the gRPC
|
||||
/// interceptor's is the gRPC peer string (address and port, i.e. per connection), so the two paths
|
||||
/// share only the per-key-id aggregate layer.
|
||||
/// </remarks>
|
||||
public sealed class MetricsAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
@@ -43,6 +46,7 @@ public sealed class MetricsAuthenticationHandler(
|
||||
IApiKeyVerifier apiKeyVerifier,
|
||||
ApiKeyFailureLimiter failureLimiter,
|
||||
IAuthenticationSchemeProvider schemeProvider,
|
||||
IOptions<GatewayOptions> gatewayOptions,
|
||||
GatewayMetrics metrics)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
@@ -111,6 +115,14 @@ public sealed class MetricsAuthenticationHandler(
|
||||
// signed in to the dashboard can open /metrics in the same browser.
|
||||
private async Task<AuthenticateResult> AuthenticateDashboardCookieAsync()
|
||||
{
|
||||
// Dashboard:DisableLogin registers an always-succeeding auto-login handler under the cookie
|
||||
// scheme name. Falling back to it would let every anonymous request satisfy /metrics, so
|
||||
// with DisableLogin on, RequireAuthentication=true accepts API keys only.
|
||||
if (gatewayOptions.Value.Dashboard.DisableLogin)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
if (await schemeProvider.GetSchemeAsync(DashboardAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false)
|
||||
is null)
|
||||
{
|
||||
|
||||
@@ -98,70 +98,6 @@ 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]
|
||||
|
||||
@@ -505,6 +505,50 @@ public sealed class MxAccessGatewayServiceTests
|
||||
Assert.Equal(string.Empty, reply.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoke logs the client correlation id alongside the worker envelope id once, at Debug, so
|
||||
/// a client error report can be matched to gateway and worker logs.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invoke_LogsClientToWorkerCorrelationPairAtDebug()
|
||||
{
|
||||
FakeSessionManager sessionManager = new()
|
||||
{
|
||||
InvokeReply = new WorkerCommandReply
|
||||
{
|
||||
Reply = new MxCommandReply
|
||||
{
|
||||
SessionId = "session-1",
|
||||
CorrelationId = "envelope-7f3a",
|
||||
Kind = MxCommandKind.AddItem,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
AddItem = new AddItemReply { ItemHandle = 42 },
|
||||
},
|
||||
},
|
||||
};
|
||||
RecordingLogger<MxAccessGatewayService> logger = new();
|
||||
MxAccessGatewayService service = CreateService(sessionManager, logger: logger);
|
||||
MxCommandRequest request = new()
|
||||
{
|
||||
SessionId = "session-1",
|
||||
ClientCorrelationId = "client-trace-9",
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddItem,
|
||||
AddItem = new AddItemCommand { ServerHandle = 12, ItemDefinition = "Galaxy.Tag.Value" },
|
||||
},
|
||||
};
|
||||
|
||||
await service.Invoke(request, new TestServerCallContext());
|
||||
|
||||
(LogLevel level, string message, _) = Assert.Single(
|
||||
logger.Entries,
|
||||
entry => entry.Message.Contains("envelope-7f3a", StringComparison.Ordinal));
|
||||
Assert.Equal(LogLevel.Debug, level);
|
||||
Assert.Contains("client-trace-9", message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// StreamEvents hands the call's cancellation token to every response write, so a write
|
||||
/// blocked on a slow or vanished client is released when the call is cancelled.
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||
using ZB.MOM.WW.MxGateway.Server;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
|
||||
|
||||
/// <summary>
|
||||
/// Host-level coverage of <c>MxGateway:Metrics:RequireAuthentication=true</c> (SEC-14): each case
|
||||
/// builds the real gateway with <see cref="GatewayApplication.Build"/> and drives the full
|
||||
/// middleware pipeline (authentication, authorization, the dashboard cookie scheme, the API-key
|
||||
/// store) over HTTP on an ephemeral loopback port. Requests therefore arrive from loopback, which
|
||||
/// is exactly the anonymous-localhost case the dashboard Viewer policy would admit and /metrics
|
||||
/// must not.
|
||||
/// </summary>
|
||||
public sealed class MetricsEndpointAuthenticationTests
|
||||
{
|
||||
// A fixed test pepper: the configuration-backed pepper provider reads MxGateway:ApiKeyPepper,
|
||||
// and the auth store itself is isolated per test process by TestHostEnvironmentInitializer.
|
||||
private const string TestPepper = "metrics-endpoint-tests-pepper-0123456789abcdef";
|
||||
|
||||
/// <summary>
|
||||
/// An anonymous loopback scrape gets a plain 401 Bearer challenge — not a 302 to /login, and not
|
||||
/// the anonymous-localhost admission the dashboard Viewer policy grants.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AnonymousLoopbackScrape_Returns401BearerNotLoginRedirect()
|
||||
{
|
||||
await using WebApplication app = await StartAsync();
|
||||
using HttpClient client = CreateClient(app);
|
||||
|
||||
using HttpResponseMessage response = await client.GetAsync("/metrics");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Null(response.Headers.Location);
|
||||
Assert.Contains(response.Headers.WwwAuthenticate, value => value.Scheme == "Bearer");
|
||||
}
|
||||
|
||||
/// <summary>/health/active stays anonymous: the off-repo Overview dashboard polls it without credentials.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HealthActive_StaysAnonymous()
|
||||
{
|
||||
await using WebApplication app = await StartAsync();
|
||||
using HttpClient client = CreateClient(app);
|
||||
|
||||
using HttpResponseMessage response = await client.GetAsync("/health/active");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>A dashboard login cookie holding the Viewer role is accepted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DashboardViewerCookie_Returns200()
|
||||
{
|
||||
await using WebApplication app = await StartAsync();
|
||||
using HttpClient client = CreateClient(app);
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, "/metrics");
|
||||
request.Headers.Add("Cookie", MintDashboardCookie(app, DashboardRoles.Viewer));
|
||||
|
||||
using HttpResponseMessage response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>A real API key with the admin scope is accepted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AdminScopedApiKey_Returns200()
|
||||
{
|
||||
await using WebApplication app = await StartAsync();
|
||||
string token = await CreateKeyAsync(app, [GatewayScopes.Admin]);
|
||||
using HttpClient client = CreateClient(app);
|
||||
|
||||
using HttpResponseMessage response = await SendWithBearerAsync(client, token);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>A real API key without the admin scope authenticates but is forbidden.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NonAdminApiKey_Returns403()
|
||||
{
|
||||
await using WebApplication app = await StartAsync();
|
||||
string token = await CreateKeyAsync(app, [GatewayScopes.EventsRead, GatewayScopes.InvokeRead]);
|
||||
using HttpClient client = CreateClient(app);
|
||||
|
||||
using HttpResponseMessage response = await SendWithBearerAsync(client, token);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dev-only <c>Dashboard:DisableLogin</c> registers an always-succeeding auto-login handler under
|
||||
/// the cookie scheme name; /metrics must not fall back to it, or the flag would silently open
|
||||
/// an endpoint the operator explicitly asked to protect.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisableLogin_DoesNotOpenMetrics()
|
||||
{
|
||||
await using WebApplication app = await StartAsync("--MxGateway:Dashboard:DisableLogin=true");
|
||||
using HttpClient client = CreateClient(app);
|
||||
|
||||
using HttpResponseMessage response = await client.GetAsync("/metrics");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<WebApplication> StartAsync(params string[] extraArgs)
|
||||
{
|
||||
// Ephemeral port (:0): xUnit runs collections in parallel, so a fixed port would collide.
|
||||
WebApplication app = GatewayApplication.Build(
|
||||
[
|
||||
"--urls=http://127.0.0.1:0",
|
||||
"--MxGateway:Metrics:RequireAuthentication=true",
|
||||
$"--MxGateway:ApiKeyPepper={TestPepper}",
|
||||
.. extraArgs,
|
||||
]);
|
||||
await app.StartAsync();
|
||||
return app;
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient(WebApplication app) =>
|
||||
new(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false })
|
||||
{
|
||||
BaseAddress = new Uri(app.Urls.First()),
|
||||
};
|
||||
|
||||
private static async Task<HttpResponseMessage> SendWithBearerAsync(HttpClient client, string token)
|
||||
{
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, "/metrics");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}");
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
// Creates a key through the same admin command set the CLI and dashboard use. Key ids are unique
|
||||
// per call because the isolated auth store is shared by every test in the process.
|
||||
private static async Task<string> CreateKeyAsync(WebApplication app, string[] scopes)
|
||||
{
|
||||
ApiKeyAdminCommands commands = app.Services.GetRequiredService<ApiKeyAdminCommands>();
|
||||
await commands.InitDbAsync(remoteAddress: null, CancellationToken.None);
|
||||
CreateKeyResult created = await commands.CreateKeyAsync(
|
||||
$"metrics{Guid.NewGuid():N}",
|
||||
"Metrics test key",
|
||||
new HashSet<string>(scopes, StringComparer.Ordinal),
|
||||
null,
|
||||
null,
|
||||
remoteAddress: null,
|
||||
CancellationToken.None);
|
||||
return created.Token ?? throw new InvalidOperationException("Key creation returned no token.");
|
||||
}
|
||||
|
||||
// Protects a ticket with the running host's own cookie options (data-protection keys, ticket
|
||||
// format, cookie name) — the cookie a real dashboard login would issue, minus the LDAP bind.
|
||||
private static string MintDashboardCookie(WebApplication app, string role)
|
||||
{
|
||||
CookieAuthenticationOptions cookieOptions = app.Services
|
||||
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
|
||||
.Get(DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(ClaimTypes.Name, "viewer-user"), new Claim(ClaimTypes.Role, role)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
ClaimTypes.Role);
|
||||
AuthenticationTicket ticket = new(
|
||||
new ClaimsPrincipal(identity),
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
return $"{cookieOptions.Cookie.Name}={cookieOptions.TicketDataFormat.Protect(ticket)}";
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
@@ -190,7 +191,8 @@ public sealed class MetricsAuthorizationTests
|
||||
UrlEncoder.Default,
|
||||
verifier,
|
||||
limiter ?? new ApiKeyFailureLimiter(1000, TimeSpan.FromMinutes(1), 1024, 0, TimeSpan.Zero, TimeProvider.System),
|
||||
new AuthenticationSchemeProvider(Options.Create(new AuthenticationOptions())),
|
||||
new AuthenticationSchemeProvider(Options.Create(new Microsoft.AspNetCore.Authentication.AuthenticationOptions())),
|
||||
Options.Create(new GatewayOptions()),
|
||||
new GatewayMetrics());
|
||||
await handler.InitializeAsync(
|
||||
new AuthenticationScheme(
|
||||
|
||||
Reference in New Issue
Block a user