From 8dd45b20fba179f1709a580972fca2e9ba13229b Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 26 Jun 2026 18:06:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(delegated):=20scoped-global=20reader=20?= =?UTF-8?q?=E2=80=94=20dashboard=20+=20analytics=20+=20fleet=20overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make a delegated ("MSP") admin a "scoped Global Reader": the same pages a Global Admin sees, but every tenant switcher/filter is bounded to the managed allow-list. Reader path only (readWrite deferred). MCP stays read-only (Phase 6 already covers delegated reads). Backend (bounded aggregate — reuses the GA per-tenant fan-out): - global/sessions + global/stats/sessions: GlobalReadOrAdmin -> GlobalReadOrDelegatedSubset (keep QueryParam). A delegated caller WITHOUT ?tenantId= is admitted as a bounded aggregate; the handler restricts the cross-tenant fan-out to RequestContext.AllowedTenantIds. With ?tenantId=X it drills into one managed tenant (middleware validates X in scope). - TableStorageService: GetAllSessions{,Page,Stats}Async gain an optional allowedTenantIds set; FetchAllSessionsPageInternalAsync swaps its Step-1 tenant source to that subset (everything downstream — per-tenant query, inverted-tick merge, cursor — is reused). - IsDelegatedReadTier += GlobalReadOrDelegatedSubset so the single-tenant drill rescue fires. - SignalR AddToGroup/RemoveFromGroup: admit a delegated caller to a managed tenant-{id} group (in scope); global-admins stays platform-scope-only. Web: - useAggregatedAdminScope / useGlobalAdminScope: cross-tenant mode is now on for a delegated admin too (isGlobalAdmin = crossTenant; new isDelegatedScope). Tenant list bounded to the managed subset; always a concrete tenant (no "All tenants" aggregate on analytics pages). - TenantScopeSelector hides the aggregate option for delegated; GlobalAdminBanner gains a blue "Delegated Admin View" variant (wired on all analytics pages + dashboard). - Nav: Fleet group exposes Dashboard + Fleet Health + Software + Geographic + SLA + Usage; showDashboard now includes delegated. - Dashboard delegated-ready: crossTenant drives the /global endpoints + filter UI; empty filter = bounded aggregate over managed tenants, a selected tenant = drill-in; global-admins SignalR join stays real-GA-only; read-only (mutations already GA-gated). `?tenant=` deep-link seeds the filter. - Fleet is now a pure stats card grid; the per-tenant session browser /fleet/[tenantId] is removed and cards link to /dashboard?tenant=. Deferred follow-ups: Audit Log + Geographic session drill-in for delegated (both diverge from the scope-hook pattern / need tenantId threading). Tests: backend 2484 green (catalog/middleware contract updated for the new subset tier), web 481 green, MCP 292 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PlatformMetricsServiceWindowTests.cs | 16 +- .../PolicyEnforcementMiddlewareTests.cs | 35 ++- .../TableStorage/TableSessionRepository.cs | 12 +- .../SignalRAddToGroupFunction.cs | 29 ++- .../SignalRRemoveFromGroupFunction.cs | 20 +- .../Sessions/GetAllSessionStatsFunction.cs | 13 +- .../Sessions/GetAllSessionsFunction.cs | 10 +- .../Middleware/PolicyEnforcementMiddleware.cs | 7 +- .../Security/EndpointAccessPolicyCatalog.cs | 8 +- .../Services/TableStorageService.Sessions.cs | 25 +- .../DataAccess/ISessionRepository.cs | 15 +- .../app/analyze-rules/page.tsx | 2 +- .../app/apps/[appName]/page.tsx | 2 +- .../autopilot-monitor-web/app/apps/page.tsx | 2 +- .../dashboard/hooks/useDashboardSessions.ts | 30 ++- .../app/dashboard/page.tsx | 66 +++-- .../app/fleet-health/page.tsx | 2 +- .../[tenantId]/hooks/useTenantSessions.ts | 134 ---------- .../app/fleet/[tenantId]/page.tsx | 233 ------------------ .../autopilot-monitor-web/app/fleet/page.tsx | 6 +- .../app/gather-rules/page.tsx | 2 +- .../app/geographic-performance/page.tsx | 1 + .../autopilot-monitor-web/app/sla/page.tsx | 2 +- .../app/usage-metrics/page.tsx | 2 +- .../components/GlobalAdminBanner.tsx | 20 +- .../components/GlobalSidebar.tsx | 9 +- .../components/TenantScopeSelector.tsx | 4 +- .../hooks/useAggregatedAdminScope.ts | 97 +++++--- .../hooks/useGlobalAdminScope.ts | 70 ++++-- .../lib/globalNavConfig.tsx | 11 +- 30 files changed, 358 insertions(+), 527 deletions(-) delete mode 100644 src/Web/autopilot-monitor-web/app/fleet/[tenantId]/hooks/useTenantSessions.ts delete mode 100644 src/Web/autopilot-monitor-web/app/fleet/[tenantId]/page.tsx diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PlatformMetricsServiceWindowTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PlatformMetricsServiceWindowTests.cs index 491f66cd..47fd689e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PlatformMetricsServiceWindowTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PlatformMetricsServiceWindowTests.cs @@ -20,7 +20,7 @@ public class PlatformMetricsServiceWindowTests { var sessionRepo = new Mock(); sessionRepo - .Setup(r => r.GetAllSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(r => r.GetAllSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>())) .ReturnsAsync(EmptyPage()); sessionRepo .Setup(r => r.GetSessionEventsAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -41,8 +41,8 @@ public class PlatformMetricsServiceWindowTests int? capturedDays = null; repo - .Setup(r => r.GetAllSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, d, _, _) => capturedDays = d) + .Setup(r => r.GetAllSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>())) + .Callback?>((_, d, _, _, _) => capturedDays = d) .ReturnsAsync(EmptyPage()); var result = await service.ComputePlatformMetricsAsync(days, limit: 50); @@ -66,8 +66,8 @@ public class PlatformMetricsServiceWindowTests int? capturedPageSize = null; repo - .Setup(r => r.GetAllSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, _, ps, _) => capturedPageSize = ps) + .Setup(r => r.GetAllSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>())) + .Callback?>((_, _, ps, _, _) => capturedPageSize = ps) .ReturnsAsync(EmptyPage()); var result = await service.ComputePlatformMetricsAsync(days: days, limit: limit); @@ -83,8 +83,8 @@ public class PlatformMetricsServiceWindowTests int? capturedDays = null; repo - .Setup(r => r.GetAllSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, d, _, _) => capturedDays = d) + .Setup(r => r.GetAllSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>())) + .Callback?>((_, d, _, _, _) => capturedDays = d) .ReturnsAsync(EmptyPage()); var result = await service.ComputePlatformMetricsAsync(0); @@ -117,7 +117,7 @@ public class PlatformMetricsServiceWindowTests var (service, repo) = CreateService(); int callCount = 0; repo - .Setup(r => r.GetAllSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(r => r.GetAllSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>())) .Callback(() => callCount++) .ReturnsAsync(EmptyPage()); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs index 99898042..f98b8f4a 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs @@ -427,19 +427,38 @@ public class PolicyEnforcementMiddlewareTests } [Fact] - public async Task Delegated_AggregateGlobalEndpoint_NoTenantId_IsForbidden() + public void GlobalSessions_IsBoundedSubsetTier() { - // The no-tenantId AGGREGATE path of a global endpoint fans out over ALL tenants. Even though - // global/sessions is QueryParam-scoped (Phase 2a), a delegated caller WITHOUT ?tenantId= resolves no - // target ⇒ no delegated grant ⇒ denied. Only the single-tenant (?tenantId=X) path is open to them. + // global/sessions + global/stats/sessions are GlobalReadOrDelegatedSubset (bounded aggregate for + // delegated) yet keep QueryParam scoping (single-tenant drill). config/all is the same tier sans drill. + foreach (var path in new[] { "/api/global/sessions", "/api/global/stats/sessions" }) + { + var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", path); + Assert.NotNull(entry); + Assert.Equal(EndpointPolicy.GlobalReadOrDelegatedSubset, entry!.Policy); + Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping); + } + } + + [Fact] + public async Task Delegated_GlobalSessions_NoTenantId_IsBoundedAggregate() + { + // global/sessions WITHOUT ?tenantId= is the BOUNDED aggregate for a delegated ("MSP") caller: the + // subset tier admits them and publishes the managed set on AllowedTenantIds, which the handler uses + // to restrict the cross-tenant fan-out to those tenants. (global/metrics/summary, still on the plain + // GlobalReadOrAdmin tier, stays forbidden without a tenantId — see Delegated_MetricsSummary_* below.) const string upn = "msp@partner.example"; var h = BuildHarness(); h.AsDelegated(TenantB); var result = await h.Middleware.DecideAsync("GET", "/api/global/sessions", null, AuthedPrincipal(TenantA, upn)); - Assert.False(result.Allowed); - Assert.Equal(403, result.StatusCode); + Assert.True(result.Allowed); + var rc = result.Context!; + Assert.True(rc.IsDelegatedReader); + Assert.False(rc.IsGlobalReader); + Assert.NotNull(rc.AllowedTenantIds); + Assert.Contains(TenantB.ToLowerInvariant(), rc.AllowedTenantIds!); } [Fact] @@ -560,8 +579,8 @@ public class PolicyEnforcementMiddlewareTests // reachable by a delegated admin. None == aggregate-only == GA/Reader-only. Pins the leak-critical split. [Theory] - // SAFE — opened to delegated single-tenant access: - [InlineData("GET", "/api/global/sessions", TenantScoping.QueryParam)] + // SAFE — opened to delegated single-tenant access. (global/sessions + global/stats/sessions moved to + // the GlobalReadOrDelegatedSubset tier — see GlobalSessions_IsBoundedSubsetTier below.) [InlineData("GET", "/api/global/audit/logs", TenantScoping.QueryParam)] [InlineData("GET", "/api/global/metrics/app", TenantScoping.QueryParam)] [InlineData("GET", "/api/global/metrics/fleet-health", TenantScoping.QueryParam)] diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs index 8469fe26..aa396d61 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs @@ -39,20 +39,20 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task> GetSessionsAsync(string tenantId, int? days = null) => _storage.GetSessionsAsync(tenantId, days); - public Task> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null) - => _storage.GetAllSessionsAsync(tenantIdFilter, days); + public Task> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null, IReadOnlyCollection? allowedTenantIds = null) + => _storage.GetAllSessionsAsync(tenantIdFilter, days, allowedTenantIds); public Task> GetSessionsPageAsync(string tenantId, int? days, int pageSize, string? continuation) => _storage.GetSessionsPageAsync(tenantId, days, pageSize, continuation); - public Task> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation) - => _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation); + public Task> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation, IReadOnlyCollection? allowedTenantIds = null) + => _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation, allowedTenantIds); public Task GetSessionStatsAsync(string tenantId, int days) => _storage.GetSessionStatsAsync(tenantId, days); - public Task GetAllSessionStatsAsync(string? tenantIdFilter, int days) - => _storage.GetAllSessionStatsAsync(tenantIdFilter, days); + public Task GetAllSessionStatsAsync(string? tenantIdFilter, int days, IReadOnlyCollection? allowedTenantIds = null) + => _storage.GetAllSessionStatsAsync(tenantIdFilter, days, allowedTenantIds); public async Task UpdateSessionStatusAsync( string tenantId, string sessionId, SessionStatus status, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs index 13f6a2d6..ebf8ab67 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs @@ -1,5 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; @@ -11,11 +12,14 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure public class SignalRAddToGroupFunction { private readonly ILogger _logger; + private readonly DelegatedAdminService _delegatedAdminService; public SignalRAddToGroupFunction( - ILogger logger) + ILogger logger, + DelegatedAdminService delegatedAdminService) { _logger = logger; + _delegatedAdminService = delegatedAdminService; } [Function("AddToGroup")] @@ -82,18 +86,33 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure // Check if user is allowed to join this tenant's group. Cross-tenant joins require // platform scope (Global Admin OR read-only Global Reader) — both have cross-tenant - // READ scope, and group membership only RECEIVES live updates. The admin/member - // notification-group checks below still gate by the real tenant role. + // READ scope, and group membership only RECEIVES live updates — OR a delegated ("MSP") + // admin whose managed scope includes the requested tenant (so the delegated dashboard + // gets the same live session/event pushes a GA gets for that tenant). The admin/member + // notification-group checks below still gate by the real tenant role, so a delegated + // caller (no tenant role) receives session/event broadcasts but NOT notification pushes. if (requestedTenantId != userTenantId) { - if (!requestCtx.HasGlobalScope) + var allowedCrossTenant = requestCtx.HasGlobalScope; + if (!allowedCrossTenant && !string.IsNullOrEmpty(userEmail)) + { + // realtime/groups/join is not a tenant-scoped route, so the middleware did not + // resolve the delegated scope onto RequestContext — resolve it here, bounded to + // the requested tenant only. + var scope = await _delegatedAdminService.GetScopeAsync(userEmail); + allowedCrossTenant = scope.RoleFor(requestedTenantId) != null; + if (allowedCrossTenant) + _logger.LogInformation($"Delegated user {userEmail} joining managed cross-tenant group: {request.GroupName}"); + } + + if (!allowedCrossTenant) { _logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to join group for tenant {requestedTenantId}"); var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden); await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: You can only join groups for your own tenant" }); return new AddToGroupOutput { HttpResponse = forbiddenResponse }; } - else + else if (requestCtx.HasGlobalScope) { _logger.LogInformation($"Platform-scope user {userEmail} (role={requestCtx.UserRole}) joining cross-tenant group: {request.GroupName}"); } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs index 6dbfcbd7..23c97934 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs @@ -1,5 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; @@ -11,11 +12,14 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure public class SignalRRemoveFromGroupFunction { private readonly ILogger _logger; + private readonly DelegatedAdminService _delegatedAdminService; public SignalRRemoveFromGroupFunction( - ILogger logger) + ILogger logger, + DelegatedAdminService delegatedAdminService) { _logger = logger; + _delegatedAdminService = delegatedAdminService; } [Function("RemoveFromGroup")] @@ -79,10 +83,18 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure } // Check if user is allowed to leave this tenant's group — platform scope (GA or - // read-only Global Reader) may leave any tenant's group, symmetric with AddToGroup. + // read-only Global Reader) may leave any tenant's group, symmetric with AddToGroup; a + // delegated ("MSP") admin may leave a managed tenant's group (symmetric with the join). if (requestedTenantId != userTenantId) { - if (!requestCtx.HasGlobalScope) + var allowedCrossTenant = requestCtx.HasGlobalScope; + if (!allowedCrossTenant && !string.IsNullOrEmpty(userEmail)) + { + var scope = await _delegatedAdminService.GetScopeAsync(userEmail); + allowedCrossTenant = scope.RoleFor(requestedTenantId) != null; + } + + if (!allowedCrossTenant) { _logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to leave group for tenant {requestedTenantId}"); var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden); @@ -91,7 +103,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure } else { - _logger.LogInformation($"Platform-scope user {userEmail} (role={requestCtx.UserRole}) leaving cross-tenant group: {request.GroupName}"); + _logger.LogInformation($"User {userEmail} (role={requestCtx.UserRole}) leaving cross-tenant group: {request.GroupName}"); } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionStatsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionStatsFunction.cs index 21345dde..b3547f7c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionStatsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionStatsFunction.cs @@ -56,11 +56,16 @@ namespace AutopilotMonitor.Functions.Functions.Sessions tenantIdFilter = tenantIdFilterRaw; } - _logger.LogInformation( - "Computing cross-tenant session stats (caller={Caller}, filter={Filter}, days={Days})", - callerTenantId, tenantIdFilter ?? "none", days); + // Delegated ("MSP") callers carry AllowedTenantIds (subset tier) → the no-filter aggregate is + // bounded to their managed tenants. Null for GA/Reader = all tenants; ignored when a single + // ?tenantId= is named (middleware already validated it is in the caller's scope). + var allowedTenantIds = req.GetRequestContext().AllowedTenantIds; - var stats = await _sessionRepo.GetAllSessionStatsAsync(tenantIdFilter, days); + _logger.LogInformation( + "Computing cross-tenant session stats (caller={Caller}, filter={Filter}, days={Days}, bounded={Bounded})", + callerTenantId, tenantIdFilter ?? "none", days, allowedTenantIds != null); + + var stats = await _sessionRepo.GetAllSessionStatsAsync(tenantIdFilter, days, allowedTenantIds); return await req.OkAsync(new { success = true, stats }); } catch (Exception ex) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs index a5e35302..ac2c75c7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs @@ -31,11 +31,17 @@ namespace AutopilotMonitor.Functions.Functions.Sessions try { - // Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware + // Authentication + authorization enforced by PolicyEnforcementMiddleware (GlobalReadOrDelegatedSubset). var userEmail = TenantHelper.GetUserIdentifier(req); var callerTenantId = TenantHelper.GetTenantId(req); var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); + // Delegated ("MSP") callers are admitted by the subset tier with AllowedTenantIds set; the + // aggregate (no ?tenantId=) is then bounded to that managed subset. Null for GA/Reader = all + // tenants. When a single ?tenantId= IS named, the repo takes the single-tenant path and this + // bound is irrelevant (the middleware already validated the target is in the caller's scope). + var allowedTenantIds = req.GetRequestContext().AllowedTenantIds; + var parsed = SessionListPagination.ParseQuery(query, acceptFilterTenantId: true); if (parsed.Error != null) { @@ -76,7 +82,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions } var page = await _sessionRepo.GetAllSessionsPageAsync( - parsed.FilterTenantId, parsed.Days, parsed.PageSize, azureToken); + parsed.FilterTenantId, parsed.Days, parsed.PageSize, azureToken, allowedTenantIds); string? nextLink = null; if (!string.IsNullOrEmpty(page.NextRawToken)) diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs index ebc7a607..bfcaeab0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs @@ -263,7 +263,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware private static bool IsDelegatedReadTier(EndpointPolicy policy) => policy is EndpointPolicy.MemberRead or EndpointPolicy.TenantAdminOrGlobalReader - or EndpointPolicy.GlobalReadOrAdmin; + or EndpointPolicy.GlobalReadOrAdmin + // Subset-tier routes (global/sessions, global/stats/sessions) admit a delegated caller for the + // bounded AGGREGATE (no tenantId, handler bounds to AllowedTenantIds); the single-tenant ?tenantId= + // DRILL still flows through the scoped-route cross-tenant guard, which requires this tier to count + // as a delegated read tier so RoleFor(target) can rescue the cross-tenant block. + or EndpointPolicy.GlobalReadOrDelegatedSubset; private async Task EvaluateCatalogPolicyAsync( ClaimsPrincipal? principal, EndpointPolicyEntry entry) diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index eb2bdc19..28cdef4f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -343,8 +343,12 @@ public static class EndpointAccessPolicyCatalog new("DELETE", "preview/whitelist/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), new("GET", "preview/notification-email/{tenantId}", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.RouteParam), new("POST", "preview/send-welcome-email/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), - new("GET", "global/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), - new("GET", "global/stats/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + // Subset tier (like config/all): a delegated ("MSP") caller is admitted WITHOUT a named tenantId and + // the handler bounds the aggregate to RequestContext.AllowedTenantIds (its managed subset). With a + // named ?tenantId= the QueryParam path drills into that single tenant (middleware validates it is in + // the caller's scope). GA/Reader keep full cross-tenant access (unbounded aggregate + any drill). + new("GET", "global/sessions", EndpointPolicy.GlobalReadOrDelegatedSubset, TenantScoping.QueryParam), + new("GET", "global/stats/sessions", EndpointPolicy.GlobalReadOrDelegatedSubset, TenantScoping.QueryParam), new("GET", "global/audit/logs", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("GET", "global/presence", EndpointPolicy.GlobalReadOrAdmin), new("GET", "global/tenants-with-deletion-manifests", EndpointPolicy.GlobalReadOrAdmin), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs index e8a795fd..4059aa62 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -897,7 +897,8 @@ namespace AutopilotMonitor.Functions.Services /// optionally restricts to one tenant. /// public async Task> GetAllSessionsPageAsync( - string? tenantIdFilter, int? days, int pageSize, string? continuation) + string? tenantIdFilter, int? days, int pageSize, string? continuation, + IReadOnlyCollection? allowedTenantIds = null) { if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); @@ -908,7 +909,7 @@ namespace AutopilotMonitor.Functions.Services return await GetSessionsPageAsync(tenantIdFilter!, days, pageSize, continuation); } - var page = await FetchAllSessionsPageInternalAsync(maxResults: pageSize, cursor: continuation, days: days); + var page = await FetchAllSessionsPageInternalAsync(maxResults: pageSize, cursor: continuation, days: days, allowedTenantIds: allowedTenantIds); return new RawPage(page.Sessions, page.HasMore ? page.NextCursor : null); } @@ -917,7 +918,7 @@ namespace AutopilotMonitor.Functions.Services /// optionally restricts to a single tenant /// (routed through the per-tenant index for efficiency). /// - public async Task> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null) + public async Task> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null, IReadOnlyCollection? allowedTenantIds = null) { if (!string.IsNullOrEmpty(tenantIdFilter)) { @@ -928,7 +929,7 @@ namespace AutopilotMonitor.Functions.Services string? token = null; do { - var page = await GetAllSessionsPageAsync(tenantIdFilter: null, days, pageSize: 1000, continuation: token); + var page = await GetAllSessionsPageAsync(tenantIdFilter: null, days, pageSize: 1000, continuation: token, allowedTenantIds: allowedTenantIds); all.AddRange(page.Items); token = page.NextRawToken; } while (!string.IsNullOrEmpty(token)); @@ -953,11 +954,11 @@ namespace AutopilotMonitor.Functions.Services /// Cross-tenant variant. Routes through the per-tenant index when /// is set (cheaper scan). /// - public async Task GetAllSessionStatsAsync(string? tenantIdFilter, int days) + public async Task GetAllSessionStatsAsync(string? tenantIdFilter, int days, IReadOnlyCollection? allowedTenantIds = null) { if (days < 1) throw new ArgumentOutOfRangeException(nameof(days)); - var sessions = await GetAllSessionsAsync(tenantIdFilter, days); + var sessions = await GetAllSessionsAsync(tenantIdFilter, days, allowedTenantIds); return AggregateSessionStats(sessions, days); } @@ -1073,7 +1074,7 @@ namespace AutopilotMonitor.Functions.Services /// (single page). /// private async Task<(List Sessions, bool HasMore, string? NextCursor)> FetchAllSessionsPageInternalAsync( - int maxResults, string? cursor, int? days) + int maxResults, string? cursor, int? days, IReadOnlyCollection? allowedTenantIds = null) { try { @@ -1088,6 +1089,16 @@ namespace AutopilotMonitor.Functions.Services tenantIds.Add(entity.PartitionKey); } + // Delegated ("MSP") bound: restrict the cross-tenant fan-out to the caller's managed subset. + // This is the SAME per-tenant loop the Global Admin aggregate uses — only the tenant set + // shrinks, so the merge/cursor/pagination below stay identical. Comparison is case-insensitive + // because AllowedTenantIds is lowercased while the config PartitionKey casing is not guaranteed. + if (allowedTenantIds != null) + { + var allowed = new HashSet(allowedTenantIds, StringComparer.OrdinalIgnoreCase); + tenantIds = tenantIds.Where(t => allowed.Contains(t)).ToList(); + } + if (tenantIds.Count == 0 && string.IsNullOrEmpty(cursor)) { var fallback = await FetchAllSessionsFromPrimaryTableInternalAsync(maxResults, days); diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs index 8177f255..5ef82be8 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs @@ -29,8 +29,11 @@ namespace AutopilotMonitor.Shared.DataAccess /// /// Cross-tenant variant of (Global Admin). /// optionally restricts to a single tenant. + /// (when non-null) bounds the cross-tenant fan-out to that + /// subset — used by delegated ("MSP") callers so the aggregate covers only their managed tenants. /// - Task> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null); + Task> GetAllSessionsAsync( + string? tenantIdFilter = null, int? days = null, IReadOnlyCollection? allowedTenantIds = null); /// /// Reads a single page of sessions for , newest-first @@ -45,9 +48,12 @@ namespace AutopilotMonitor.Shared.DataAccess /// /// Cross-tenant variant of (Global Admin). /// optionally restricts to a single tenant. + /// (when non-null) bounds the cross-tenant fan-out to that + /// subset — used by delegated ("MSP") callers so the aggregate covers only their managed tenants. /// Task> GetAllSessionsPageAsync( - string? tenantIdFilter, int? days, int pageSize, string? continuation); + string? tenantIdFilter, int? days, int pageSize, string? continuation, + IReadOnlyCollection? allowedTenantIds = null); /// /// Server-side aggregation for the dashboard stats cards (per-tenant scope). @@ -60,8 +66,11 @@ namespace AutopilotMonitor.Shared.DataAccess /// Cross-tenant variant of (Global Admin). /// optionally restricts to a single tenant /// (routes through the per-tenant index for cheaper scan). + /// (when non-null) bounds the aggregate to that subset + /// — used by delegated ("MSP") callers so the cards cover only their managed tenants. /// - Task GetAllSessionStatsAsync(string? tenantIdFilter, int days); + Task GetAllSessionStatsAsync( + string? tenantIdFilter, int days, IReadOnlyCollection? allowedTenantIds = null); // --- Session Updates --- Task UpdateSessionStatusAsync( diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx index 918de77f..684427b4 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx @@ -407,7 +407,7 @@ export default function AnalyzeRulesPage() { return (
- + {/* Header */}
diff --git a/src/Web/autopilot-monitor-web/app/apps/[appName]/page.tsx b/src/Web/autopilot-monitor-web/app/apps/[appName]/page.tsx index 9cd3d717..d452ac3e 100644 --- a/src/Web/autopilot-monitor-web/app/apps/[appName]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/[appName]/page.tsx @@ -361,7 +361,7 @@ export default function AppDetailPage() { return (
- +