From d44dbe7ec0d682e1dc35030d7e3fa3824c866a96 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 25 Jun 2026 20:16:49 +0200 Subject: [PATCH] feat(backend): delegated single-tenant access to /api/global/* reads (MSP switcher) [Phase 2a] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a delegated ("MSP") admin read its managed tenants through the cross-tenant /api/global/* endpoints — but ONLY on the single-tenant ?tenantId=X path, bounded by the middleware to a tenant in its allowed set. This is what makes the tenant SWITCHER work end-to-end (drill into a managed tenant, see its normal dashboard). The dangerous no-tenantId AGGREGATE path (fan-out over ALL tenants) stays GA/Reader-only and is untouched here; bounded fleet aggregation is a separate step. - Middleware: GlobalReadOrAdmin added to the delegated read tier. The existing `isScopedRoute && crossTenant` guard means a delegated grant still fires ONLY on a tenant-scoped route for an in-scope target — so a delegated caller reaches a global endpoint only via its single-tenant path, never the aggregate path. - Catalog: the 27 GlobalReadOrAdmin routes whose handlers strictly restrict to the named ?tenantId= (verified by an exhaustive per-endpoint audit) recataloged None -> QueryParam. QueryParam/RouteParam on a GlobalReadOrAdmin route now formally means "handler restricts to the named tenant => delegated-reachable"; aggregate-only routes (presence, platform metrics, distress-reports, config/all, …) stay None and remain unreachable by delegated callers. Documented inline. - Tests: delegated single-tenant global read allowed in-scope / denied out-of-scope / denied on the no-tenantId aggregate path; an aggregate-only route stays blocked even with an in-scope ?tenantId=; Global Reader aggregate path regression; and a catalog-contract theory pinning the SAFE(QueryParam) vs AGGREGATE-ONLY(None) split. Updated GlobalAppsRoutes completeness assertion to QueryParam. Full suite green (2461). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../EndpointPolicyCatalogCompletenessTests.cs | 11 ++- .../PolicyEnforcementMiddlewareTests.cs | 97 ++++++++++++++++++- .../Middleware/PolicyEnforcementMiddleware.cs | 17 +++- .../Security/EndpointAccessPolicyCatalog.cs | 63 ++++++------ 4 files changed, 149 insertions(+), 39 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs index 59ca3db2..942f890d 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs @@ -225,10 +225,11 @@ public class EndpointPolicyCatalogCompletenessTests /// /// The three global/apps/* routes must be cross-tenant READS — GlobalReadOrAdmin (GA + read-only - /// GlobalReader) — with TenantScoping.None (the optional ?tenantId= query param is intentional - /// cross-tenant scoping, enforced inside the function — not by the middleware). A future accidental - /// downgrade below the global-scope tier (e.g. to MemberRead, exposing them to tenant members) - /// would be caught here, whereas the generic completeness test only guarantees *some* entry exists. + /// GlobalReader). As of Phase 2a they carry TenantScoping.QueryParam: their handlers strictly restrict + /// to the ?tenantId= named tenant, which is exactly what makes them reachable by a delegated ("MSP") + /// admin bounded to its allowed tenant set (single-tenant path only; the no-tenantId aggregate path + /// stays GA/Reader-only). A future accidental downgrade below the global-scope tier (e.g. to MemberRead, + /// exposing them to tenant members) would be caught here. /// [Theory] [InlineData("GET", "/api/global/apps/list", "global/apps/list")] @@ -241,7 +242,7 @@ public class EndpointPolicyCatalogCompletenessTests Assert.NotNull(entry); Assert.Equal(expectedTemplate, entry!.RouteTemplate); Assert.Equal(EndpointPolicy.GlobalReadOrAdmin, entry.Policy); - Assert.Equal(TenantScoping.None, entry.TenantScoping); + Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping); } /// diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs index 22585f6b..5b02cd7c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using AutopilotMonitor.Functions.Middleware; +using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; @@ -393,10 +394,11 @@ public class PolicyEnforcementMiddlewareTests } [Fact] - public async Task Delegated_DoesNotReachAggregateGlobalEndpoint() + public async Task Delegated_AggregateGlobalEndpoint_NoTenantId_IsForbidden() { - // Cross-tenant AGGREGATE endpoints (GlobalReadOrAdmin) fan out over ALL tenants and are not - // bounded to the delegated subset until Phase 2 — a delegated admin must NOT reach them yet. + // 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. const string upn = "msp@partner.example"; var h = BuildHarness(); h.AsDelegated(TenantB); @@ -424,4 +426,93 @@ public class PolicyEnforcementMiddlewareTests // …but no config row was persisted for the MSP's home tenant A (nor any tenant). h.ConfigRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny()), Times.Never); } + + // ── Phase 2a: delegated single-tenant access to cross-tenant /api/global/* read endpoints ── + + [Fact] + public async Task Delegated_GlobalEndpoint_SingleTenant_InScope_IsAllowed() + { + // global/sessions?tenantId=B — SAFE route (handler restricts to the named tenant), B in scope. + const string upn = "msp@partner.example"; + var h = BuildHarness(); + h.AsDelegated(TenantB); + + var result = await h.Middleware.DecideAsync("GET", "/api/global/sessions", TenantB, AuthedPrincipal(TenantA, upn)); + + Assert.True(result.Allowed); + var rc = result.Context!; + Assert.Equal(TenantB, rc.TargetTenantId); + Assert.True(rc.IsDelegatedReader); + Assert.False(rc.IsGlobalReader); + Assert.Contains(TenantB.ToLowerInvariant(), rc.AllowedTenantIds!); + } + + [Fact] + public async Task Delegated_GlobalEndpoint_SingleTenant_OutOfScope_IsForbidden() + { + // global/sessions?tenantId=C — C is not in the delegated set ⇒ blocked. + const string upn = "msp@partner.example"; + var h = BuildHarness(); + h.AsDelegated(TenantB); + + const string tenantC = "33333333-3333-3333-3333-333333333333"; + var result = await h.Middleware.DecideAsync("GET", "/api/global/sessions", tenantC, AuthedPrincipal(TenantA, upn)); + + Assert.False(result.Allowed); + Assert.Equal(403, result.StatusCode); + } + + [Fact] + public async Task Delegated_AggregateOnlyRoute_WithTenantId_StaysBlocked() + { + // global/presence is AGGREGATE-ONLY: its handler ignores ?tenantId= and returns ALL tenants' + // presence. It MUST stay TenantScoping.None so a delegated caller can never reach it — even if they + // pass a tenantId in their scope. (If this ever flips to QueryParam it's a cross-tenant leak.) + const string upn = "msp@partner.example"; + var h = BuildHarness(); + h.AsDelegated(TenantB); + + var result = await h.Middleware.DecideAsync("GET", "/api/global/presence", TenantB, AuthedPrincipal(TenantA, upn)); + + Assert.False(result.Allowed); + Assert.Equal(403, result.StatusCode); + } + + [Fact] + public async Task GlobalReader_AggregateGlobalEndpoint_StillWorks() + { + // Regression: recataloging SAFE routes to QueryParam must not change full-platform behavior. + // A Global Reader hitting the aggregate path (no tenantId) is still allowed (HasGlobalScope bypass). + const string upn = "reader@contoso.com"; + var h = BuildHarness(); + h.AsGlobalRole(Constants.GlobalRoles.GlobalReader); + + var result = await h.Middleware.DecideAsync("GET", "/api/global/sessions", null, AuthedPrincipal(TenantA, upn)); + + Assert.True(result.Allowed); + } + + // ── Catalog contract: which global routes are delegated-accessible (single-tenant) vs aggregate-only ── + // QueryParam/RouteParam on a GlobalReadOrAdmin route == "handler restricts to the named tenant" == + // 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)] + [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)] + [InlineData("GET", "/api/global/search/sessions", TenantScoping.QueryParam)] + // AGGREGATE-ONLY — MUST stay None (unreachable by delegated): + [InlineData("GET", "/api/global/presence", TenantScoping.None)] + [InlineData("GET", "/api/global/metrics/platform", TenantScoping.None)] + [InlineData("GET", "/api/global/distress-reports", TenantScoping.None)] + [InlineData("GET", "/api/config/all", TenantScoping.None)] + public void GlobalRoute_DelegatedAccessibility_MatchesContract(string method, string path, TenantScoping expected) + { + var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path); + Assert.NotNull(entry); + Assert.Equal(EndpointPolicy.GlobalReadOrAdmin, entry!.Policy); + Assert.Equal(expected, entry.TenantScoping); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs index f174be9f..65b20731 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs @@ -240,12 +240,21 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware /// /// The READ policy tiers a delegated (scoped-global / MSP) assignment may satisfy for a tenant in its - /// scope. Deliberately excludes every write tier (TenantAdminOrGA, BootstrapManagerOrGA, GlobalAdminOnly) - /// and the cross-tenant aggregate tier (GlobalReadOrAdmin — its endpoints fan out over ALL tenants and - /// are only safe for delegated callers once bounded to AllowedTenantIds; that bounding lands in Phase 2). + /// scope. Deliberately excludes every write tier (TenantAdminOrGA, BootstrapManagerOrGA, GlobalAdminOnly). + /// + /// GlobalReadOrAdmin is included (Phase 2a) BUT a delegated grant only ever fires on a tenant-SCOPED + /// route (RouteParam / QueryParam) for a target in the caller's allowed set — see the + /// isScopedRoute && crossTenant guard in . A delegated caller therefore + /// reaches a GlobalReadOrAdmin endpoint ONLY on its single-tenant (?tenantId=X / {tenantId}) path, never + /// the no-tenantId AGGREGATE path (which fans out over ALL tenants). The catalog enforces the other half + /// of this invariant: ONLY GlobalReadOrAdmin routes that strictly restrict to the named tenant carry + /// QueryParam/RouteParam scoping; aggregate-only routes stay TenantScoping.None and are thus unreachable + /// by delegated callers. Bounded cross-tenant aggregation for the fleet view is a separate later step. /// private static bool IsDelegatedReadTier(EndpointPolicy policy) - => policy is EndpointPolicy.MemberRead or EndpointPolicy.TenantAdminOrGlobalReader; + => policy is EndpointPolicy.MemberRead + or EndpointPolicy.TenantAdminOrGlobalReader + or EndpointPolicy.GlobalReadOrAdmin; 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 37a08ca6..5d90958f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -281,6 +281,15 @@ public static class EndpointAccessPolicyCatalog // reads but stay GlobalAdminOnly — they can dump secret-bearing tables and would bypass the // GlobalReader config-secret redaction. // + // SECURITY — delegated ("MSP") accessibility (Phase 2a): a GlobalReadOrAdmin route carries + // TenantScoping.QueryParam (or RouteParam) IFF its handler strictly restricts the response to the + // single tenant named by ?tenantId= (or {tenantId}). That scoping is what lets a delegated admin + // reach the route — bounded by the middleware to a tenant in its allowed set (single-tenant path + // only; the no-tenantId AGGREGATE path stays GA/Reader-only). Routes that cannot restrict to one + // tenant (they fan out over ALL tenants) MUST stay TenantScoping.None so delegated callers can + // never reach them. Before adding QueryParam scoping to a global route, verify the handler honors + // the filter — a wrong scoping here is a cross-tenant data leak. + // // Inspector v1 endpoints (Plan §M6 — lift to MemberRead+QueryParam scoping at the v2 adminMode // lift). Cross-tenant resolution for global-scope callers happens inside the functions via // SessionsIndex, so TenantScoping.None on the catalog is fine. @@ -297,17 +306,17 @@ public static class EndpointAccessPolicyCatalog new("GET", "global/sessions/{sessionId}/deletion-manifest", EndpointPolicy.GlobalReadOrAdmin), new("GET", "global/tenants/{tenantId}/deletion-manifests", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.RouteParam), new("GET", "global/session-deletions", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/raw/sessions", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/raw/events", EndpointPolicy.GlobalReadOrAdmin), + new("GET", "global/raw/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/raw/events", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), // Raw table/log access stays GlobalAdminOnly: query_table can dump arbitrary tables // (incl. TenantConfiguration secrets), which would bypass the GlobalReader config-secret // redaction. A read-only GlobalReader uses the curated read endpoints instead. new("GET", "global/raw/tables", EndpointPolicy.GlobalAdminOnly), new("GET", "global/raw/tables/{tableName}", EndpointPolicy.GlobalAdminOnly), new("POST", "global/raw/logs", EndpointPolicy.GlobalAdminOnly), - new("GET", "global/search/sessions", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/search/sessions-by-event", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/search/sessions-by-cve", EndpointPolicy.GlobalReadOrAdmin), + new("GET", "global/search/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/search/sessions-by-event", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/search/sessions-by-cve", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("GET", "global/metrics/summary", EndpointPolicy.GlobalReadOrAdmin), new("GET", "global/config", EndpointPolicy.GlobalReadOrAdmin), new("PUT", "global/config", EndpointPolicy.GlobalAdminOnly), @@ -321,31 +330,31 @@ 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), - new("GET", "global/stats/sessions", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/audit/logs", EndpointPolicy.GlobalReadOrAdmin), + new("GET", "global/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/stats/sessions", EndpointPolicy.GlobalReadOrAdmin, 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), new("GET", "global/metrics/platform", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/metrics/app", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/metrics/fleet-health", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/apps/list", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/apps/{appName}/analytics", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/apps/{appName}/sessions", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/metrics/geographic", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/metrics/geographic/sessions", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/metrics/usage", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/metrics/sla", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/metrics/rule-stats", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/metrics/vulnerability", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin), + new("GET", "global/metrics/app", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/metrics/fleet-health", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/apps/list", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/apps/{appName}/analytics", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/apps/{appName}/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/metrics/geographic", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/metrics/geographic/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/metrics/usage", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/metrics/sla", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/metrics/rule-stats", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/metrics/vulnerability", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("GET", "global/distress-reports", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/session-reports", EndpointPolicy.GlobalReadOrAdmin), + new("GET", "global/session-reports", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("GET", "global/session-reports/download-url", EndpointPolicy.GlobalReadOrAdmin), new("PATCH", "global/session-reports/{reportId}/note", EndpointPolicy.GlobalAdminOnly), - new("GET", "global/rules/gather", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "global/rules/analyze", EndpointPolicy.GlobalReadOrAdmin), + new("GET", "global/rules/gather", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/rules/analyze", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("GET", "global/devices/blocked", EndpointPolicy.GlobalReadOrAdmin), new("GET", "devices/blocked", EndpointPolicy.GlobalReadOrAdmin), new("POST", "devices/block", EndpointPolicy.GlobalAdminOnly), @@ -370,7 +379,7 @@ public static class EndpointAccessPolicyCatalog // The {tenantId} route param is the OFFBOARDED tenant whose data was archived; // the operator (GA) does not belong to that tenant. RouteParam scoping declares // that the route's tenantId is supplied from the URL, not from the caller's JWT. - new("GET", "global/customs-archive", EndpointPolicy.GlobalReadOrAdmin), + new("GET", "global/customs-archive", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("GET", "global/customs-archive/{tenantId}/{historyRowKey}", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.RouteParam), new("GET", "global/customs-archive/{tenantId}/{historyRowKey}/{archiveRowKey}", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.RouteParam), new("DELETE", "global/customs-archive/{tenantId}/{historyRowKey}/{archiveRowKey}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), @@ -379,7 +388,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "health/mcp", EndpointPolicy.AuthenticatedUser), new("POST", "rules/reseed-from-github", EndpointPolicy.GlobalAdminOnly), new("GET", "vulnerability/unmatched-software", EndpointPolicy.GlobalReadOrAdmin), - new("GET", "vulnerability/software-inventory", EndpointPolicy.GlobalReadOrAdmin), + new("GET", "vulnerability/software-inventory", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("POST", "vulnerability/sync", EndpointPolicy.GlobalAdminOnly), new("POST", "vulnerability/sync-msrc", EndpointPolicy.GlobalAdminOnly), new("GET", "vulnerability/sync-status", EndpointPolicy.GlobalReadOrAdmin), @@ -413,7 +422,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "notifications", EndpointPolicy.MemberRead, TenantScoping.Jwt), new("POST", "notifications/dismiss-all", EndpointPolicy.TenantAdminOrGA, TenantScoping.Jwt), new("POST", "notifications/{notificationId}/dismiss", EndpointPolicy.TenantAdminOrGA, TenantScoping.Jwt), - new("GET", "global/ops-events", EndpointPolicy.GlobalReadOrAdmin), + new("GET", "global/ops-events", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), }; ///