From 87711e12619f1cfc7fd8049cb5043d1dfb36faae Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 25 Jun 2026 20:40:50 +0200 Subject: [PATCH] feat(backend): bounded tenant list for delegated admins (MSP fleet) [Phase 2b] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../PolicyEnforcementMiddlewareTests.cs | 59 ++++++++++++++++++- .../GetAllTenantConfigurationsFunction.cs | 32 ++++++++++ .../Middleware/PolicyEnforcementMiddleware.cs | 59 +++++++++++++++++-- .../Security/EndpointAccessPolicyCatalog.cs | 12 +++- 4 files changed, 154 insertions(+), 8 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs index 5b02cd7c..8d6fa17e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs @@ -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); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs index e5a437ae..4a862789 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs @@ -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( + 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) diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs index 65b20731..ebc7a607 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs @@ -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"); } + /// + /// 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). + /// + private async Task 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 EvaluateGlobalAdminOnlyAsync( string? upn, string userIdentifier) { @@ -548,8 +587,16 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware /// 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 }; + /// + /// 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. + /// + public IReadOnlyCollection? AllowedTenantIds { get; private init; } + + public static CatalogDecisionResult Allow(string user, string role, string reason, string? tenantRole = null, + IReadOnlyCollection? 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 }; diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 5d90958f..8b0d4453 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -78,6 +78,16 @@ public enum EndpointPolicy /// GlobalReadOrAdmin, + /// + /// 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 + /// 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). + /// + GlobalReadOrDelegatedSubset, + /// Global Admin only. Platform-wide access (all writes + global settings). 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),