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 }); }