mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
fe9ffc3f67
Revoke enforcement (L4): a revoked delegated (MSP) user kept receiving pushes on already-joined SignalR groups until the connection dropped (join-time-only authorization). Negotiate now goes through the Management SDK and binds the client token to a SignalR user id (lowercased UPN); new DisconnectUserAsync strips the user from all groups server-side and pushes "accessRevoked", on which the web client restarts its connection so every re-join re-runs authorization against the fresh scope. Wired into delegated revoke/disable and tenant-group unassign / remove-tenant / delete-group. Residual (documented): a tampered client ignoring the push, and connections negotiated before this deploy (no user id), keep streams until the connection drops. Revoke audit integrity (L5): POST /global/delegated-admins now requires a GUID tenantId (mirrors the tenant-group path), and DELETE reports 404 for an assignment that never existed instead of returning 200 and writing a false customer-visible "access removed" audit row (repo delete now checks the 404 response of the idempotent DeleteEntityAsync). Casing (L6): the SignalR group join/leave cross-tenant check now compares tenant IDs OrdinalIgnoreCase, aligned with the middleware and the delegated scope dictionary, so a casing difference can never flip which branch authorizes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
352 lines
16 KiB
C#
352 lines
16 KiB
C#
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
using Microsoft.Azure.SignalR.Management;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Services
|
|
{
|
|
/// <summary>
|
|
/// Programmatic SignalR message sender for background tasks that run outside
|
|
/// of Azure Function output bindings (e.g., async rule engine, vulnerability correlation).
|
|
/// Uses the Azure SignalR Management SDK to send messages to connected clients.
|
|
/// </summary>
|
|
public class SignalRNotificationService : ISignalRNotificationService, IDisposable
|
|
{
|
|
private readonly ILogger<SignalRNotificationService> _logger;
|
|
private readonly ServiceManager _serviceManager;
|
|
private ServiceHubContext? _hubContext;
|
|
private static readonly SemaphoreSlim _initLock = new(1, 1);
|
|
|
|
private const string HubName = "autopilotmonitor";
|
|
|
|
/// <summary>
|
|
/// Hard ceiling for every imperative send (hub-context resolution + SendCoreAsync).
|
|
/// Without it a slow/unreachable SignalR endpoint blocks for the SDK's HttpClient
|
|
/// default of 100s, pinning the calling thread (queue worker or Task.Run) for the
|
|
/// full duration. These pushes are observability nudges, never correctness — fail fast.
|
|
/// </summary>
|
|
private static readonly TimeSpan SendTimeout = TimeSpan.FromSeconds(5);
|
|
|
|
/// <summary>
|
|
/// Ceiling for client negotiation. More generous than SendTimeout: the first negotiate on a
|
|
/// cold instance also pays the one-time hub-context initialization, and a negotiate failure
|
|
/// is user-visible (the client cannot connect at all), so err toward completing it.
|
|
/// </summary>
|
|
private static readonly TimeSpan NegotiateTimeout = TimeSpan.FromSeconds(10);
|
|
|
|
public SignalRNotificationService(IConfiguration configuration, ILogger<SignalRNotificationService> logger)
|
|
{
|
|
_logger = logger;
|
|
var connectionString = configuration["AzureSignalRConnectionString"]
|
|
?? throw new InvalidOperationException("AzureSignalRConnectionString is not configured");
|
|
|
|
_serviceManager = new ServiceManagerBuilder()
|
|
.WithOptions(o =>
|
|
{
|
|
o.ConnectionString = connectionString;
|
|
o.ServiceTransportType = ServiceTransportType.Persistent;
|
|
})
|
|
.BuildServiceManager();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Notify a session's connected clients that new rule analysis results are available.
|
|
/// Sends to the session-specific SignalR group so only the relevant session detail page reacts.
|
|
/// </summary>
|
|
public async Task NotifyRuleResultsAvailableAsync(string tenantId, string sessionId, int resultCount)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(SendTimeout);
|
|
var hub = await GetHubContextAsync(cts.Token);
|
|
var groupName = $"session-{tenantId}-{sessionId}";
|
|
|
|
await hub.Clients.Group(groupName).SendCoreAsync("ruleResultsReady", new object[]
|
|
{
|
|
new
|
|
{
|
|
sessionId,
|
|
tenantId,
|
|
ruleResultCount = resultCount,
|
|
timestamp = DateTime.UtcNow.ToString("o")
|
|
}
|
|
}, cts.Token);
|
|
|
|
_logger.LogDebug("Sent ruleResultsReady to group {Group} ({Count} results)", groupName, resultCount);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to send ruleResultsReady notification for session {SessionId}", sessionId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Notify a session's connected clients that vulnerability correlation results are available.
|
|
/// </summary>
|
|
public async Task NotifyVulnerabilityReportAvailableAsync(string tenantId, string sessionId, string overallRisk)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(SendTimeout);
|
|
var hub = await GetHubContextAsync(cts.Token);
|
|
var groupName = $"session-{tenantId}-{sessionId}";
|
|
|
|
await hub.Clients.Group(groupName).SendCoreAsync("vulnerabilityReportReady", new object[]
|
|
{
|
|
new
|
|
{
|
|
sessionId,
|
|
tenantId,
|
|
overallRisk,
|
|
timestamp = DateTime.UtcNow.ToString("o")
|
|
}
|
|
}, cts.Token);
|
|
|
|
_logger.LogDebug("Sent vulnerabilityReportReady to group {Group} (risk={Risk})", groupName, overallRisk);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to send vulnerabilityReportReady notification for session {SessionId}", sessionId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Notify the session group that the cascade-deletion worker has tombstoned the session
|
|
/// (plan §5 PR4). Receivers (web UI) remove the row and close any open detail pane.
|
|
/// Failures are logged and swallowed — the SignalR push is observability, not correctness;
|
|
/// the audit log entry <c>deletion_completed</c> remains the canonical record.
|
|
/// </summary>
|
|
public async Task NotifySessionDeletedAsync(string tenantId, string sessionId)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(SendTimeout);
|
|
var hub = await GetHubContextAsync(cts.Token);
|
|
var groupName = $"session-{tenantId}-{sessionId}";
|
|
|
|
await hub.Clients.Group(groupName).SendCoreAsync("sessionDeleted", new object[]
|
|
{
|
|
new
|
|
{
|
|
sessionId,
|
|
tenantId,
|
|
timestamp = DateTime.UtcNow.ToString("o")
|
|
}
|
|
}, cts.Token);
|
|
|
|
_logger.LogDebug("Sent sessionDeleted to group {Group}", groupName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to send sessionDeleted notification for session {SessionId}", sessionId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Push a newly created tenant notification to the appropriate audience-tier group.
|
|
/// Member-tier notifications go to the -notify-member group (joined by all tenant members);
|
|
/// Admin-tier notifications go to the -notify-admin group (joined by Tenant Admins + Global Admins only).
|
|
/// </summary>
|
|
public virtual async Task SendTenantNotificationAsync(string tenantId, NotificationAudience audience, object dto)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(SendTimeout);
|
|
var hub = await GetHubContextAsync(cts.Token);
|
|
var groupName = audience == NotificationAudience.Admin
|
|
? SignalRGroupHelper.TenantNotifyAdminGroup(tenantId)
|
|
: SignalRGroupHelper.TenantNotifyMemberGroup(tenantId);
|
|
|
|
await hub.Clients.Group(groupName).SendCoreAsync("tenantNotification", new[] { dto }, cts.Token);
|
|
_logger.LogDebug("Sent tenantNotification to group {Group}", groupName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to send tenantNotification for tenant {TenantId}", tenantId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Push a tenant notification dismissal to all audience-tier groups for the tenant.
|
|
/// We do not know the original audience at dismiss time, so we broadcast to both groups —
|
|
/// receivers filter by id and remove if present (idempotent).
|
|
/// </summary>
|
|
public virtual async Task SendTenantNotificationDismissedAsync(string tenantId, string notificationId)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(SendTimeout);
|
|
var hub = await GetHubContextAsync(cts.Token);
|
|
var memberGroup = SignalRGroupHelper.TenantNotifyMemberGroup(tenantId);
|
|
var adminGroup = SignalRGroupHelper.TenantNotifyAdminGroup(tenantId);
|
|
var payload = new { id = notificationId };
|
|
|
|
await Task.WhenAll(
|
|
hub.Clients.Group(memberGroup).SendCoreAsync("tenantNotificationDismissed", new object[] { payload }, cts.Token),
|
|
hub.Clients.Group(adminGroup).SendCoreAsync("tenantNotificationDismissed", new object[] { payload }, cts.Token)
|
|
);
|
|
_logger.LogDebug("Sent tenantNotificationDismissed for tenant {TenantId} id {Id}", tenantId, notificationId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to send tenantNotificationDismissed for tenant {TenantId} id {Id}", tenantId, notificationId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Push a "dismiss all" event to all audience-tier groups for the tenant. Receivers clear local state.
|
|
/// </summary>
|
|
public virtual async Task SendTenantNotificationDismissedAllAsync(string tenantId)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(SendTimeout);
|
|
var hub = await GetHubContextAsync(cts.Token);
|
|
var memberGroup = SignalRGroupHelper.TenantNotifyMemberGroup(tenantId);
|
|
var adminGroup = SignalRGroupHelper.TenantNotifyAdminGroup(tenantId);
|
|
|
|
await Task.WhenAll(
|
|
hub.Clients.Group(memberGroup).SendCoreAsync("tenantNotificationsDismissedAll", Array.Empty<object>(), cts.Token),
|
|
hub.Clients.Group(adminGroup).SendCoreAsync("tenantNotificationsDismissedAll", Array.Empty<object>(), cts.Token)
|
|
);
|
|
_logger.LogDebug("Sent tenantNotificationsDismissedAll for tenant {TenantId}", tenantId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to send tenantNotificationsDismissedAll for tenant {TenantId}", tenantId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Push a newly created global notification to the global-admins group.
|
|
/// </summary>
|
|
public virtual async Task SendGlobalNotificationAsync(object dto)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(SendTimeout);
|
|
var hub = await GetHubContextAsync(cts.Token);
|
|
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotification", new[] { dto }, cts.Token);
|
|
_logger.LogDebug("Sent globalNotification to group {Group}", SignalRGroupHelper.GlobalAdminsGroup);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to send globalNotification");
|
|
}
|
|
}
|
|
|
|
public virtual async Task SendGlobalNotificationDismissedAsync(string notificationId)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(SendTimeout);
|
|
var hub = await GetHubContextAsync(cts.Token);
|
|
var payload = new { id = notificationId };
|
|
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotificationDismissed", new object[] { payload }, cts.Token);
|
|
_logger.LogDebug("Sent globalNotificationDismissed for id {Id}", notificationId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to send globalNotificationDismissed for id {Id}", notificationId);
|
|
}
|
|
}
|
|
|
|
public virtual async Task SendGlobalNotificationDismissedAllAsync()
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(SendTimeout);
|
|
var hub = await GetHubContextAsync(cts.Token);
|
|
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotificationsDismissedAll", Array.Empty<object>(), cts.Token);
|
|
_logger.LogDebug("Sent globalNotificationsDismissedAll");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to send globalNotificationsDismissedAll");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Negotiates a client connection with the access token bound to <paramref name="userId"/>
|
|
/// (lowercased UPN) — see <see cref="ISignalRNotificationService.NegotiateClientAsync"/>.
|
|
/// Token generation is local (no service round-trip beyond hub-context init).
|
|
/// </summary>
|
|
public virtual async Task<(string Url, string AccessToken)?> NegotiateClientAsync(string userId)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(NegotiateTimeout);
|
|
var hub = await GetHubContextAsync(cts.Token);
|
|
var negotiation = await hub.NegotiateAsync(new NegotiationOptions { UserId = userId }, cts.Token);
|
|
if (string.IsNullOrEmpty(negotiation?.Url) || string.IsNullOrEmpty(negotiation.AccessToken))
|
|
{
|
|
_logger.LogError("SignalR negotiation returned an empty url/token for user {UserId}", userId);
|
|
return null;
|
|
}
|
|
return (negotiation.Url, negotiation.AccessToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "SignalR negotiation failed for user {UserId}", userId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cuts the user's live streams after a revoke (see
|
|
/// <see cref="ISignalRNotificationService.DisconnectUserAsync"/>). Two prongs, because the
|
|
/// Management SDK (1.33) exposes no user-level connection close: (1) strip the user from all
|
|
/// groups server-side, and (2) push "accessRevoked" to the user's connections — the web
|
|
/// client restarts its connection on receipt, and every re-join then re-runs authorization
|
|
/// against the fresh (≤30s-cached) scope. Residual: a tampered client that ignores (2) and
|
|
/// whose connection-scoped memberships survive (1) keeps its streams until the connection
|
|
/// drops. Warning (not Debug) on failure so it reaches App Insights.
|
|
/// </summary>
|
|
public virtual async Task DisconnectUserAsync(string userId)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(SendTimeout);
|
|
var hub = await GetHubContextAsync(cts.Token);
|
|
await hub.UserGroups.RemoveFromAllGroupsAsync(userId, cts.Token);
|
|
await hub.Clients.User(userId).SendCoreAsync("accessRevoked", new object[]
|
|
{
|
|
new { timestamp = DateTime.UtcNow.ToString("o") }
|
|
}, cts.Token);
|
|
_logger.LogInformation("Revoked SignalR streams of user {UserId} (group strip + accessRevoked push)", userId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"Failed to cut SignalR streams of user {UserId} after revoke — already-joined connections keep streaming until they drop",
|
|
userId);
|
|
}
|
|
}
|
|
|
|
private async Task<ServiceHubContext> GetHubContextAsync(CancellationToken ct = default)
|
|
{
|
|
if (_hubContext != null) return _hubContext;
|
|
|
|
await _initLock.WaitAsync(ct);
|
|
try
|
|
{
|
|
_hubContext ??= await _serviceManager.CreateHubContextAsync(HubName, ct);
|
|
return _hubContext;
|
|
}
|
|
finally
|
|
{
|
|
_initLock.Release();
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
(_hubContext as IDisposable)?.Dispose();
|
|
_serviceManager?.Dispose();
|
|
}
|
|
}
|
|
}
|