using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.ManagementService; /// /// SignalR hub for real-time debug stream subscriptions. /// External consumers (CLI) connect via WebSocket, authenticate with Basic Auth, /// and receive streaming attribute value and alarm state changes. /// public class DebugStreamHub : Hub { private const string SessionIdKey = "DebugStreamSessionId"; private const string RolesKey = "DebugStreamRoles"; private const string PermittedSiteIdsKey = "DebugStreamPermittedSiteIds"; private const string DisconnectedKey = "DebugStreamDisconnected"; /// /// Pure site-scope authorization check for a debug-stream subscription. /// Returns true when the caller may subscribe to a debug stream for an instance /// belonging to . /// Administrator role, or an empty (system-wide /// Deployer), grants access to any site; otherwise the instance's site must be /// in the permitted set. /// /// Roles held by the connected user. /// Site ids the user is scoped to; empty means system-wide. /// Site id of the instance being subscribed to. /// if the user may subscribe to the instance; otherwise . public static bool IsInstanceAccessAllowed( IReadOnlyCollection roles, IReadOnlyCollection permittedSiteIds, int instanceSiteId) { if (roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return true; if (permittedSiteIds.Count == 0) return true; // system-wide deployment return permittedSiteIds.Contains(instanceSiteId.ToString()); } private readonly DebugStreamService _debugStreamService; private readonly IHubContext _hubContext; private readonly ILogger _logger; /// /// Initializes the hub with its required services. /// /// Service that manages debug stream subscriptions. /// SignalR hub context for pushing events to clients. /// Logger for this hub. public DebugStreamHub( DebugStreamService debugStreamService, IHubContext hubContext, ILogger logger) { _debugStreamService = debugStreamService; _hubContext = hubContext; _logger = logger; } /// public override async Task OnConnectedAsync() { var httpContext = Context.GetHttpContext(); if (httpContext == null) { _logger.LogWarning("DebugStreamHub connection rejected: no HTTP context"); Context.Abort(); return; } // Authenticate via the SAME shared Basic→LDAP flow as POST /management and the // audit REST (arch-review R2 N1): ManagementAuthenticator applies the DisableLogin // bypass, the LoginThrottle lockout check BEFORE the bind, and RecordFailure/ // RecordSuccess bookkeeping — so hub connection attempts can no longer be used as // an unthrottled LDAP-bind oracle, and hub failures feed the shared lockout. var outcome = await ManagementAuthenticator.AuthenticateAsync(httpContext); if (outcome.User is null) { _logger.LogWarning("DebugStreamHub connection rejected: authentication failed or throttled"); Context.Abort(); return; } var user = outcome.User; // Role check — Deployer role required (unchanged policy). if (!user.Roles.Contains(Roles.Deployer)) { _logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployer role", user.Username); Context.Abort(); return; } // Persist the resolved identity on the connection so per-instance site-scope // enforcement can be applied to SubscribeInstance calls. PermittedSiteIds is the // authenticator's normalized form (empty == system-wide), same as every other surface. Context.Items[RolesKey] = user.Roles; Context.Items[PermittedSiteIdsKey] = user.PermittedSiteIds; _logger.LogInformation("DebugStreamHub connection established for {Username}", user.Username); await base.OnConnectedAsync(); } /// /// Subscribes to a debug stream for the specified instance. /// Sends the initial snapshot immediately, then streams incremental changes. /// /// Database id of the instance to subscribe to. /// A task that represents the asynchronous operation. public async Task SubscribeInstance(int instanceId) { // Stop any existing subscription for this connection await UnsubscribeInstance(); var connectionId = Context.ConnectionId; // Per-instance site-scope enforcement: a site-scoped Deployment user must not // be able to stream an instance belonging to a site outside their scope. var httpContext = Context.GetHttpContext(); if (httpContext == null) { _logger.LogWarning("DebugStreamHub: {ConnectionId} subscribe rejected — no HTTP context", connectionId); await Clients.Caller.SendAsync("OnStreamTerminated", "Authorization context unavailable."); return; } var roles = Context.Items.TryGetValue(RolesKey, out var rolesObj) && rolesObj is string[] r ? r : Array.Empty(); var permittedSiteIds = Context.Items.TryGetValue(PermittedSiteIdsKey, out var sitesObj) && sitesObj is string[] s ? s : Array.Empty(); var instanceRepo = httpContext.RequestServices.GetRequiredService(); var instance = await instanceRepo.GetInstanceByIdAsync(instanceId); if (instance == null) { _logger.LogWarning("DebugStreamHub: {ConnectionId} subscribe rejected — instance {InstanceId} not found", connectionId, instanceId); await Clients.Caller.SendAsync("OnStreamTerminated", $"Instance {instanceId} not found."); return; } if (!IsInstanceAccessAllowed(roles, permittedSiteIds, instance.SiteId)) { _logger.LogWarning( "DebugStreamHub: {ConnectionId} subscribe to instance {InstanceId} denied — site {SiteId} outside permitted scope", connectionId, instanceId, instance.SiteId); await Clients.Caller.SendAsync("OnStreamTerminated", $"Access denied: instance {instanceId} belongs to a site outside your Deployment scope."); return; } try { // Use IHubContext for callbacks — the hub instance is transient (disposed after method returns), // but IHubContext is a singleton that remains valid for the lifetime of the connection. var hubClients = _hubContext.Clients; // Capture the logger into a local: the callbacks outlive this (transient) // hub instance, and ILogger is a singleton, so this is safe to hold. var logger = _logger; var session = await _debugStreamService.StartStreamAsync( instanceId, onEvent: evt => { var send = evt switch { AttributeValueChanged changed => hubClients.Client(connectionId).SendAsync("OnAttributeChanged", changed), AlarmStateChanged changed => hubClients.Client(connectionId).SendAsync("OnAlarmChanged", changed), DebugViewSnapshot snapshot => hubClients.Client(connectionId).SendAsync("OnSnapshot", snapshot), _ => Task.CompletedTask }; // Fire-and-forget (deliberate — a slow/dead client must not block the // stream), but log the push failure rather than swallowing it silently. _ = send.ContinueWith( t => logger.LogWarning(t.Exception?.GetBaseException(), "Debug stream push to connection {ConnectionId} failed", connectionId), CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); }, onTerminated: () => { _ = hubClients.Client(connectionId).SendAsync("OnStreamTerminated", "Site disconnected") .ContinueWith( t => logger.LogWarning(t.Exception?.GetBaseException(), "Debug stream termination push to connection {ConnectionId} failed", connectionId), CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); }); // arch-review 2026-08-19 P6-6: the client may have disconnected while // StartStreamAsync was in flight (up to 30s). OnDisconnectedAsync already ran // and found no session id to release, so publishing it here would leave the // session registered in the singleton DebugStreamService — whose keepalive // renews registered sessions forever, pinning the site subscription and its // bridge actor until process restart. Publish and re-check liveness under the // per-connection Items monitor (the same one UnsubscribeInstance takes) so // exactly one side tears the session down. var abandoned = false; lock (Context.Items) { if (Context.Items.ContainsKey(DisconnectedKey) || Context.ConnectionAborted.IsCancellationRequested) { abandoned = true; } else { Context.Items[SessionIdKey] = session.SessionId; } } if (abandoned) { _debugStreamService.StopStream(session.SessionId); _logger.LogInformation( "DebugStreamHub: {ConnectionId} disconnected while subscribing to instance {InstanceId} — session released", connectionId, instanceId); return; } // Send the initial snapshot await Clients.Caller.SendAsync("OnSnapshot", session.InitialSnapshot); _logger.LogInformation("DebugStreamHub: {ConnectionId} subscribed to instance {InstanceId}", connectionId, instanceId); } catch (Exception ex) { _logger.LogError(ex, "DebugStreamHub: Failed to subscribe {ConnectionId} to instance {InstanceId}", connectionId, instanceId); await Clients.Caller.SendAsync("OnStreamTerminated", ex.Message); } } /// /// Unsubscribes from the current debug stream. /// /// A task that represents the asynchronous operation. public Task UnsubscribeInstance() { var sessionId = TakeSessionId(); if (sessionId is not null) { _debugStreamService.StopStream(sessionId); _logger.LogInformation("DebugStreamHub: {ConnectionId} unsubscribed", Context.ConnectionId); } return Task.CompletedTask; } /// /// Atomically removes and returns the session id registered for this connection, so a /// release path and the late publish in cannot both /// claim (or both miss) the same session — see P6-6 there. /// /// The session id that was registered, or if none was. private string? TakeSessionId() { lock (Context.Items) { if (Context.Items.TryGetValue(SessionIdKey, out var sessionIdObj) && sessionIdObj is string sessionId) { Context.Items.Remove(SessionIdKey); return sessionId; } } return null; } /// public override async Task OnDisconnectedAsync(Exception? exception) { // P6-6: mark the connection gone BEFORE releasing, so a SubscribeInstance still // awaiting StartStreamAsync sees the flag and stops its own session rather than // registering it against a connection nothing will ever unsubscribe. lock (Context.Items) { Context.Items[DisconnectedKey] = true; } await UnsubscribeInstance(); await base.OnDisconnectedAsync(exception); } }