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.
278 lines
12 KiB
C#
278 lines
12 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
|
|
/// <summary>
|
|
/// Default implementation of <see cref="IDashboardSessionAdminService"/>: gates
|
|
/// destructive session actions on the <see cref="DashboardRoles.Admin"/> role,
|
|
/// records each attempt as a canonical <see cref="AuditEvent"/> through <see cref="IAuditWriter"/>
|
|
/// (in addition to the operational <see cref="ILogger"/> line), and converts
|
|
/// <see cref="SessionManagerException"/> (and any other unexpected exceptions) into
|
|
/// <see cref="DashboardSessionAdminResult.Fail(string)"/> so the Blazor pages never see a raw exception.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The constant <c>dashboard-admin-kill</c> is the reason passed to
|
|
/// <see cref="ISessionManager.KillWorkerAsync"/> and forwarded as the
|
|
/// <c>reason</c> tag on the <c>mxgateway.workers.killed</c> counter and in
|
|
/// the worker-kill audit log entries. Destructive dashboard actions write durable,
|
|
/// queryable audit rows (actions <c>dashboard-close-session</c> / <c>dashboard-kill-worker</c>)
|
|
/// to the canonical <c>audit_event</c> store, mirroring the API-key management path so a
|
|
/// worker killed mid-production is not visible only in a rotatable <see cref="ILogger"/> line.
|
|
/// </remarks>
|
|
public sealed class DashboardSessionAdminService(
|
|
ISessionManager sessionManager,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
IAuditWriter auditWriter,
|
|
ILogger<DashboardSessionAdminService>? logger = null) : IDashboardSessionAdminService
|
|
{
|
|
/// <summary>Canonical <see cref="AuditEvent.Category"/> for dashboard session-admin actions.</summary>
|
|
internal const string SessionAdminCategory = "SessionAdmin";
|
|
|
|
/// <summary>Canonical <see cref="AuditEvent.Action"/> for a dashboard session close.</summary>
|
|
internal const string CloseSessionAction = "dashboard-close-session";
|
|
|
|
/// <summary>Canonical <see cref="AuditEvent.Action"/> for a dashboard worker kill.</summary>
|
|
internal const string KillWorkerAction = "dashboard-kill-worker";
|
|
|
|
private const string UnauthorizedMessage = "Sign in as an Admin to close sessions or kill workers.";
|
|
private const string KillReason = "dashboard-admin-kill";
|
|
|
|
private readonly ILogger<DashboardSessionAdminService> _logger =
|
|
logger ?? NullLogger<DashboardSessionAdminService>.Instance;
|
|
|
|
/// <inheritdoc />
|
|
public bool CanManage(ClaimsPrincipal user)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(user);
|
|
|
|
return user.Identity?.IsAuthenticated == true
|
|
&& user.IsInRole(DashboardRoles.Admin);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DashboardSessionAdminResult> CloseSessionAsync(
|
|
ClaimsPrincipal user,
|
|
string sessionId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!CanManage(user))
|
|
{
|
|
await WriteAuditAsync(user, CloseSessionAction, sessionId, AuditOutcome.Denied, "unauthorized", cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return DashboardSessionAdminResult.Fail(UnauthorizedMessage);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(sessionId))
|
|
{
|
|
return DashboardSessionAdminResult.Fail("Session id is required.");
|
|
}
|
|
|
|
string actor = ResolveActor(user);
|
|
try
|
|
{
|
|
SessionCloseResult result = await sessionManager
|
|
.CloseSessionAsync(sessionId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
_logger.LogInformation(
|
|
"Dashboard admin {Actor} closed session {SessionId} from {RemoteAddress}; alreadyClosed={AlreadyClosed}.",
|
|
actor,
|
|
sessionId,
|
|
ResolveRemoteAddress(),
|
|
result.AlreadyClosed);
|
|
|
|
await WriteAuditAsync(
|
|
user,
|
|
CloseSessionAction,
|
|
sessionId,
|
|
AuditOutcome.Success,
|
|
result.AlreadyClosed ? "alreadyClosed" : "closed",
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return DashboardSessionAdminResult.Success(
|
|
result.AlreadyClosed
|
|
? $"Session {sessionId} was already closed."
|
|
: $"Session {sessionId} closed.");
|
|
}
|
|
catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound)
|
|
{
|
|
await WriteAuditAsync(user, CloseSessionAction, sessionId, AuditOutcome.Failure, "not-found", cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found.");
|
|
}
|
|
catch (SessionManagerException exception)
|
|
{
|
|
_logger.LogWarning(
|
|
exception,
|
|
"Dashboard admin {Actor} close failed for session {SessionId}.",
|
|
actor,
|
|
sessionId);
|
|
await WriteAuditAsync(user, CloseSessionAction, sessionId, AuditOutcome.Failure, exception.Message, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return DashboardSessionAdminResult.Fail(
|
|
$"Close failed: {exception.Message}");
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_logger.LogWarning(
|
|
exception,
|
|
"Dashboard admin {Actor} close failed unexpectedly for session {SessionId}.",
|
|
actor,
|
|
sessionId);
|
|
await WriteAuditAsync(user, CloseSessionAction, sessionId, AuditOutcome.Failure, "unexpected", cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return DashboardSessionAdminResult.Fail(
|
|
$"Close failed unexpectedly for session {sessionId}. See the gateway log for details.");
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DashboardSessionAdminResult> KillWorkerAsync(
|
|
ClaimsPrincipal user,
|
|
string sessionId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!CanManage(user))
|
|
{
|
|
await WriteAuditAsync(user, KillWorkerAction, sessionId, AuditOutcome.Denied, "unauthorized", cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return DashboardSessionAdminResult.Fail(UnauthorizedMessage);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(sessionId))
|
|
{
|
|
return DashboardSessionAdminResult.Fail("Session id is required.");
|
|
}
|
|
|
|
string actor = ResolveActor(user);
|
|
try
|
|
{
|
|
SessionCloseResult result = await sessionManager
|
|
.KillWorkerAsync(sessionId, KillReason, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
_logger.LogInformation(
|
|
"Dashboard admin {Actor} killed worker for session {SessionId} from {RemoteAddress}; alreadyClosed={AlreadyClosed}.",
|
|
actor,
|
|
sessionId,
|
|
ResolveRemoteAddress(),
|
|
result.AlreadyClosed);
|
|
|
|
await WriteAuditAsync(
|
|
user,
|
|
KillWorkerAction,
|
|
sessionId,
|
|
AuditOutcome.Success,
|
|
result.AlreadyClosed ? "alreadyClosed" : "killed",
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return DashboardSessionAdminResult.Success(
|
|
result.AlreadyClosed
|
|
? $"Session {sessionId} was already closed."
|
|
: $"Worker for session {sessionId} killed.");
|
|
}
|
|
catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound)
|
|
{
|
|
await WriteAuditAsync(user, KillWorkerAction, sessionId, AuditOutcome.Failure, "not-found", cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found.");
|
|
}
|
|
catch (SessionManagerException exception)
|
|
{
|
|
_logger.LogWarning(
|
|
exception,
|
|
"Dashboard admin {Actor} kill failed for session {SessionId}.",
|
|
actor,
|
|
sessionId);
|
|
await WriteAuditAsync(user, KillWorkerAction, sessionId, AuditOutcome.Failure, exception.Message, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return DashboardSessionAdminResult.Fail(
|
|
$"Kill failed: {exception.Message}");
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_logger.LogWarning(
|
|
exception,
|
|
"Dashboard admin {Actor} kill failed unexpectedly for session {SessionId}.",
|
|
actor,
|
|
sessionId);
|
|
await WriteAuditAsync(user, KillWorkerAction, sessionId, AuditOutcome.Failure, "unexpected", cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return DashboardSessionAdminResult.Fail(
|
|
$"Kill failed unexpectedly for session {sessionId}. See the gateway log for details.");
|
|
}
|
|
}
|
|
|
|
private static string ResolveActor(ClaimsPrincipal user)
|
|
{
|
|
return user.Identity?.Name ?? "<unknown>";
|
|
}
|
|
|
|
private string? ResolveRemoteAddress()
|
|
{
|
|
return httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes a canonical <see cref="AuditEvent"/> for a dashboard session-admin action through the
|
|
/// best-effort <see cref="IAuditWriter"/> (failures are swallowed/logged by the writer, so this
|
|
/// never throws and never masks the operation result). <see cref="AuditEvent.Actor"/> is the LDAP
|
|
/// operator, <see cref="AuditEvent.Target"/> the session id, and <paramref name="detail"/> is wrapped
|
|
/// as the <c>detail</c> field of the JSON extension bag.
|
|
/// </summary>
|
|
private async Task WriteAuditAsync(
|
|
ClaimsPrincipal user,
|
|
string action,
|
|
string sessionId,
|
|
AuditOutcome outcome,
|
|
string? detail,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
AuditEvent auditEvent = new()
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
|
Actor = ResolveActor(user),
|
|
Action = action,
|
|
Outcome = outcome,
|
|
Category = SessionAdminCategory,
|
|
Target = sessionId,
|
|
SourceNode = ResolveRemoteAddress(),
|
|
CorrelationId = ParseCorrelationId(),
|
|
DetailsJson = WrapDetail(detail),
|
|
};
|
|
|
|
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Derives a correlation id from the request trace identifier when it is a well-formed GUID;
|
|
/// otherwise null (the default <c>HttpContext.TraceIdentifier</c> is the connection:request form,
|
|
/// not a GUID, so it correlates to null rather than fabricating one).
|
|
/// </summary>
|
|
private Guid? ParseCorrelationId() =>
|
|
Guid.TryParse(httpContextAccessor.HttpContext?.TraceIdentifier, out Guid correlationId)
|
|
? correlationId
|
|
: null;
|
|
|
|
private static string? WrapDetail(string? detail) =>
|
|
detail is null
|
|
? null
|
|
: JsonSerializer.Serialize(new Dictionary<string, string> { ["detail"] = detail });
|
|
}
|