74e815c4d7
- SEC-02: DashboardAuthorizationHandler restricts the loopback and Authentication:Mode=Disabled bypasses to read-only. They now satisfy only a Viewer-bearing requirement (AnyDashboardRole), never AdminOnly, so anonymous localhost can view the dashboard but cannot reach API-key CRUD or session Close/Kill at the policy layer (previously guarded only by service re-checks). - SEC-12: DashboardSessionAdminService emits canonical AuditEvents through IAuditWriter (actions dashboard-close-session / dashboard-kill-worker, category SessionAdmin) on Success/Failure/Denied, mirroring the API-key audit path so destructive session actions leave durable, queryable rows. - SEC-20: drop the unbounded session_id tag from the exported mxgateway.heartbeats.failed counter (per-session detail stays in the snapshot/log). Docs updated same-change: CLAUDE.md (read-only loopback + 5-min bearer), GatewayDashboardDesign.md (bypass scoping + session-admin audit), Metrics.md. Server build clean; 30/30 targeted + 295/295 Dashboard/Security/App/Metrics sweep.
79 lines
3.0 KiB
C#
79 lines
3.0 KiB
C#
using System.Net;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
|
|
/// <summary>
|
|
/// Authorizes a dashboard request by checking either: (a) the LDAP-issued
|
|
/// role claim satisfies <see cref="DashboardAuthorizationRequirement.RequiredRoles"/>,
|
|
/// (b) authentication is fully disabled, or (c) the request is from loopback
|
|
/// and <c>MxGateway:Dashboard:AllowAnonymousLocalhost</c> is on.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The environment bypasses in (b) and (c) grant <em>read-only</em> access only: they
|
|
/// satisfy a requirement that includes <see cref="DashboardRoles.Viewer"/> (i.e.
|
|
/// <see cref="DashboardAuthorizationRequirement.AnyDashboardRole"/>) but never the
|
|
/// <see cref="DashboardAuthorizationRequirement.AdminOnly"/> requirement. Destructive/admin
|
|
/// surfaces still require a real Admin role claim, preserving the documented
|
|
/// "anonymous localhost is read-only" contract at the policy layer rather than by accident
|
|
/// in downstream service re-checks. The loopback test trusts
|
|
/// <c>Connection.RemoteIpAddress</c>; if forwarded-headers middleware is ever added upstream,
|
|
/// <see cref="IsLoopbackRequest"/> must be revisited so a spoofed <c>X-Forwarded-For</c>
|
|
/// cannot masquerade as loopback.
|
|
/// </remarks>
|
|
public sealed class DashboardAuthorizationHandler(
|
|
IHttpContextAccessor httpContextAccessor,
|
|
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
|
|
{
|
|
/// <inheritdoc />
|
|
protected override Task HandleRequirementAsync(
|
|
AuthorizationHandlerContext context,
|
|
DashboardAuthorizationRequirement requirement)
|
|
{
|
|
GatewayOptions gatewayOptions = options.Value;
|
|
|
|
// Environment bypasses are read-only: they satisfy the Viewer requirement
|
|
// (AnyDashboardRole) but never AdminOnly, which lacks Viewer in RequiredRoles.
|
|
bool grantsReadOnly = requirement.RequiredRoles.Contains(DashboardRoles.Viewer);
|
|
|
|
if (grantsReadOnly && gatewayOptions.Authentication.Mode == AuthenticationMode.Disabled)
|
|
{
|
|
context.Succeed(requirement);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
if (grantsReadOnly && gatewayOptions.Dashboard.AllowAnonymousLocalhost && IsLoopbackRequest())
|
|
{
|
|
context.Succeed(requirement);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
if (context.User.Identity?.IsAuthenticated != true)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
foreach (string role in requirement.RequiredRoles)
|
|
{
|
|
if (context.User.IsInRole(role))
|
|
{
|
|
context.Succeed(requirement);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private bool IsLoopbackRequest()
|
|
{
|
|
IPAddress? remoteAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress;
|
|
|
|
return remoteAddress is not null && IPAddress.IsLoopback(remoteAddress);
|
|
}
|
|
}
|