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;
///
/// Default implementation of : gates
/// destructive session actions on the role,
/// records each attempt as a canonical through
/// (in addition to the operational line), and converts
/// (and any other unexpected exceptions) into
/// so the Blazor pages never see a raw exception.
///
///
/// The constant dashboard-admin-kill is the reason passed to
/// and forwarded as the
/// reason tag on the mxgateway.workers.killed counter and in
/// the worker-kill audit log entries. Destructive dashboard actions write durable,
/// queryable audit rows (actions dashboard-close-session / dashboard-kill-worker)
/// to the canonical audit_event store, mirroring the API-key management path so a
/// worker killed mid-production is not visible only in a rotatable line.
///
public sealed class DashboardSessionAdminService(
ISessionManager sessionManager,
IHttpContextAccessor httpContextAccessor,
IAuditWriter auditWriter,
ILogger? logger = null) : IDashboardSessionAdminService
{
/// Canonical for dashboard session-admin actions.
internal const string SessionAdminCategory = "SessionAdmin";
/// Canonical for a dashboard session close.
internal const string CloseSessionAction = "dashboard-close-session";
/// Canonical for a dashboard worker kill.
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 _logger =
logger ?? NullLogger.Instance;
///
public bool CanManage(ClaimsPrincipal user)
{
ArgumentNullException.ThrowIfNull(user);
return user.Identity?.IsAuthenticated == true
&& user.IsInRole(DashboardRoles.Admin);
}
///
public async Task 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.");
}
}
///
public async Task 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 ?? "";
}
private string? ResolveRemoteAddress()
{
return httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
}
///
/// Writes a canonical for a dashboard session-admin action through the
/// best-effort (failures are swallowed/logged by the writer, so this
/// never throws and never masks the operation result). is the LDAP
/// operator, the session id, and is wrapped
/// as the detail field of the JSON extension bag.
///
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);
}
///
/// Derives a correlation id from the request trace identifier when it is a well-formed GUID;
/// otherwise null (the default HttpContext.TraceIdentifier is the connection:request form,
/// not a GUID, so it correlates to null rather than fabricating one).
///
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 { ["detail"] = detail });
}