feat(backend): bounded tenant list for delegated admins (MSP fleet) [Phase 2b]

Introduces the GlobalReadOrDelegatedSubset policy tier — a cross-tenant READ that
is BOUNDABLE to a subset — and applies it to config/all (the tenant list). A
delegated ("MSP") admin can now list ITS managed tenants in one call, bounded to
its allowed set; a Global Admin / Reader still gets all tenants (unbounded).

This is the server-side linchpin for the MSP fleet view + tenant switcher: the
frontend gets the managed-tenant list (id + domain) here, then builds the
per-tenant fleet grid by fanning out over that subset using the Phase-2a
single-tenant /api/global/*?tenantId= endpoints (subset fan-out, client-orchestrated
over already-bounded per-tenant reads — no all-tenants code path for delegated).

- Middleware: new EvaluateGlobalReadOrDelegatedSubsetAsync — GA/Reader admitted
  unbounded; a delegated caller with a non-empty scope admitted with its managed
  set carried on the decision and published as RequestContext.AllowedTenantIds.
  DecideAsync merges this into the delegated flags + AllowedTenantIds.
- GetAllTenantConfigurationsFunction: when AllowedTenantIds is present, BIND the
  response to that subset (both the bare-array and paginated shapes) and always
  redact secrets (a delegated caller is never a Global Admin). The subset is small,
  so it is returned in one shot.
- Catalog: config/all -> GlobalReadOrDelegatedSubset. Aggregates that cannot be
  bounded stay GlobalReadOrAdmin + None (delegated-unreachable).
- Tests: delegated config/all admitted with bounded AllowedTenantIds; GA unbounded
  (null bound); non-delegated non-global denied; tier-assignment guard. Full suite green (2464).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-25 20:40:50 +02:00
parent d44dbe7ec0
commit 87711e1261
4 changed files with 154 additions and 8 deletions
@@ -507,7 +507,6 @@ public class PolicyEnforcementMiddlewareTests
[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);
@@ -515,4 +514,62 @@ public class PolicyEnforcementMiddlewareTests
Assert.Equal(EndpointPolicy.GlobalReadOrAdmin, entry!.Policy);
Assert.Equal(expected, entry.TenantScoping);
}
// ── Phase 2b: config/all is a bounded-subset aggregate (GlobalReadOrDelegatedSubset) ──
[Fact]
public void ConfigAll_IsGlobalReadOrDelegatedSubsetTier()
{
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/config/all");
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.GlobalReadOrDelegatedSubset, entry!.Policy);
}
[Fact]
public async Task Delegated_ConfigAll_IsAdmitted_WithBoundedSubset()
{
// A delegated admin may list ITS tenants via config/all — the middleware publishes the managed set
// on AllowedTenantIds and the handler binds the response to it.
const string upn = "msp@partner.example";
var h = BuildHarness();
h.AsDelegated(TenantB);
var result = await h.Middleware.DecideAsync("GET", "/api/config/all", null, AuthedPrincipal(TenantA, upn));
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]
public async Task GlobalAdmin_ConfigAll_IsUnbounded()
{
// A Global Admin sees ALL tenants: no AllowedTenantIds bound is published (handler does not filter).
const string upn = "ga@contoso.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
var result = await h.Middleware.DecideAsync("GET", "/api/config/all", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.True(result.Context!.IsGlobalAdmin);
Assert.Null(result.Context!.AllowedTenantIds);
}
[Fact]
public async Task NonDelegatedNonGlobal_ConfigAll_IsForbidden()
{
// A plain tenant admin (no delegated assignment, no platform role) cannot list tenants.
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
var result = await h.Middleware.DecideAsync("GET", "/api/config/all", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
}
@@ -44,6 +44,38 @@ namespace AutopilotMonitor.Functions.Functions.Config
return bad;
}
// Delegated ("MSP") caller: the middleware (GlobalReadOrDelegatedSubset tier) admitted them
// and published their managed tenant set on AllowedTenantIds. BIND the response to that
// subset — a delegated admin must never see a tenant they do not manage. Secrets are always
// redacted for them (they are never a Global Admin). The subset is small, so we return it in
// one shot (no server pagination) in whichever shape the caller requested.
var requestCtx = req.GetRequestContext();
if (requestCtx.AllowedTenantIds != null)
{
var allowed = new System.Collections.Generic.HashSet<string>(
requestCtx.AllowedTenantIds, StringComparer.OrdinalIgnoreCase);
var all = await _configService.GetAllConfigurationsAsync();
var subset = all.Where(c => allowed.Contains(c.TenantId)).ToList();
_logger.LogInformation("GetAllTenantConfigurations (delegated subset, {Count} tenants) by {User}",
subset.Count, userIdentifier);
if (parsed.PageSize == null)
{
var redacted = subset.Select(c => c.RedactedCopyForReader()).ToList();
var resp = req.CreateResponse(HttpStatusCode.OK);
await resp.WriteAsJsonAsync(redacted);
return resp;
}
var projected = TenantConfigProjection.ProjectAll(subset, parsed.Fields);
return await req.OkAsync(new
{
count = projected.Count,
tenants = projected,
nextLink = (string?)null,
});
}
// Legacy/default mode: no pageSize → unpaginated bare full-config array.
// Web consumers (tenant selectors + admin config editor) depend on this shape.
if (parsed.PageSize == null)
@@ -190,6 +190,14 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
decision.UserIdentifier, decision.UserRole, "CrossTenant");
}
// Merge delegated state from the two delegated admission paths:
// • scoped-route (RouteParam/QueryParam): a single-tenant cross-tenant read — delegatedRole +
// allowedTenantIds were set above.
// • GlobalReadOrDelegatedSubset tier (aggregate, no single target): the evaluator carried the
// managed tenant set on the decision instead. Either way the caller is a delegated reader here.
var admittedViaDelegatedSubset = decision.AllowedTenantIds != null;
var effectiveAllowedTenantIds = allowedTenantIds ?? decision.AllowedTenantIds;
var requestContext = new RequestContext
{
TenantId = jwtTenantId,
@@ -197,11 +205,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
UserPrincipalName = decision.UserIdentifier,
IsGlobalAdmin = isGlobalAdmin,
IsGlobalReader = isGlobalReader,
// Delegated flags reflect the caller's role for THIS request's target (null unless a delegated
// grant applied). Phase 1: both confer read only — no write tier admits a delegated caller.
IsDelegatedReader = delegatedRole == Constants.DelegatedRoles.DelegatedReader,
// Delegated flags reflect the caller's role for THIS request (null unless a delegated grant
// applied — either a single-tenant scoped read or the bounded-subset aggregate tier). Read only:
// no write tier admits a delegated caller.
IsDelegatedReader = delegatedRole == Constants.DelegatedRoles.DelegatedReader || admittedViaDelegatedSubset,
IsDelegatedAdmin = delegatedRole == Constants.DelegatedRoles.DelegatedAdmin,
AllowedTenantIds = allowedTenantIds,
AllowedTenantIds = effectiveAllowedTenantIds,
// Additive: a caller's tenant-admin status is its OWN-TENANT role, independent of any
// platform role. Evaluators that admit via a platform-role branch still surface the
// resolved tenant role on decision.TenantRole; pure-tenant branches leave it null and
@@ -296,6 +305,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
case EndpointPolicy.GlobalReadOrAdmin:
return await EvaluateGlobalReadOrAdminAsync(upn, userIdentifier);
case EndpointPolicy.GlobalReadOrDelegatedSubset:
return await EvaluateGlobalReadOrDelegatedSubsetAsync(upn, userIdentifier);
case EndpointPolicy.GlobalAdminOnly:
return await EvaluateGlobalAdminOnlyAsync(upn, userIdentifier);
@@ -484,6 +496,33 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return CatalogDecisionResult.Deny(userIdentifier, "NonGlobal", "NotGlobalScope");
}
/// <summary>
/// Cross-tenant READ that is BOUNDABLE to a subset. A Global Admin / Global Reader is admitted unbounded
/// (no AllowedTenantIds — the handler sees all tenants). A delegated ("MSP") admin with a non-empty scope
/// is admitted too, but carries its managed tenant set on the result so the middleware can publish it as
/// RequestContext.AllowedTenantIds; the HANDLER is then responsible for restricting the aggregate
/// response to that set. A pure delegated reader of nothing, or any non-global non-delegated caller, is
/// denied. Used by aggregate endpoints whose body can be filtered per tenant (e.g. config/all).
/// </summary>
private async Task<CatalogDecisionResult> EvaluateGlobalReadOrDelegatedSubsetAsync(
string? upn, string userIdentifier)
{
if (string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
var globalRole = await _globalAdminService.GetGlobalRoleAsync(upn);
if (globalRole != null)
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
var scope = await _delegatedAdminService.GetScopeAsync(upn);
if (!scope.IsEmpty)
return CatalogDecisionResult.Allow(
userIdentifier, Constants.DelegatedRoles.DelegatedReader, "DelegatedSubset",
allowedTenantIds: scope.TenantIds);
return CatalogDecisionResult.Deny(userIdentifier, "NonGlobal", "NotGlobalOrDelegated");
}
private async Task<CatalogDecisionResult> EvaluateGlobalAdminOnlyAsync(
string? upn, string userIdentifier)
{
@@ -548,8 +587,16 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// </summary>
public string? TenantRole { get; private init; }
public static CatalogDecisionResult Allow(string user, string role, string reason, string? tenantRole = null)
=> new() { IsAllowed = true, UserIdentifier = user, UserRole = role, Reason = reason, TenantRole = tenantRole };
/// <summary>
/// For the GlobalReadOrDelegatedSubset tier: a delegated caller's managed tenant set, which the
/// middleware publishes as RequestContext.AllowedTenantIds so the handler can bound its aggregate
/// response. Null for unbounded (Global Admin / Reader) admits and all other tiers.
/// </summary>
public IReadOnlyCollection<string>? AllowedTenantIds { get; private init; }
public static CatalogDecisionResult Allow(string user, string role, string reason, string? tenantRole = null,
IReadOnlyCollection<string>? allowedTenantIds = null)
=> new() { IsAllowed = true, UserIdentifier = user, UserRole = role, Reason = reason, TenantRole = tenantRole, AllowedTenantIds = allowedTenantIds };
public static CatalogDecisionResult Deny(string user, string role, string reason)
=> new() { IsAllowed = false, UserIdentifier = user, UserRole = role, Reason = reason };
@@ -78,6 +78,16 @@ public enum EndpointPolicy
/// </summary>
GlobalReadOrAdmin,
/// <summary>
/// Cross-tenant READ that can be BOUNDED to a tenant subset. Global Admin / Global Reader see ALL
/// tenants (unbounded). A delegated ("MSP") admin is also admitted, but the middleware populates
/// <see cref="Helpers.RequestContext.AllowedTenantIds"/> with its managed subset and the HANDLER MUST
/// restrict its output to that set (the middleware cannot filter an aggregate response body). Use only
/// for aggregate endpoints whose response is filterable per-tenant — e.g. config/all (the tenant list).
/// Aggregates that cannot be bounded stay GlobalReadOrAdmin + TenantScoping.None (delegated-unreachable).
/// </summary>
GlobalReadOrDelegatedSubset,
/// <summary>Global Admin only. Platform-wide access (all writes + global settings).</summary>
GlobalAdminOnly,
}
@@ -321,7 +331,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/config", EndpointPolicy.GlobalReadOrAdmin),
new("PUT", "global/config", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/config", EndpointPolicy.GlobalAdminOnly),
new("GET", "config/all", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "config/all", EndpointPolicy.GlobalReadOrDelegatedSubset),
new("GET", "auth/global-admins", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "auth/global-admins", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "auth/global-admins/{upn}", EndpointPolicy.GlobalAdminOnly),