using System.Net; using System.Security.Claims; using AutopilotMonitor.Functions.Extensions; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; using Microsoft.AspNetCore.Http; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Middleware; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Middleware; /// /// Policy enforcement middleware that evaluates the EndpointAccessPolicyCatalog against each request /// and blocks requests that don't meet the required policy. Fail-closed: unregistered routes are denied. /// /// Phase 3 of the auth refactor: replaces both PolicyAuditMiddleware (logging-only) and /// MemberAuthorizationMiddleware (coarse member check) with catalog-driven enforcement. /// /// Middleware order: AuthenticationMiddleware → PolicyEnforcementMiddleware → Function /// public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware { /// /// Throttle bucket for an authenticated token that carries no identifying claim at all (no upn, /// oid, sub, appid or azp). Every real Entra token has at least a sub, so this is a /// fail-closed backstop rather than a live code path: such callers share one bucket instead of /// escaping the limiter. Deliberately NOT "anonymous" — that value is reserved for the log /// rendering of unauthenticated calls and must never become a rate-limit key again. /// internal const string UnidentifiedCallerBucket = "unidentified-token"; private readonly ILogger _logger; private readonly GlobalAdminService _globalAdminService; private readonly DelegatedAdminService _delegatedAdminService; private readonly TenantAdminsService _tenantAdminsService; private readonly TenantConfigurationService _tenantConfigService; public PolicyEnforcementMiddleware( ILogger logger, GlobalAdminService globalAdminService, DelegatedAdminService delegatedAdminService, TenantAdminsService tenantAdminsService, TenantConfigurationService tenantConfigService) { _logger = logger; _globalAdminService = globalAdminService; _delegatedAdminService = delegatedAdminService; _tenantAdminsService = tenantAdminsService; _tenantConfigService = tenantConfigService; } public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next) { var httpContext = context.GetHttpContext(); if (httpContext == null) { // Non-HTTP trigger (e.g. timer, queue) — no auth needed await next(context); return; } var requestPath = httpContext.Request.Path.Value ?? string.Empty; var httpMethod = httpContext.Request.Method; var queryTenantId = httpContext.Request.Query["tenantId"].FirstOrDefault(); // Sanitized copies for logging only — never used for routing/authorization decisions. var logPath = LogSanitizer.Clean(requestPath); var logMethod = LogSanitizer.Clean(httpMethod); // Authenticated principal set by AuthenticationMiddleware (null on anonymous/device routes). // Read from FunctionContext.Items (reliable in the isolated worker) rather than httpContext.User. ClaimsPrincipal? principal = null; if (context.Items.TryGetValue("ClaimsPrincipal", out var principalObj) && principalObj is ClaimsPrincipal cp && cp.Identity?.IsAuthenticated == true) { principal = cp; } // All authorization + RequestContext construction is the transport-agnostic DecideAsync seam. // Fail-closed on service errors: return 503 (not 500) so clients can retry. PolicyResult result; try { result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal); } catch (Exception ex) { _logger.LogError(ex, "[PolicyEnforcement] Service error evaluating policy for {Method} {Path}", logMethod, logPath); httpContext.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; httpContext.Response.ContentType = "application/json"; httpContext.Response.Headers["Retry-After"] = "5"; await httpContext.Response.WriteAsJsonAsync(new { error = "ServiceUnavailable", message = "Authorization service temporarily unavailable. Please retry." }); return; } if (result.Allowed) { context.Items[RequestContext.ItemsKey] = result.Context!; _logger.LogDebug("[PolicyEnforcement] ALLOW {Method} {Path} user={User} role={Role}", logMethod, logPath, LogSanitizer.Clean(result.UserIdentifier), result.UserRole); await next(context); return; } _logger.LogWarning("[PolicyEnforcement] DENIED {Method} {Path} status={Status} user={User} role={Role} reason={Reason}", logMethod, logPath, result.StatusCode, LogSanitizer.Clean(result.UserIdentifier), result.UserRole, result.LogReason); httpContext.Response.StatusCode = result.StatusCode; httpContext.Response.ContentType = "application/json"; await httpContext.Response.WriteAsJsonAsync(new { error = result.ErrorCode, message = result.ErrorMessage }); } /// /// Transport-agnostic authorization decision: catalog lookup → policy evaluation → cross-tenant /// enforcement → RequestContext construction. Returns an allow (carrying the resolved RequestContext) /// or a deny (carrying the HTTP status + error payload). Extracted from so the /// full decision — including the additive tenant-role resolution that drives RequestContext.IsTenantAdmin — /// is unit-testable without faking the worker's HttpContext feature. is a thin /// adapter that reads the request, calls this, and writes the response. /// internal async Task DecideAsync( string httpMethod, string requestPath, string? queryTenantId, ClaimsPrincipal? principal) { // Sanitized copies for logging only — never used for routing/authorization decisions. var logPath = LogSanitizer.Clean(requestPath); var logMethod = LogSanitizer.Clean(httpMethod); var catalogEntry = EndpointAccessPolicyCatalog.FindPolicy(httpMethod, requestPath); if (catalogEntry == null) { // Fail-closed: unregistered route → 403 _logger.LogError("[PolicyEnforcement] BLOCKED unregistered route: {Method} {Path} — not in catalog (fail-closed)", logMethod, logPath); return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "Forbidden", "Access denied.", "anonymous", "N/A", "Unregistered"); } var decision = await EvaluateCatalogPolicyAsync(principal, catalogEntry); var jwtTenantId = principal?.GetTenantId() ?? string.Empty; var upn = principal?.GetUserPrincipalName(); var isGlobalAdmin = decision.IsAllowed && decision.UserRole == Constants.GlobalRoles.GlobalAdmin; var isGlobalReader = decision.IsAllowed && decision.UserRole == Constants.GlobalRoles.GlobalReader; // Platform-wide read scope (GA or Reader) governs cross-tenant visibility; write power is GA only. var hasGlobalScope = isGlobalAdmin || isGlobalReader; // Resolve the target tenant for tenant-scoped routes ({tenantId} in path, or ?tenantId= query). // crossTenant = the route names a tenant OTHER than the caller's JWT tenant. var (targetTenantId, namedTarget) = ResolveTarget(catalogEntry, requestPath, queryTenantId, jwtTenantId); var crossTenant = namedTarget != null && !string.Equals(namedTarget, jwtTenantId, StringComparison.OrdinalIgnoreCase); // Delegated ("scoped global" / MSP) resolution. A delegated admin reaches a SUBSET of tenants — // ONLY on tenant-scoped read routes where the named target is in its scope. We resolve lazily: // skip when the caller already has full platform scope, is anonymous, or the request is not a // cross-tenant scoped route (own-tenant + non-scoped routes never need a delegated grant). This // keeps the common authenticated path free of the extra table read. string? delegatedRole = null; IReadOnlyCollection? allowedTenantIds = null; var isScopedRoute = catalogEntry.TenantScoping is TenantScoping.RouteParam or TenantScoping.QueryParam; if (!hasGlobalScope && !string.IsNullOrEmpty(upn) && isScopedRoute && crossTenant) { // Full identity (upn + tid + oid): the scope resolves only for the bound identity, and the home // tenant (JWT tid) gates it — MSP seats require a Pro home tenant. var scope = await _delegatedAdminService.GetScopeAsync(AdminIdentity.FromPrincipal(principal)); delegatedRole = scope.RoleFor(namedTarget); if (delegatedRole != null) allowedTenantIds = scope.TenantIds; } // A delegated grant only applies to the READ tiers — never to write tiers (TenantAdminOrGA, // GlobalAdminOnly, …). This is the single fact that keeps delegation read-only in this phase and // prevents a delegated READER from crossing into a write on a tenant they merely read. // ExcludeDelegated routes (platform-operational GA/Reader-only reads whose {tenantId} template forces // RouteParam) opt OUT of the delegated rescue entirely — the None-equivalent for a scoped route. var delegatedGrantsRead = delegatedRole != null && IsDelegatedReadTier(catalogEntry.Policy) && !catalogEntry.ExcludeDelegated; // Policy-tier admission. If the evaluator denied a delegated-only caller (no own-tenant membership, // no platform role) but they ARE a delegated reader of this read route's target, rescue the denial. if (!decision.IsAllowed) { if (delegatedGrantsRead) { decision = CatalogDecisionResult.Allow(decision.UserIdentifier, Constants.DelegatedRoles.DelegatedReader, "DelegatedScope"); } else { var status = decision.Reason is "NoJWT" or "MissingClaims" ? HttpStatusCode.Unauthorized : HttpStatusCode.Forbidden; var (code, message) = status == HttpStatusCode.Unauthorized ? ("AuthenticationRequired", "Authentication required. Please provide a valid JWT token.") : ("InsufficientPermissions", "Access denied. You do not have permission to access this resource."); return PolicyResult.Deny((int)status, code, message, decision.UserIdentifier, decision.UserRole, decision.Reason); } } // Cross-tenant enforcement: a caller may reach a tenant other than its own ONLY with full platform // scope OR a delegated READ grant for that exact target. Applies to RouteParam/QueryParam routes. if (crossTenant && !hasGlobalScope && !delegatedGrantsRead) { _logger.LogWarning("[PolicyEnforcement] BLOCKED cross-tenant access: user={User} jwtTenant={JwtTenant} target={Target} scoping={Scoping} path={Path}", LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(jwtTenantId), LogSanitizer.Clean(namedTarget), catalogEntry.TenantScoping, logPath); return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "CrossTenantAccessDenied", "Access denied. You can only access your own tenant's resources.", decision.UserIdentifier, decision.UserRole, "CrossTenant"); } // Tenant suspension gate — the operator kill-switch (TenantConfiguration.Disabled/DisabledUntil) and // the offboarding tombstone. Enforced HERE, on the shared authorization path, so it covers every // tenant-scoped API call and not just the SPA's auth/me login probe (which also carries it, for the // richer client payload). Both the caller's HOME tenant (JWT tid) and the request's TARGET tenant are // checked: a suspended tenant's members lose everything, including delegated reach into other tenants, // and nobody without platform scope reaches a suspended target. Platform operators (GA/Reader) bypass — // they must still administer the suspended tenant. Anonymous/device routes bring their own gate // (SecurityValidator → 403 TenantDisabled). Cached, side-effect-free read; a missing row is not disabled. if (principal != null && !hasGlobalScope) { var suspended = await FindSuspendedTenantAsync(jwtTenantId, targetTenantId); if (suspended != null) { _logger.LogWarning("[PolicyEnforcement] BLOCKED suspended tenant: user={User} tenant={Tenant} path={Path}", LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(suspended.TenantId), logPath); return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "TenantSuspended", !string.IsNullOrEmpty(suspended.DisabledReason) ? suspended.DisabledReason : "Your tenant has been suspended. Please contact support for more information.", decision.UserIdentifier, decision.UserRole, "TenantSuspended"); } } // 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, TargetTenantId = targetTenantId, // ONLY a genuine JWT UPN lands here — never the "anonymous" placeholder that // decision.UserIdentifier carries on device/anonymous routes. The placeholder used to be // published here, which collapsed the entire agent fleet (every tenant) into one shared // user_ratelimit_anonymous bucket and 429'd agent telemetry fleet-wide. It stays on // decision.UserIdentifier for log lines, where "anonymous" is the useful rendering. UserPrincipalName = upn ?? string.Empty, ObjectId = principal?.GetObjectId() ?? string.Empty, // Throttle identity — NOT the same thing as the UPN. Empty only when the request carried // no JWT at all (device/anonymous routes, which bring their own limits). An authenticated // caller ALWAYS gets a non-empty value, including app-only tokens that carry no upn: // those authenticate fine and reach every AuthenticatedUser route, so keying the throttle // off the UPN alone would let them through unlimited. Fail closed if a token somehow // carries no identifying claim at all — a shared bucket is the safe answer there. CallerId = principal is null ? string.Empty : (principal.GetCallerId() ?? UnidentifiedCallerBucket), IsGlobalAdmin = isGlobalAdmin, IsGlobalReader = isGlobalReader, // 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 = 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 // carry the role in UserRole — so fall back to UserRole when TenantRole is unset. IsTenantAdmin = (decision.TenantRole ?? decision.UserRole) == Constants.TenantRoles.Admin, UserRole = decision.UserRole }; return PolicyResult.Allow(requestContext, decision.UserIdentifier, decision.UserRole); } /// /// Returns the configuration of the first currently-suspended tenant among the caller's home tenant and /// the request target (null when neither is suspended). /// is cached (5 min TTL, same as the agent gate) and never persists a default row. /// already treats an elapsed DisabledUntil as /// re-enabled, so the gate lifts on its own; auth/me persists the flip on the next login. /// private async Task FindSuspendedTenantAsync(string jwtTenantId, string targetTenantId) { var (homeConfig, _) = await _tenantConfigService.TryGetConfigurationAsync(jwtTenantId); if (homeConfig.IsCurrentlyDisabled()) return homeConfig; if (string.Equals(targetTenantId, jwtTenantId, StringComparison.OrdinalIgnoreCase)) return null; var (targetConfig, _) = await _tenantConfigService.TryGetConfigurationAsync(targetTenantId); return targetConfig.IsCurrentlyDisabled() ? targetConfig : null; } /// /// Resolves the request's target tenant. Returns the effective TargetTenantId (defaults to the JWT /// tenant) and the explicitly-named tenant from the route/query (null when the route does not name one, /// so the caller can distinguish "no target named" from "target == own tenant"). Pure — no auth. /// private static (string targetTenantId, string? namedTarget) ResolveTarget( EndpointPolicyEntry catalogEntry, string requestPath, string? queryTenantId, string jwtTenantId) { if (catalogEntry.TenantScoping == TenantScoping.RouteParam) { var normalizedPath = requestPath.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) ? requestPath.Substring(5) : requestPath; var routeTenantId = catalogEntry.RouteRegex.Match(normalizedPath).Groups["tenantId"].Value; if (!string.IsNullOrEmpty(routeTenantId)) return (routeTenantId, routeTenantId); } else if (catalogEntry.TenantScoping == TenantScoping.QueryParam && !string.IsNullOrWhiteSpace(queryTenantId)) { return (queryTenantId, queryTenantId); } return (jwtTenantId, null); } /// /// 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). /// /// 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 or EndpointPolicy.GlobalReadOrAdmin // Subset-tier routes (global/sessions, global/stats/sessions) admit a delegated caller for the // bounded AGGREGATE (no tenantId, handler bounds to AllowedTenantIds); the single-tenant ?tenantId= // DRILL still flows through the scoped-route cross-tenant guard, which requires this tier to count // as a delegated read tier so RoleFor(target) can rescue the cross-tenant block. or EndpointPolicy.GlobalReadOrDelegatedSubset; private async Task EvaluateCatalogPolicyAsync( ClaimsPrincipal? principal, EndpointPolicyEntry entry) { // principal is the authenticated ClaimsPrincipal (set by AuthenticationMiddleware), or null on // anonymous/device routes — resolved by the caller (Invoke / DecideAsync). var tenantId = principal?.GetTenantId(); var upn = principal?.GetUserPrincipalName(); var userIdentifier = upn ?? "anonymous"; switch (entry.Policy) { case EndpointPolicy.PublicAnonymous: case EndpointPolicy.DeviceOrBootstrapAuth: // These are always allowed at the middleware level // (device auth is enforced in functions via ValidateSecurityAsync) return CatalogDecisionResult.Allow(userIdentifier, "N/A", "PolicyDoesNotRequireJWT"); case EndpointPolicy.AuthenticatedUser: if (principal != null) return CatalogDecisionResult.Allow(userIdentifier, "Authenticated", "ValidJWT"); return CatalogDecisionResult.Deny(userIdentifier, "N/A", "NoJWT"); case EndpointPolicy.AuthenticatedUserWithRole: return await EvaluateAuthenticatedUserWithRoleAsync(tenantId, upn, principal, userIdentifier); case EndpointPolicy.MemberRead: return await EvaluateMemberReadAsync(tenantId, upn, principal, userIdentifier); case EndpointPolicy.TenantAdminOrGlobalReader: return await EvaluateTenantAdminOrGlobalReaderAsync(tenantId, upn, principal, userIdentifier); case EndpointPolicy.TenantAdminOrOperator: return await EvaluateTenantAdminOrOperatorAsync(tenantId, upn, principal, userIdentifier); case EndpointPolicy.TenantAdminOrGA: return await EvaluateTenantAdminOrGAAsync(tenantId, upn, principal, userIdentifier); case EndpointPolicy.BootstrapManagerOrGA: return await EvaluateBootstrapManagerOrGAAsync(tenantId, upn, principal, userIdentifier); case EndpointPolicy.GlobalReadOrAdmin: return await EvaluateGlobalReadOrAdminAsync(principal, upn, userIdentifier); case EndpointPolicy.GlobalReadOrDelegatedSubset: return await EvaluateGlobalReadOrDelegatedSubsetAsync(principal, upn, userIdentifier); case EndpointPolicy.GlobalAdminOnly: return await EvaluateGlobalAdminOnlyAsync(principal, upn, userIdentifier); default: return CatalogDecisionResult.Deny(userIdentifier, "N/A", $"UnknownPolicy:{entry.Policy}"); } } /// /// Allows ANY authenticated caller (valid JWT) but still resolves their Global-Admin status and /// effective tenant role so the downstream function can make fine-grained, per-resource decisions /// (the resolved values flow onto RequestContext.IsGlobalAdmin/IsTenantAdmin/UserRole). A caller /// with no special role is admitted as "Authenticated" (role flags stay false) — non-member end /// users are intentionally allowed here; the function gates the privileged resources. /// private async Task EvaluateAuthenticatedUserWithRoleAsync( string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier) { if (principal == null) return CatalogDecisionResult.Deny(userIdentifier, "N/A", "NoJWT"); // A platform role (GlobalAdmin or read-only GlobalReader) is surfaced as the primary role so // per-resource gates inside the function can tell GA from Reader (e.g. cross-tenant SignalR // group joins). ADDITIVE: we ALSO resolve the caller's own-tenant role and carry it separately, // so a user who is both GlobalReader and TenantAdmin still has IsTenantAdmin=true in the context // (admin notification group, admin-audience notifications). if (!string.IsNullOrEmpty(upn)) { var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal)); if (globalRole != null) { var tenantRole = !string.IsNullOrEmpty(tenantId) ? (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role : null; return CatalogDecisionResult.Allow(userIdentifier, globalRole, "ValidJWT+GlobalScope", tenantRole); } } // Resolve the effective tenant role (may be null for non-members). A resolved role is // surfaced so the function can distinguish members from roleless end users; either way // the request is allowed at the middleware tier. if (!string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(upn)) { var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal); if (role?.Role != null) return CatalogDecisionResult.Allow(userIdentifier, role.Role, "ValidJWT+Member", role.Role); } // Authenticated but not a member and not a GA — still allowed (the function decides which // groups, if any, this caller may join). return CatalogDecisionResult.Allow(userIdentifier, "Authenticated", "ValidJWT"); } private async Task EvaluateMemberReadAsync( string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier) { if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn)) return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims"); // Any platform role (GlobalAdmin or read-only GlobalReader) satisfies a member-read. ADDITIVE: // also resolve the own-tenant role and carry it separately so IsTenantAdmin stays correct for a // GlobalReader who is also their tenant's admin (drives admin-audience on /api/notifications). var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal)); if (globalRole != null) { var tenantRole = (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role; return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScopeBypass", tenantRole); } var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal); if (role == null) return CatalogDecisionResult.Deny(userIdentifier, "NonMember", "NotInTenant"); // MemberRead allows Admin, Operator, AND Viewer return CatalogDecisionResult.Allow(userIdentifier, role.Role, "TenantMember", role.Role); } /// /// Admin-tier tenant READ: own-tenant Admin, OR any platform role (GlobalAdmin / GlobalReader). /// Used on GET routes that expose admin-tier tenant data (e.g. config) so the read-only GlobalReader /// gains visibility without write power, while tenant Operators/Viewers remain excluded. ADDITIVE: /// when admitted via the platform-role branch the caller's own-tenant role is ALSO resolved and /// carried, so a GlobalReader who is also their tenant's Admin keeps IsTenantAdmin=true (config GET /// then serves them the FULL config for their own tenant, redacting only the read-only-reader view). /// private async Task EvaluateTenantAdminOrGlobalReaderAsync( string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier) { if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn)) return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims"); var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal)); if (globalRole != null) { var tenantRole = (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role; return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScopeBypass", tenantRole); } var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal); if (role?.Role == Constants.TenantRoles.Admin) return CatalogDecisionResult.Allow(userIdentifier, Constants.TenantRoles.Admin, "TenantAdmin", Constants.TenantRoles.Admin); return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOrGlobalScope"); } private async Task EvaluateTenantAdminOrGAAsync( string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier) { if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn)) return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims"); if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal))) return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass"); var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal); if (role?.Role == Constants.TenantRoles.Admin) return CatalogDecisionResult.Allow(userIdentifier, Constants.TenantRoles.Admin, "TenantAdmin"); return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOrGA"); } /// /// Operational write tier: own-tenant Admin or Operator (no Viewer), or Global Admin. /// The admitted member's role flows onto RequestContext.UserRole so a function that /// serves multiple operation kinds can re-gate the admin-only ones on IsTenantAdmin. /// The read-only Global Reader is deliberately NOT admitted — this is a write tier. /// private async Task EvaluateTenantAdminOrOperatorAsync( string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier) { if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn)) return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims"); if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal))) return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass"); var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal); if (role?.Role == Constants.TenantRoles.Admin || role?.Role == Constants.TenantRoles.Operator) return CatalogDecisionResult.Allow(userIdentifier, role.Role, "TenantAdminOrOperator"); return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOperatorOrGA"); } private async Task EvaluateBootstrapManagerOrGAAsync( string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier) { if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn)) return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims"); if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal))) return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass"); var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal); var canManageBootstrap = role != null && (role.Role == Constants.TenantRoles.Admin || (role.Role == Constants.TenantRoles.Operator && role.CanManageBootstrapTokens)); if (canManageBootstrap) return CatalogDecisionResult.Allow(userIdentifier, "BootstrapManager", "CanManageBootstrap"); return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NoBootstrapPermission"); } /// /// Resolves the effective tenant member role: the TenantAdmins table entry if present, /// otherwise — when the tenant has Entra app-roles enabled — the role derived from the token's /// "roles" claim. Table always wins (manual override). The tenant-config lookup is only /// performed when there is no table entry, so the common (table member) path stays cheap. /// private async Task ResolveEffectiveRoleAsync( string tenantId, string upn, ClaimsPrincipal? principal) { var (state, tableRole) = await _tenantAdminsService.GetTableMembershipAsync(tenantId, upn); // Table-first: an enabled row wins, a disabled row is an explicit deny. Both skip the // claim path entirely — and avoid the tenant-config lookup. Only when no row exists do // we consult the Entra app-role claim (gated by the per-tenant opt-in flag). if (state != TableMemberState.NotPresent) return EntraAppRoleResolver.Resolve(state, tableRole, appRoles: null, appRolesEnabled: false); if (principal == null) return null; // Side-effect-free read: TryGetConfigurationAsync does NOT persist a default row for a missing // tenant (GetConfigurationAsync would). Authorization role resolution must never create config as // a side effect — otherwise an external delegated/MSP user whose own home tenant is not onboarded // would get a phantom TenantConfiguration row written on their first cross-tenant read. A missing // config simply means EntraAppRolesEnabled = false (the default), so the role result is unchanged. var (config, _) = await _tenantConfigService.TryGetConfigurationAsync(tenantId); return EntraAppRoleResolver.Resolve(state, tableRole, principal.GetAppRoles(), config.EntraAppRolesEnabled); } /// /// Platform-wide cross-tenant READ: admits GlobalAdmin and the read-only GlobalReader. The /// resolved role string flows onto RequestContext so the function still distinguishes GA from /// Reader (e.g. for any in-function write gate). /// private async Task EvaluateGlobalReadOrAdminAsync( ClaimsPrincipal? principal, string? upn, string userIdentifier) { if (string.IsNullOrEmpty(upn)) return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims"); var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal)); if (globalRole != null) return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope"); 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( ClaimsPrincipal? principal, string? upn, string userIdentifier) { if (string.IsNullOrEmpty(upn)) return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims"); var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal)); if (globalRole != null) return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope"); // Full identity (upn + tid + oid): the scope resolves only for the bound identity, and the home // tenant (JWT tid) gates it — MSP seats require a Pro home tenant. var scope = await _delegatedAdminService.GetScopeAsync(AdminIdentity.FromPrincipal(principal)); if (!scope.IsEmpty) return CatalogDecisionResult.Allow( userIdentifier, Constants.DelegatedRoles.DelegatedReader, "DelegatedSubset", allowedTenantIds: scope.TenantIds); return CatalogDecisionResult.Deny(userIdentifier, "NonGlobal", "NotGlobalOrDelegated"); } private async Task EvaluateGlobalAdminOnlyAsync( ClaimsPrincipal? principal, string? upn, string userIdentifier) { if (string.IsNullOrEmpty(upn)) return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims"); if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal))) return CatalogDecisionResult.Allow(userIdentifier, Constants.GlobalRoles.GlobalAdmin, "IsGA"); return CatalogDecisionResult.Deny(userIdentifier, "NonGA", "NotGlobalAdmin"); } /// /// Outcome of : either an allow carrying the resolved /// , or a deny carrying the HTTP status + error payload that /// writes. Internal so the middleware decision is unit-testable. /// internal sealed class PolicyResult { public bool Allowed { get; private init; } public RequestContext? Context { get; private init; } public int StatusCode { get; private init; } public string? ErrorCode { get; private init; } public string? ErrorMessage { get; private init; } public string UserIdentifier { get; private init; } = "anonymous"; public string UserRole { get; private init; } = "N/A"; public string LogReason { get; private init; } = ""; public static PolicyResult Allow(RequestContext context, string user, string role) => new() { Allowed = true, Context = context, UserIdentifier = user, UserRole = role, LogReason = "Allow" }; public static PolicyResult Deny(int statusCode, string errorCode, string errorMessage, string user, string role, string logReason) => new() { Allowed = false, StatusCode = statusCode, ErrorCode = errorCode, ErrorMessage = errorMessage, UserIdentifier = user, UserRole = role, LogReason = logReason, }; } /// /// Result of evaluating a catalog policy against the current request context. /// private sealed class CatalogDecisionResult { public bool IsAllowed { get; private init; } public string UserIdentifier { get; private init; } = "anonymous"; public string UserRole { get; private init; } = "N/A"; public string Reason { get; private init; } = ""; /// /// The caller's independently-resolved tenant role ("Admin"/"Operator"/"Viewer"), when known. /// Carried SEPARATELY from so additive semantics survive: a caller admitted /// via a platform-role branch (e.g. GlobalReader) still surfaces their own-tenant role here, so /// RequestContext.IsTenantAdmin is correct for a user who is both GlobalReader and TenantAdmin. /// Null when not resolved (the allow-path then falls back to ). /// public string? TenantRole { get; private init; } /// /// 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 }; } }