fix(archreview-p1): SEC-02/12/20 dashboard + observability hardening
- 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.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
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;
|
||||
@@ -8,21 +10,35 @@ 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,
|
||||
/// audit-logs successful operations, 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.
|
||||
/// 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.
|
||||
/// 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";
|
||||
|
||||
@@ -46,6 +62,8 @@ public sealed class DashboardSessionAdminService(
|
||||
{
|
||||
if (!CanManage(user))
|
||||
{
|
||||
await WriteAuditAsync(user, CloseSessionAction, sessionId, AuditOutcome.Denied, "unauthorized", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return DashboardSessionAdminResult.Fail(UnauthorizedMessage);
|
||||
}
|
||||
|
||||
@@ -68,6 +86,15 @@ public sealed class DashboardSessionAdminService(
|
||||
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."
|
||||
@@ -75,6 +102,8 @@ public sealed class DashboardSessionAdminService(
|
||||
}
|
||||
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)
|
||||
@@ -84,6 +113,8 @@ public sealed class DashboardSessionAdminService(
|
||||
"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}");
|
||||
}
|
||||
@@ -98,6 +129,8 @@ public sealed class DashboardSessionAdminService(
|
||||
"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.");
|
||||
}
|
||||
@@ -111,6 +144,8 @@ public sealed class DashboardSessionAdminService(
|
||||
{
|
||||
if (!CanManage(user))
|
||||
{
|
||||
await WriteAuditAsync(user, KillWorkerAction, sessionId, AuditOutcome.Denied, "unauthorized", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return DashboardSessionAdminResult.Fail(UnauthorizedMessage);
|
||||
}
|
||||
|
||||
@@ -133,6 +168,15 @@ public sealed class DashboardSessionAdminService(
|
||||
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."
|
||||
@@ -140,6 +184,8 @@ public sealed class DashboardSessionAdminService(
|
||||
}
|
||||
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)
|
||||
@@ -149,6 +195,8 @@ public sealed class DashboardSessionAdminService(
|
||||
"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}");
|
||||
}
|
||||
@@ -163,6 +211,8 @@ public sealed class DashboardSessionAdminService(
|
||||
"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.");
|
||||
}
|
||||
@@ -177,4 +227,51 @@ public sealed class DashboardSessionAdminService(
|
||||
{
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user