using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; /// /// SignalR hub for per-session MxEvent push. Clients call /// to join the group for a specific /// session; sends messages to /// session:{id} as events arrive from the live gRPC stream. /// /// /// Group membership is mirrored into /// because SignalR does not expose it, and the broadcaster consults the /// registry to skip all mirror work for sessions nobody is watching. /// /// Registry tracking which sessions have live subscribers. /// Per-session visibility gate consulted before any group join. [Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)] public sealed class EventsHub( EventsHubViewerRegistry viewerRegistry, IDashboardSessionAcl sessionAcl) : Hub { /// Method name used to push individual MxEvent values to clients. public const string EventMessage = "MxEvent"; /// Computes the SignalR group name for a given session id. /// The session id. /// The group name for the session. public static string GroupName(string sessionId) => $"session:{sessionId}"; /// /// Subscribes the calling SignalR connection to the per-session events /// group, so that events broadcast by /// for that session reach this /// client. /// /// /// The hub-level (HubClientsPolicy) /// only checks that the caller carries one of the dashboard roles, which by /// itself would let any Viewer subscribe to any session id they name. The /// per-session decision is 's. /// The admin bypass is evaluated first, so an /// Administrator joins any session id they name; every check below it /// applies to non-Admin callers only. For those: a Viewer sees a session /// only when its tags intersect their granted tags, an untagged session /// follows Dashboard:UntaggedSessionVisibility, and a session id the /// registry does not have is denied outright — the phantom-id denial is /// therefore a non-Admin rule, not a universal one. /// A denied caller is not joined to the group and is /// not registered with , so the mirror /// stays off for a session nobody is legitimately watching. The same ACL /// gates the in-process seam used by the session-details page, so neither /// path is the weaker one. /// /// Session id to subscribe the caller to. /// A task representing the subscription operation. /// The caller may not observe this session. public Task SubscribeSession(string sessionId) { if (string.IsNullOrWhiteSpace(sessionId)) { return Task.CompletedTask; } if (!sessionAcl.CanViewSession(Context.User, sessionId)) { // Surfaced rather than swallowed so a client can tell "denied" from "no events yet". // The message names neither the session's tags nor the caller's grant. throw new HubException("Not authorized for this session."); } // Register before joining the group: the reverse order would leave a window // in which this connection is a group member but the broadcaster's gate still // reports the session unwatched, silently dropping events it should receive. viewerRegistry.AddViewer(Context.ConnectionId, sessionId); return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(sessionId)); } /// Unsubscribes the calling SignalR connection from the per-session events group. /// Session id to unsubscribe the caller from. /// A task representing the unsubscription operation. public async Task UnsubscribeSession(string sessionId) { if (string.IsNullOrWhiteSpace(sessionId)) { return; } // Leave the group first, deregister after — the mirror stays enabled for the // brief overlap rather than dropping events still owed to other subscribers. await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(sessionId)).ConfigureAwait(false); viewerRegistry.RemoveViewer(Context.ConnectionId, sessionId); } /// public override Task OnDisconnectedAsync(Exception? exception) { // Release every session subscription the dropped connection held. A browser tab that // closes never calls UnsubscribeSession, so without this the session would look watched // forever and the mirror would keep cloning and sending events to an empty group. viewerRegistry.ReleaseConnection(Context.ConnectionId); return base.OnDisconnectedAsync(exception); } }