feat(backend): delegated single-tenant access to /api/global/* reads (MSP switcher) [Phase 2a]

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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-25 20:16:49 +02:00
parent 967faf47ee
commit d44dbe7ec0
4 changed files with 149 additions and 39 deletions
@@ -225,10 +225,11 @@ public class EndpointPolicyCatalogCompletenessTests
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
@@ -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<TenantConfiguration>()), 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);
}
}
@@ -240,12 +240,21 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// <summary>
/// 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
/// <c>isScopedRoute &amp;&amp; crossTenant</c> guard in <see cref="DecideAsync"/>. 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.
/// </summary>
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<CatalogDecisionResult> EvaluateCatalogPolicyAsync(
ClaimsPrincipal? principal, EndpointPolicyEntry entry)
@@ -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),
};
/// <summary>