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),
};
///