mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user