From 0b80f09a74c279efe67ce3f5dd2d400e04452d44 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 8 May 2026 18:32:22 +0200 Subject: [PATCH] refactor(notifications): bell on pure SignalR push, kill 60s polling The two notification-bell React contexts polled GET /api/notifications and GET /api/global/notifications every 60s regardless of activity, generating 3017+618 = 3635 calls/24h with near-zero hit rate (notifications are emitted sparsely). SignalR was already used app-wide for live session updates; the bell was the lone hold-out on a pull model. Switch to the same push model. Steady-state traffic drops ~86% and the bell updates sub-second instead of within 60s. Architecture (Option C+): - Mount -> 1x HTTP fetch (cold-start hydration via existing GET endpoint) - SignalR onreconnected -> re-fetch (covers the disconnect window) - SignalR push -> live append/dismiss - No periodic polling, no setInterval Audience-tier security: TenantNotificationAudienceCatalog still drives visibility (Member vs Admin), but enforcement moves into SignalR group membership: tenant-{id}-notify-member is joined by every tenant member, tenant-{id}-notify-admin only by Tenant Admins / Global Admins. The AddToGroup function fail-closes with 403 on Member-tier joins to the admin group, so Admin-tier notifications cannot leak via push. Backend - ISignalRNotificationService extracted; SignalRNotificationService implements it. Resolves the long-standing test-mocking blocker (the real service's constructor validates AzureSignalRConnectionString and BuildServiceManager(); previously you could not unit-test consumers). Tenant + Global NotificationService consume the interface; the three legacy callers (Analyze, Ingest, VulnCorrelate) still bind to the concrete class -- migrate per test need. - Six new push methods: SendTenantNotification{,Dismissed,DismissedAll} and SendGlobalNotification{,Dismissed,DismissedAll}. Tenant variant routes to the audience-correct group. - Tenant + Global service push after a successful repo write (fire-and-forget; never throws into the caller). - SignalRGroupHelper: parsing handles the two new -notify-{member,admin} suffixes; new IsTenantNotifyAdminGroup predicate; group-name builders. - SignalRAddToGroupFunction: explicit 403 when a non-admin attempts to join the admin-tier group. Frontend - TenantNotificationContext: setInterval gone. useSignalR() to join member-group always, admin-group iff isTenantAdmin || isGlobalAdmin. Listens for tenantNotification (append + dedupe), tenantNotificationDismissed (filter), tenantNotificationsDismissedAll (clear). onreconnected triggers re-fetch. - GlobalNotificationContext: same pattern against global-admins group. - app/layout.tsx: SignalRProvider hoisted above the notification providers so useSignalR() resolves inside them. Tests - SignalRGroupHelperTests (8): parsing for all four group formats incl. new -notify suffixes, IsTenantNotifyAdminGroup predicate. - TenantNotificationServiceSignalRPushTests (8): audience-correct push on Create (Admin / Member / Unknown -> Admin fail-closed), no-push on repo failure, no-push on DismissAll(count=0), no-push on empty tenantId. - FakeSignalRNotificationService: in-memory spy implementing the new interface; replaces the unmockable real service in tests. - AuthFunctionSideEffectTests + TenantNotificationServiceTests: constructor signatures updated for the new SignalR dep. Suites Backend xUnit: 1161/1161 (was 1146, +15 net). Web vitest: 259/259 (unchanged; existing notification-API + gate tests still cover). Solution build: 0 warnings, 0 errors. Expected post-deploy reduction (24h): GET GetTenantNotifications: 3017 -> ~50 GET GetGlobalNotifications: 618 -> ~10 POST AddToGroup: ~246 -> ~350-450 (two joins per tab) Net bell load: -86%, sub-second live UX. Disconnect tradeoff (accepted): if SignalR drops without reconnect (rare -- withAutomaticReconnect retries 1/2/4/8/16/30s ... forever), the bell shows the snapshot from the last fetch but no live updates until next reload. The initial-3-retry give-up path of the SignalR client still gets the user a hydrated bell because the HTTP fetch runs independently on mount. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../AuthFunctionSideEffectTests.cs | 2 +- .../FakeSignalRNotificationService.cs | 71 ++++++++++ .../SignalRGroupHelperTests.cs | 67 ++++++++++ ...nantNotificationServiceSignalRPushTests.cs | 122 ++++++++++++++++++ .../TenantNotificationServiceTests.cs | 2 +- .../SignalRAddToGroupFunction.cs | 12 ++ .../Helpers/SignalRGroupHelper.cs | 40 +++++- .../AutopilotMonitor.Functions/Program.cs | 1 + .../Services/GlobalNotificationService.cs | 28 +++- .../Services/ISignalRNotificationService.cs | 24 ++++ .../Services/SignalRNotificationService.cs | 121 ++++++++++++++++- .../Services/TenantNotificationService.cs | 30 ++++- src/Web/autopilot-monitor-web/app/layout.tsx | 20 +-- .../contexts/GlobalNotificationContext.tsx | 59 +++++++-- .../contexts/TenantNotificationContext.tsx | 68 ++++++++-- 17 files changed, 624 insertions(+), 47 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/FakeSignalRNotificationService.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/SignalRGroupHelperTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TenantNotificationServiceSignalRPushTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/ISignalRNotificationService.cs diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 51e227a6..8026d9d6 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -644 +647 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 5e9ad0bf..c6523368 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1279 +1282 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs index bda6579b..542168ea 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs @@ -55,7 +55,7 @@ public class AuthFunctionSideEffectTests { CallBase = false }; _globalNotificationMock = new Mock( - notificationRepo, Mock.Of>()) + notificationRepo, new FakeSignalRNotificationService(), Mock.Of>()) { CallBase = false }; var adminConfigService = new Mock( diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/FakeSignalRNotificationService.cs b/src/Backend/AutopilotMonitor.Functions.Tests/FakeSignalRNotificationService.cs new file mode 100644 index 00000000..bc3be033 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/FakeSignalRNotificationService.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Services; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// In-memory ISignalRNotificationService for unit tests. Records every call so tests can +/// assert that producer code (TenantNotificationService, GlobalNotificationService, …) emits +/// the right SignalR push when it should — without instantiating the real Azure SignalR +/// ServiceManager (whose constructor validates a connection string and is not test-friendly). +/// +public class FakeSignalRNotificationService : ISignalRNotificationService +{ + public List<(string TenantId, NotificationAudience Audience, object Dto)> TenantSends { get; } = new(); + public List<(string TenantId, string NotificationId)> TenantDismisses { get; } = new(); + public List TenantDismissAlls { get; } = new(); + public List GlobalSends { get; } = new(); + public List GlobalDismisses { get; } = new(); + public int GlobalDismissAllCount { get; private set; } + public List<(string TenantId, string SessionId, int Count)> RuleResultsCalls { get; } = new(); + public List<(string TenantId, string SessionId, string Risk)> VulnReportCalls { get; } = new(); + + public Task NotifyRuleResultsAvailableAsync(string tenantId, string sessionId, int resultCount) + { + RuleResultsCalls.Add((tenantId, sessionId, resultCount)); + return Task.CompletedTask; + } + + public Task NotifyVulnerabilityReportAvailableAsync(string tenantId, string sessionId, string overallRisk) + { + VulnReportCalls.Add((tenantId, sessionId, overallRisk)); + return Task.CompletedTask; + } + + public Task SendTenantNotificationAsync(string tenantId, NotificationAudience audience, object dto) + { + TenantSends.Add((tenantId, audience, dto)); + return Task.CompletedTask; + } + + public Task SendTenantNotificationDismissedAsync(string tenantId, string notificationId) + { + TenantDismisses.Add((tenantId, notificationId)); + return Task.CompletedTask; + } + + public Task SendTenantNotificationDismissedAllAsync(string tenantId) + { + TenantDismissAlls.Add(tenantId); + return Task.CompletedTask; + } + + public Task SendGlobalNotificationAsync(object dto) + { + GlobalSends.Add(dto); + return Task.CompletedTask; + } + + public Task SendGlobalNotificationDismissedAsync(string notificationId) + { + GlobalDismisses.Add(notificationId); + return Task.CompletedTask; + } + + public Task SendGlobalNotificationDismissedAllAsync() + { + GlobalDismissAllCount++; + return Task.CompletedTask; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SignalRGroupHelperTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SignalRGroupHelperTests.cs new file mode 100644 index 00000000..9aed4bab --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SignalRGroupHelperTests.cs @@ -0,0 +1,67 @@ +using AutopilotMonitor.Functions.Helpers; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for . Locks in the parsing rules for the four +/// supported group formats so a future refactor cannot accidentally break tenant +/// isolation in , +/// which uses for cross-tenant validation. +/// +public class SignalRGroupHelperTests +{ + private const string TenantId = "11111111-2222-3333-4444-555555555555"; + private const string SessionId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + + [Fact] + public void ExtractTenantId_TenantWideGroup() + { + Assert.Equal(TenantId, SignalRGroupHelper.ExtractTenantIdFromGroupName($"tenant-{TenantId}")); + } + + [Fact] + public void ExtractTenantId_TenantNotifyMemberGroup() + { + Assert.Equal(TenantId, SignalRGroupHelper.ExtractTenantIdFromGroupName($"tenant-{TenantId}-notify-member")); + } + + [Fact] + public void ExtractTenantId_TenantNotifyAdminGroup() + { + Assert.Equal(TenantId, SignalRGroupHelper.ExtractTenantIdFromGroupName($"tenant-{TenantId}-notify-admin")); + } + + [Fact] + public void ExtractTenantId_SessionGroup() + { + Assert.Equal(TenantId, SignalRGroupHelper.ExtractTenantIdFromGroupName($"session-{TenantId}-{SessionId}")); + } + + [Fact] + public void ExtractTenantId_GlobalAdmins_ReturnsNull() + { + Assert.Null(SignalRGroupHelper.ExtractTenantIdFromGroupName("global-admins")); + } + + [Fact] + public void ExtractTenantId_UnknownFormat_ReturnsNull() + { + Assert.Null(SignalRGroupHelper.ExtractTenantIdFromGroupName("foo-bar")); + } + + [Fact] + public void IsTenantNotifyAdminGroup_OnlyTrueForAdminSuffix() + { + Assert.True(SignalRGroupHelper.IsTenantNotifyAdminGroup($"tenant-{TenantId}-notify-admin")); + Assert.False(SignalRGroupHelper.IsTenantNotifyAdminGroup($"tenant-{TenantId}-notify-member")); + Assert.False(SignalRGroupHelper.IsTenantNotifyAdminGroup($"tenant-{TenantId}")); + Assert.False(SignalRGroupHelper.IsTenantNotifyAdminGroup("global-admins")); + } + + [Fact] + public void GroupNameBuilders_RoundTrip() + { + Assert.Equal($"tenant-{TenantId}-notify-member", SignalRGroupHelper.TenantNotifyMemberGroup(TenantId)); + Assert.Equal($"tenant-{TenantId}-notify-admin", SignalRGroupHelper.TenantNotifyAdminGroup(TenantId)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantNotificationServiceSignalRPushTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantNotificationServiceSignalRPushTests.cs new file mode 100644 index 00000000..3e757f60 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantNotificationServiceSignalRPushTests.cs @@ -0,0 +1,122 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Locks in that emits the right SignalR push for each +/// lifecycle event — Create / Dismiss / DismissAll — and routes to the audience-correct group. +/// +/// Why this matters: the web bell now relies entirely on SignalR for live updates (no polling). +/// If the service silently drops a push on a code path, the UI will show stale state until +/// the user reloads. That regression would be invisible without these tests. +/// +public class TenantNotificationServiceSignalRPushTests +{ + private const string TenantId = "test-tenant-aaaa-bbbb-cccc-dddddddddddd"; + + private static (TenantNotificationService Service, FakeSignalRNotificationService Signalr, Mock Repo) Build() + { + var repo = new Mock(); + var signalr = new FakeSignalRNotificationService(); + var service = new TenantNotificationService(repo.Object, signalr, NullLogger.Instance); + return (service, signalr, repo); + } + + [Fact] + public async Task Create_AdminTierType_PushesToAdminAudience() + { + var (svc, signalr, _) = Build(); + + await svc.CreateNotificationAsync(TenantId, "hardware_rejection", "Title", "Message"); + + var send = Assert.Single(signalr.TenantSends); + Assert.Equal(TenantId, send.TenantId); + Assert.Equal(NotificationAudience.Admin, send.Audience); + } + + [Fact] + public async Task Create_MemberTierType_PushesToMemberAudience() + { + var (svc, signalr, _) = Build(); + + await svc.CreateNotificationAsync(TenantId, "sla_breach", "Title", "Message"); + + var send = Assert.Single(signalr.TenantSends); + Assert.Equal(NotificationAudience.Member, send.Audience); + } + + [Fact] + public async Task Create_UnknownType_DefaultsToAdminAudience_FailClosed() + { + var (svc, signalr, _) = Build(); + + await svc.CreateNotificationAsync(TenantId, "brand_new_unregistered_type", "Title", "Message"); + + // TenantNotificationAudienceCatalog.Resolve fails closed (Admin-only) for unknown types + Assert.Equal(NotificationAudience.Admin, signalr.TenantSends.Single().Audience); + } + + [Fact] + public async Task Create_EmptyTenantId_NoPushNoRepoWrite() + { + var (svc, signalr, repo) = Build(); + + await svc.CreateNotificationAsync("", "sla_breach", "Title", "Message"); + + Assert.Empty(signalr.TenantSends); + repo.Verify(r => r.AddNotificationAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dismiss_RepoReturnsTrue_PushesDismissed() + { + var (svc, signalr, repo) = Build(); + repo.Setup(r => r.DismissNotificationAsync(TenantId, "n1", It.IsAny())).ReturnsAsync(true); + + var ok = await svc.DismissNotificationAsync(TenantId, "n1", "user@example.com"); + + Assert.True(ok); + var dismiss = Assert.Single(signalr.TenantDismisses); + Assert.Equal(TenantId, dismiss.TenantId); + Assert.Equal("n1", dismiss.NotificationId); + } + + [Fact] + public async Task Dismiss_RepoReturnsFalse_NoPush() + { + var (svc, signalr, repo) = Build(); + repo.Setup(r => r.DismissNotificationAsync(TenantId, "missing", It.IsAny())).ReturnsAsync(false); + + var ok = await svc.DismissNotificationAsync(TenantId, "missing", "user@example.com"); + + Assert.False(ok); + Assert.Empty(signalr.TenantDismisses); + } + + [Fact] + public async Task DismissAll_RepoReturnsCount_PushesOnceWithTenantId() + { + var (svc, signalr, repo) = Build(); + repo.Setup(r => r.DismissAllNotificationsAsync(TenantId)).ReturnsAsync(7); + + var count = await svc.DismissAllNotificationsAsync(TenantId); + + Assert.Equal(7, count); + Assert.Equal(new[] { TenantId }, signalr.TenantDismissAlls); + } + + [Fact] + public async Task DismissAll_RepoReturnsZero_NoPush() + { + var (svc, signalr, repo) = Build(); + repo.Setup(r => r.DismissAllNotificationsAsync(TenantId)).ReturnsAsync(0); + + await svc.DismissAllNotificationsAsync(TenantId); + + Assert.Empty(signalr.TenantDismissAlls); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantNotificationServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantNotificationServiceTests.cs index 4e7eff7b..833a9d6f 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantNotificationServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantNotificationServiceTests.cs @@ -23,7 +23,7 @@ public class TenantNotificationServiceTests var repo = new Mock(); repo.Setup(r => r.GetNotificationsAsync(TenantId, It.IsAny())) .ReturnsAsync(notifications.ToList()); - return new TenantNotificationService(repo.Object, NullLogger.Instance); + return new TenantNotificationService(repo.Object, new FakeSignalRNotificationService(), NullLogger.Instance); } private static GlobalNotification Notif(string type, string id) => new() diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs index 29f932d4..56e5648b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs @@ -93,6 +93,18 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure _logger.LogInformation($"Global Admin {userEmail} joining cross-tenant group: {request.GroupName}"); } } + + // Admin-tier notification group: only Tenant Admins or Global Admins may join. + // The Member-tier group has no extra check beyond the tenant-membership above. + if (SignalRGroupHelper.IsTenantNotifyAdminGroup(request.GroupName) + && !requestCtx.IsTenantAdmin + && !requestCtx.IsGlobalAdmin) + { + _logger.LogWarning($"User {userEmail} (role={requestCtx.UserRole}) attempted to join Admin-tier notification group: {request.GroupName}"); + var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden); + await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: Only Tenant Admins can join the admin notification group" }); + return new AddToGroupOutput { HttpResponse = forbiddenResponse }; + } } // Extract session ID from group name if it's a session-specific group diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/SignalRGroupHelper.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/SignalRGroupHelper.cs index 5debed7d..65878683 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/SignalRGroupHelper.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/SignalRGroupHelper.cs @@ -2,21 +2,24 @@ namespace AutopilotMonitor.Functions.Helpers; /// /// Helper class for parsing SignalR group names. -/// Group formats: "tenant-{tenantId}", "session-{tenantId}-{sessionId}", or "global-admins" +/// Group formats: +/// - "tenant-{tenantId}" — tenant-wide live updates (sessions, etc.) +/// - "tenant-{tenantId}-notify-member" — tenant notification bell (Member-tier visibility) +/// - "tenant-{tenantId}-notify-admin" — tenant notification bell (Admin-tier visibility) +/// - "session-{tenantId}-{sessionId}" — single-session live updates +/// - "global-admins" — global notification bell /// public static class SignalRGroupHelper { + public const string TenantNotifyMemberSuffix = "-notify-member"; + public const string TenantNotifyAdminSuffix = "-notify-admin"; + /// /// Extracts tenant ID from SignalR group name. /// public static string? ExtractTenantIdFromGroupName(string groupName) { - if (groupName.StartsWith("tenant-")) - { - // Format: "tenant-{tenantId}" - return groupName.Substring("tenant-".Length); - } - else if (groupName.StartsWith("session-")) + if (groupName.StartsWith("session-")) { // Format: "session-{tenantId}-{sessionId}" // Extract everything between "session-" and the last 5 GUID segments @@ -26,10 +29,33 @@ public static class SignalRGroupHelper // Reconstruct tenant GUID from parts 1-5 return string.Join("-", parts.Skip(1).Take(5)); } + return null; } + + if (groupName.StartsWith("tenant-")) + { + // Strip the leading "tenant-" prefix, then strip the optional "-notify-{member,admin}" suffix. + var withoutPrefix = groupName.Substring("tenant-".Length); + if (withoutPrefix.EndsWith(TenantNotifyAdminSuffix)) + return withoutPrefix.Substring(0, withoutPrefix.Length - TenantNotifyAdminSuffix.Length); + if (withoutPrefix.EndsWith(TenantNotifyMemberSuffix)) + return withoutPrefix.Substring(0, withoutPrefix.Length - TenantNotifyMemberSuffix.Length); + return withoutPrefix; + } + return null; } + /// + /// True for the Admin-tier tenant notification group. Joining requires Tenant-Admin or Global-Admin. + /// + public static bool IsTenantNotifyAdminGroup(string groupName) + => groupName.StartsWith("tenant-") && groupName.EndsWith(TenantNotifyAdminSuffix); + + public static string TenantNotifyMemberGroup(string tenantId) => $"tenant-{tenantId}{TenantNotifyMemberSuffix}"; + public static string TenantNotifyAdminGroup(string tenantId) => $"tenant-{tenantId}{TenantNotifyAdminSuffix}"; + public const string GlobalAdminsGroup = "global-admins"; + public static string ExtractLogPrefix(string groupName) { // Extract session ID from group name: "session-{tenantId}-{sessionId}" diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 8ebe4e77..3cafa02f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -217,6 +217,7 @@ builder.Services.AddHostedService< // Programmatic SignalR push for background tasks (rule engine, vulnerability correlation) builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); // Vulnerability correlation services builder.Services.AddHttpClient() diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs index 7e461050..5e8d8332 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs @@ -46,13 +46,16 @@ public class GlobalNotificationDto public class GlobalNotificationService { private readonly INotificationRepository _notificationRepo; + private readonly ISignalRNotificationService _signalr; private readonly ILogger _logger; public GlobalNotificationService( INotificationRepository notificationRepo, + ISignalRNotificationService signalr, ILogger logger) { _notificationRepo = notificationRepo; + _signalr = signalr; _logger = logger; } @@ -79,6 +82,17 @@ public class GlobalNotificationService await _notificationRepo.AddNotificationAsync(notification); _logger.LogInformation("Global notification created: {NotificationId} ({Type})", notificationId, type); + + var dto = new GlobalNotificationDto + { + Id = notificationId, + Type = type, + Title = title, + Message = message, + Href = href, + CreatedAt = notification.CreatedAt + }; + await _signalr.SendGlobalNotificationAsync(dto); } catch (Exception ex) { @@ -120,7 +134,12 @@ public class GlobalNotificationService { try { - return await _notificationRepo.DismissNotificationAsync(notificationId, string.Empty); + var dismissed = await _notificationRepo.DismissNotificationAsync(notificationId, string.Empty); + if (dismissed) + { + await _signalr.SendGlobalNotificationDismissedAsync(notificationId); + } + return dismissed; } catch (Exception ex) { @@ -136,7 +155,12 @@ public class GlobalNotificationService { try { - return await _notificationRepo.DismissAllNotificationsAsync(); + var count = await _notificationRepo.DismissAllNotificationsAsync(); + if (count > 0) + { + await _signalr.SendGlobalNotificationDismissedAllAsync(); + } + return count; } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/ISignalRNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/ISignalRNotificationService.cs new file mode 100644 index 00000000..8050daba --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/ISignalRNotificationService.cs @@ -0,0 +1,24 @@ +using System.Threading.Tasks; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Programmatic SignalR message sender. Extracted as an interface so that consumers + /// (TenantNotificationService, GlobalNotificationService, IngestEventsFunction, etc.) + /// can be unit-tested without instantiating the real Azure SignalR ServiceManager, + /// whose constructor validates the connection string. + /// + public interface ISignalRNotificationService + { + Task NotifyRuleResultsAvailableAsync(string tenantId, string sessionId, int resultCount); + Task NotifyVulnerabilityReportAvailableAsync(string tenantId, string sessionId, string overallRisk); + + Task SendTenantNotificationAsync(string tenantId, NotificationAudience audience, object dto); + Task SendTenantNotificationDismissedAsync(string tenantId, string notificationId); + Task SendTenantNotificationDismissedAllAsync(string tenantId); + + Task SendGlobalNotificationAsync(object dto); + Task SendGlobalNotificationDismissedAsync(string notificationId); + Task SendGlobalNotificationDismissedAllAsync(); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SignalRNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SignalRNotificationService.cs index 5485ac0c..0846c256 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SignalRNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SignalRNotificationService.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using AutopilotMonitor.Functions.Helpers; using Microsoft.Azure.SignalR.Management; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -11,7 +12,7 @@ namespace AutopilotMonitor.Functions.Services /// of Azure Function output bindings (e.g., async rule engine, vulnerability correlation). /// Uses the Azure SignalR Management SDK to send messages to connected clients. /// - public class SignalRNotificationService : IDisposable + public class SignalRNotificationService : ISignalRNotificationService, IDisposable { private readonly ILogger _logger; private readonly ServiceManager _serviceManager; @@ -94,6 +95,124 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// 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). + /// + public virtual async Task SendTenantNotificationAsync(string tenantId, NotificationAudience audience, object dto) + { + try + { + var hub = await GetHubContextAsync(); + var groupName = audience == NotificationAudience.Admin + ? SignalRGroupHelper.TenantNotifyAdminGroup(tenantId) + : SignalRGroupHelper.TenantNotifyMemberGroup(tenantId); + + await hub.Clients.Group(groupName).SendCoreAsync("tenantNotification", new[] { dto }); + _logger.LogDebug("Sent tenantNotification to group {Group}", groupName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send tenantNotification for tenant {TenantId}", tenantId); + } + } + + /// + /// 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). + /// + public virtual async Task SendTenantNotificationDismissedAsync(string tenantId, string notificationId) + { + try + { + var hub = await GetHubContextAsync(); + 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 }), + hub.Clients.Group(adminGroup).SendCoreAsync("tenantNotificationDismissed", new object[] { payload }) + ); + _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); + } + } + + /// + /// Push a "dismiss all" event to all audience-tier groups for the tenant. Receivers clear local state. + /// + public virtual async Task SendTenantNotificationDismissedAllAsync(string tenantId) + { + try + { + var hub = await GetHubContextAsync(); + var memberGroup = SignalRGroupHelper.TenantNotifyMemberGroup(tenantId); + var adminGroup = SignalRGroupHelper.TenantNotifyAdminGroup(tenantId); + + await Task.WhenAll( + hub.Clients.Group(memberGroup).SendCoreAsync("tenantNotificationsDismissedAll", Array.Empty()), + hub.Clients.Group(adminGroup).SendCoreAsync("tenantNotificationsDismissedAll", Array.Empty()) + ); + _logger.LogDebug("Sent tenantNotificationsDismissedAll for tenant {TenantId}", tenantId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send tenantNotificationsDismissedAll for tenant {TenantId}", tenantId); + } + } + + /// + /// Push a newly created global notification to the global-admins group. + /// + public virtual async Task SendGlobalNotificationAsync(object dto) + { + try + { + var hub = await GetHubContextAsync(); + await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotification", new[] { dto }); + _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 + { + var hub = await GetHubContextAsync(); + var payload = new { id = notificationId }; + await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotificationDismissed", new object[] { payload }); + _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 + { + var hub = await GetHubContextAsync(); + await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotificationsDismissedAll", Array.Empty()); + _logger.LogDebug("Sent globalNotificationsDismissedAll"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send globalNotificationsDismissedAll"); + } + } + private async Task GetHubContextAsync() { if (_hubContext != null) return _hubContext; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantNotificationService.cs index 3066e4dc..b3f2f766 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantNotificationService.cs @@ -12,13 +12,16 @@ namespace AutopilotMonitor.Functions.Services; public class TenantNotificationService { private readonly ITenantNotificationRepository _repo; + private readonly ISignalRNotificationService _signalr; private readonly ILogger _logger; public TenantNotificationService( ITenantNotificationRepository repo, + ISignalRNotificationService signalr, ILogger logger) { _repo = repo; + _signalr = signalr; _logger = logger; } @@ -49,6 +52,19 @@ public class TenantNotificationService await _repo.AddNotificationAsync(tenantId, notification); _logger.LogInformation("Tenant notification created: tenant={TenantId} id={NotificationId} type={Type}", tenantId, notificationId, type); + + // Push to the audience-tier SignalR group so connected clients update live without polling. + var audience = TenantNotificationAudienceCatalog.Resolve(type); + var dto = new GlobalNotificationDto + { + Id = notificationId, + Type = type, + Title = title, + Message = message, + Href = href, + CreatedAt = notification.CreatedAt + }; + await _signalr.SendTenantNotificationAsync(tenantId, audience, dto); } catch (Exception ex) { @@ -90,7 +106,12 @@ public class TenantNotificationService { try { - return await _repo.DismissNotificationAsync(tenantId, notificationId, dismissedBy); + var dismissed = await _repo.DismissNotificationAsync(tenantId, notificationId, dismissedBy); + if (dismissed) + { + await _signalr.SendTenantNotificationDismissedAsync(tenantId, notificationId); + } + return dismissed; } catch (Exception ex) { @@ -104,7 +125,12 @@ public class TenantNotificationService { try { - return await _repo.DismissAllNotificationsAsync(tenantId); + var count = await _repo.DismissAllNotificationsAsync(tenantId); + if (count > 0) + { + await _signalr.SendTenantNotificationDismissedAllAsync(tenantId); + } + return count; } catch (Exception ex) { diff --git a/src/Web/autopilot-monitor-web/app/layout.tsx b/src/Web/autopilot-monitor-web/app/layout.tsx index 71402bc5..678180bd 100644 --- a/src/Web/autopilot-monitor-web/app/layout.tsx +++ b/src/Web/autopilot-monitor-web/app/layout.tsx @@ -118,11 +118,11 @@ export default function RootLayout({ /> - - - - - + + + + + @@ -132,11 +132,11 @@ export default function RootLayout({ - - - - - + + + + + diff --git a/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx b/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx index 1eb7cacc..22a7f73d 100644 --- a/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; import { useAuth } from './AuthContext'; +import { useSignalR } from './SignalRContext'; import { api } from '@/lib/api'; import { authenticatedFetch } from '@/lib/authenticatedFetch'; @@ -24,14 +25,12 @@ interface GlobalNotificationContextType { const GlobalNotificationContext = createContext(undefined); -const POLL_INTERVAL_MS = 60_000; - export function GlobalNotificationProvider({ children }: { children: React.ReactNode }) { const { user, getAccessToken } = useAuth(); + const { connection, isConnected, joinGroup, leaveGroup } = useSignalR(); const [notifications, setNotifications] = useState([]); const [isLoading, setIsLoading] = useState(false); const fetchingRef = useRef(false); - const intervalRef = useRef | null>(null); const isGlobal = user?.isGlobalAdmin === true; @@ -55,23 +54,23 @@ export function GlobalNotificationProvider({ children }: { children: React.React } }, [isGlobal, getAccessToken]); - // Initial fetch + polling + // Initial state hydration. Re-fetched on SignalR reconnect to recover any deltas + // pushed during the disconnect window. useEffect(() => { if (!isGlobal) { setNotifications([]); return; } - setIsLoading(true); fetchNotifications().finally(() => setIsLoading(false)); - - intervalRef.current = setInterval(fetchNotifications, POLL_INTERVAL_MS); - - return () => { - if (intervalRef.current) clearInterval(intervalRef.current); - }; }, [isGlobal, fetchNotifications]); + useEffect(() => { + if (!connection || !isGlobal) return; + const handler = () => { fetchNotifications(); }; + connection.onreconnected(handler); + }, [connection, isGlobal, fetchNotifications]); + // Re-fetch when globalAdminMode is toggled in localStorage useEffect(() => { const handleStorageChange = () => { @@ -83,8 +82,42 @@ export function GlobalNotificationProvider({ children }: { children: React.React return () => window.removeEventListener('localStorageChange', handleStorageChange); }, [isGlobal, fetchNotifications]); + // Group membership: only Global Admins join the global-admins group. + useEffect(() => { + if (!isConnected || !isGlobal) return; + const group = 'global-admins'; + joinGroup(group); + return () => { leaveGroup(group); }; + }, [isConnected, isGlobal, joinGroup, leaveGroup]); + + // Live-push handlers. + useEffect(() => { + if (!connection) return; + + const onCreate = (notification: GlobalNotification) => { + if (!notification?.id) return; + setNotifications(prev => prev.some(n => n.id === notification.id) ? prev : [notification, ...prev]); + }; + const onDismiss = (payload: { id: string }) => { + if (!payload?.id) return; + setNotifications(prev => prev.filter(n => n.id !== payload.id)); + }; + const onDismissAll = () => { + setNotifications([]); + }; + + connection.on('globalNotification', onCreate); + connection.on('globalNotificationDismissed', onDismiss); + connection.on('globalNotificationsDismissedAll', onDismissAll); + + return () => { + connection.off('globalNotification', onCreate); + connection.off('globalNotificationDismissed', onDismiss); + connection.off('globalNotificationsDismissedAll', onDismissAll); + }; + }, [connection]); + const dismissNotification = useCallback(async (id: string) => { - // Optimistic removal setNotifications(prev => prev.filter(n => n.id !== id)); try { @@ -94,7 +127,7 @@ export function GlobalNotificationProvider({ children }: { children: React.React { method: 'POST' }, ); } catch { - // Best-effort; next poll will reconcile + // Best-effort; backend push will reconcile other tabs if dismiss eventually lands } }, [getAccessToken]); diff --git a/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx b/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx index 204bd0fb..098fba13 100644 --- a/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; import { useAuth } from './AuthContext'; +import { useSignalR } from './SignalRContext'; import { canFetchTenantNotifications } from './tenantNotificationsGate'; import { api } from '@/lib/api'; import { authenticatedFetch } from '@/lib/authenticatedFetch'; @@ -25,20 +26,20 @@ interface TenantNotificationContextType { const TenantNotificationContext = createContext(undefined); -const POLL_INTERVAL_MS = 60_000; - export function TenantNotificationProvider({ children }: { children: React.ReactNode }) { const { user, getAccessToken } = useAuth(); + const { connection, isConnected, joinGroup, leaveGroup } = useSignalR(); const [tenantNotifications, setTenantNotifications] = useState([]); const [isLoading, setIsLoading] = useState(false); const fetchingRef = useRef(false); - const intervalRef = useRef | null>(null); // Only tenant members (Admin/Operator/Viewer) and Global Admins are entitled to the bell. // The backend filters per-notification visibility by audience tier, so it is safe to fetch // for every member role — but unauthenticated users and users without a tenant role would // just produce 401/403 noise, so we skip them entirely. const canFetchNotifications = canFetchTenantNotifications(user); + const tenantId = user?.tenantId ?? null; + const isAdminTier = user?.isTenantAdmin === true || user?.isGlobalAdmin === true; const fetchNotifications = useCallback(async () => { if (!canFetchNotifications || fetchingRef.current) return; @@ -60,21 +61,72 @@ export function TenantNotificationProvider({ children }: { children: React.React } }, [canFetchNotifications, getAccessToken]); + // Initial state hydration. Runs once on mount and again after every SignalR reconnect to + // recover any deltas that were emitted while the SignalR connection was down. useEffect(() => { if (!canFetchNotifications) { setTenantNotifications([]); return; } - setIsLoading(true); fetchNotifications().finally(() => setIsLoading(false)); + }, [canFetchNotifications, fetchNotifications]); - intervalRef.current = setInterval(fetchNotifications, POLL_INTERVAL_MS); + // Re-fetch on SignalR reconnect — covers the deltas pushed during the disconnect window. + useEffect(() => { + if (!connection || !canFetchNotifications) return; + const handler = () => { fetchNotifications(); }; + connection.onreconnected(handler); + // The signalr-js client does not expose an unsubscribe for onreconnected; rely on the + // SignalR connection lifecycle (one connection per app session) for cleanup. + }, [connection, canFetchNotifications, fetchNotifications]); + + // Group membership: Member-tier always; Admin-tier only for Tenant Admins / Global Admins. + useEffect(() => { + if (!isConnected || !canFetchNotifications || !tenantId) return; + + const memberGroup = `tenant-${tenantId}-notify-member`; + const adminGroup = `tenant-${tenantId}-notify-admin`; + + joinGroup(memberGroup); + if (isAdminTier) { + joinGroup(adminGroup); + } return () => { - if (intervalRef.current) clearInterval(intervalRef.current); + leaveGroup(memberGroup); + if (isAdminTier) { + leaveGroup(adminGroup); + } }; - }, [canFetchNotifications, fetchNotifications]); + }, [isConnected, canFetchNotifications, tenantId, isAdminTier, joinGroup, leaveGroup]); + + // Live-push handlers. + useEffect(() => { + if (!connection) return; + + const onCreate = (notification: TenantNotification) => { + if (!notification?.id) return; + setTenantNotifications(prev => prev.some(n => n.id === notification.id) ? prev : [notification, ...prev]); + }; + const onDismiss = (payload: { id: string }) => { + if (!payload?.id) return; + setTenantNotifications(prev => prev.filter(n => n.id !== payload.id)); + }; + const onDismissAll = () => { + setTenantNotifications([]); + }; + + connection.on('tenantNotification', onCreate); + connection.on('tenantNotificationDismissed', onDismiss); + connection.on('tenantNotificationsDismissedAll', onDismissAll); + + return () => { + connection.off('tenantNotification', onCreate); + connection.off('tenantNotificationDismissed', onDismiss); + connection.off('tenantNotificationsDismissedAll', onDismissAll); + }; + }, [connection]); const dismissTenantNotification = useCallback(async (id: string) => { setTenantNotifications(prev => prev.filter(n => n.id !== id)); @@ -86,7 +138,7 @@ export function TenantNotificationProvider({ children }: { children: React.React { method: 'POST' }, ); } catch { - // Best-effort; next poll will reconcile + // Best-effort; the backend push will reconcile other tabs if the dismiss eventually lands } }, [getAccessToken]);