mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
2fee50276e
- New EndpointPolicy.TenantAdminOrOperator (Admin/Operator/GA, no Viewer) with
middleware evaluator; POST sessions/{sessionId}/actions moves to this tier so
troubleshooting operators can queue request_diagnostics ("Collect Logs").
- QueueSessionActionFunction re-gates per type: terminate_session and
rotate_config stay Admin/GA-only (403 for operators).
- GET config/{tenantId}/feature-flags exposes diagnosticsUploadConfigured so
members below Admin can see whether an on-demand upload would work.
- Offboarding cascade: hosted diagnostics deletion now sweeps the session
prefix {tenantId}/AgentDiagnostics-{sessionId}- (plus the manifest-named
blob) so on-demand packages overwritten on the Sessions row by the terminal
package no longer survive deletion. CustomerSas branch unchanged.
- Notification dismiss routes deliberately NOT migrated: dismissal is still
tenant-shared; operators would clear admin-tier notifications they cannot see.
Tests: middleware role matrix on the actions route, per-type gate matrix,
feature-flag matrix, prefix-sweep + real hosted-branch cascade tests.
Backend suite 3253/3253.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
671 lines
38 KiB
C#
671 lines
38 KiB
C#
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 Microsoft.AspNetCore.Http;
|
|
using Microsoft.Azure.Functions.Worker;
|
|
using Microsoft.Azure.Functions.Worker.Middleware;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Middleware;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
|
{
|
|
/// <summary>
|
|
/// 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 <c>sub</c>, 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.
|
|
/// </summary>
|
|
internal const string UnidentifiedCallerBucket = "unidentified-token";
|
|
|
|
private readonly ILogger<PolicyEnforcementMiddleware> _logger;
|
|
private readonly GlobalAdminService _globalAdminService;
|
|
private readonly DelegatedAdminService _delegatedAdminService;
|
|
private readonly TenantAdminsService _tenantAdminsService;
|
|
private readonly TenantConfigurationService _tenantConfigService;
|
|
|
|
public PolicyEnforcementMiddleware(
|
|
ILogger<PolicyEnforcementMiddleware> 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 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="Invoke"/> so the
|
|
/// full decision — including the additive tenant-role resolution that drives RequestContext.IsTenantAdmin —
|
|
/// is unit-testable without faking the worker's HttpContext feature. <see cref="Invoke"/> is a thin
|
|
/// adapter that reads the request, calls this, and writes the response.
|
|
/// </summary>
|
|
internal async Task<PolicyResult> 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<string>? allowedTenantIds = null;
|
|
var isScopedRoute = catalogEntry.TenantScoping is TenantScoping.RouteParam or TenantScoping.QueryParam;
|
|
if (!hasGlobalScope && !string.IsNullOrEmpty(upn) && isScopedRoute && crossTenant)
|
|
{
|
|
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require an Enterprise home tenant.
|
|
var scope = await _delegatedAdminService.GetScopeAsync(upn, jwtTenantId);
|
|
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");
|
|
}
|
|
|
|
// 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,
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <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).
|
|
///
|
|
/// 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 && 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
|
|
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<CatalogDecisionResult> 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(upn, userIdentifier);
|
|
|
|
case EndpointPolicy.GlobalReadOrDelegatedSubset:
|
|
return await EvaluateGlobalReadOrDelegatedSubsetAsync(tenantId, upn, userIdentifier);
|
|
|
|
case EndpointPolicy.GlobalAdminOnly:
|
|
return await EvaluateGlobalAdminOnlyAsync(upn, userIdentifier);
|
|
|
|
default:
|
|
return CatalogDecisionResult.Deny(userIdentifier, "N/A", $"UnknownPolicy:{entry.Policy}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task<CatalogDecisionResult> 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(upn);
|
|
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<CatalogDecisionResult> 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(upn);
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
private async Task<CatalogDecisionResult> 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(upn);
|
|
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<CatalogDecisionResult> 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(upn))
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task<CatalogDecisionResult> 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(upn))
|
|
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<CatalogDecisionResult> 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(upn))
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task<MemberRoleInfo?> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
private async Task<CatalogDecisionResult> EvaluateGlobalReadOrAdminAsync(
|
|
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");
|
|
|
|
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? jwtTenantId, 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");
|
|
|
|
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require an Enterprise home tenant.
|
|
var scope = await _delegatedAdminService.GetScopeAsync(upn, jwtTenantId);
|
|
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)
|
|
{
|
|
if (string.IsNullOrEmpty(upn))
|
|
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
|
|
|
|
if (await _globalAdminService.IsGlobalAdminAsync(upn))
|
|
return CatalogDecisionResult.Allow(userIdentifier, Constants.GlobalRoles.GlobalAdmin, "IsGA");
|
|
|
|
return CatalogDecisionResult.Deny(userIdentifier, "NonGA", "NotGlobalAdmin");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Outcome of <see cref="DecideAsync"/>: either an allow carrying the resolved
|
|
/// <see cref="RequestContext"/>, or a deny carrying the HTTP status + error payload that
|
|
/// <see cref="Invoke"/> writes. Internal so the middleware decision is unit-testable.
|
|
/// </summary>
|
|
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,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of evaluating a catalog policy against the current request context.
|
|
/// </summary>
|
|
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; } = "";
|
|
|
|
/// <summary>
|
|
/// The caller's independently-resolved tenant role ("Admin"/"Operator"/"Viewer"), when known.
|
|
/// Carried SEPARATELY from <see cref="UserRole"/> 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 <see cref="UserRole"/>).
|
|
/// </summary>
|
|
public string? TenantRole { get; private init; }
|
|
|
|
/// <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 };
|
|
}
|
|
}
|