using System.Security.Claims; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.MxGateway.Server.Sessions; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// /// Default implementation of : gates /// destructive session actions on the role, /// audit-logs successful operations, 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. /// public sealed class DashboardSessionAdminService( ISessionManager sessionManager, IHttpContextAccessor httpContextAccessor, ILogger? logger = null) : IDashboardSessionAdminService { 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)) { 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); return DashboardSessionAdminResult.Success( result.AlreadyClosed ? $"Session {sessionId} was already closed." : $"Session {sessionId} closed."); } catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound) { return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found."); } catch (SessionManagerException exception) { _logger.LogWarning( exception, "Dashboard admin {Actor} close failed for session {SessionId}.", actor, sessionId); return DashboardSessionAdminResult.Fail( $"Close failed: {exception.Message}"); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception exception) { // Server-050: any non-SessionManagerException (e.g. an IOException or // InvalidOperationException from the session DisposeAsync / pipe teardown // path) used to propagate raw into Blazor's error boundary. Convert it to // a friendly failure so the Razor pages see only DashboardSessionAdminResult. _logger.LogWarning( exception, "Dashboard admin {Actor} close failed unexpectedly for session {SessionId}.", actor, sessionId); 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)) { 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); return DashboardSessionAdminResult.Success( result.AlreadyClosed ? $"Session {sessionId} was already closed." : $"Worker for session {sessionId} killed."); } catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound) { return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found."); } catch (SessionManagerException exception) { _logger.LogWarning( exception, "Dashboard admin {Actor} kill failed for session {SessionId}.", actor, sessionId); return DashboardSessionAdminResult.Fail( $"Kill failed: {exception.Message}"); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception exception) { // Server-050: any non-SessionManagerException (e.g. an IOException from // worker pipe teardown surfacing through session.DisposeAsync, or an // InvalidOperationException from a corrupted worker handle) used to // propagate raw into Blazor's error boundary. Convert it to a friendly // failure so the page renders the ResultMessage rather than the circuit // error page. _logger.LogWarning( exception, "Dashboard admin {Actor} kill failed unexpectedly for session {SessionId}.", actor, sessionId); 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(); } }