f40409a688
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
109 lines
5.2 KiB
C#
109 lines
5.2 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
|
|
|
/// <summary>
|
|
/// SignalR hub for per-session MxEvent push. Clients call
|
|
/// <see cref="SubscribeSession"/> to join the group for a specific
|
|
/// session; <see cref="DashboardEventBroadcaster"/> sends messages to
|
|
/// <c>session:{id}</c> as events arrive from the live gRPC stream.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Group membership is mirrored into <see cref="EventsHubViewerRegistry"/>
|
|
/// because SignalR does not expose it, and the broadcaster consults the
|
|
/// registry to skip all mirror work for sessions nobody is watching.
|
|
/// </remarks>
|
|
/// <param name="viewerRegistry">Registry tracking which sessions have live subscribers.</param>
|
|
/// <param name="sessionAcl">Per-session visibility gate consulted before any group join.</param>
|
|
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
|
|
public sealed class EventsHub(
|
|
EventsHubViewerRegistry viewerRegistry,
|
|
IDashboardSessionAcl sessionAcl) : Hub
|
|
{
|
|
/// <summary>Method name used to push individual <c>MxEvent</c> values to clients.</summary>
|
|
public const string EventMessage = "MxEvent";
|
|
|
|
/// <summary>Computes the SignalR group name for a given session id.</summary>
|
|
/// <param name="sessionId">The session id.</param>
|
|
/// <returns>The group name for the session.</returns>
|
|
public static string GroupName(string sessionId) => $"session:{sessionId}";
|
|
|
|
/// <summary>
|
|
/// Subscribes the calling SignalR connection to the per-session events
|
|
/// group, so that events broadcast by
|
|
/// <see cref="DashboardEventBroadcaster"/> for that session reach this
|
|
/// client.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The hub-level <see cref="AuthorizeAttribute"/> (<c>HubClientsPolicy</c>)
|
|
/// 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 <see cref="IDashboardSessionAcl"/>'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 <c>Dashboard:UntaggedSessionVisibility</c>, 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 <see cref="EventsHubViewerRegistry"/>, 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.
|
|
/// </remarks>
|
|
/// <param name="sessionId">Session id to subscribe the caller to.</param>
|
|
/// <returns>A task representing the subscription operation.</returns>
|
|
/// <exception cref="HubException">The caller may not observe this session.</exception>
|
|
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));
|
|
}
|
|
|
|
/// <summary>Unsubscribes the calling SignalR connection from the per-session events group.</summary>
|
|
/// <param name="sessionId">Session id to unsubscribe the caller from.</param>
|
|
/// <returns>A task representing the unsubscription operation.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
}
|