using System.Collections.Generic;
using System.Net;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Infrastructure;
///
/// Authentication and authorization endpoints
///
public class AuthFunction
{
private readonly ILogger _logger;
private readonly GlobalAdminService _globalAdminService;
private readonly DelegatedAdminService _delegatedAdminService;
private readonly TenantConfigurationService _tenantConfigService;
private readonly TenantAdminsService _tenantAdminsService;
private readonly IMetricsRepository _metricsRepo;
private readonly PreviewWhitelistService _previewWhitelistService;
private readonly TelegramNotificationService _telegramNotificationService;
private readonly GlobalNotificationService _globalNotificationService;
private readonly McpUserService _mcpUserService;
private readonly Services.Activation.ITenantAutoApproveEnqueuer _tenantAutoApproveEnqueuer;
private readonly EntraAppRegistry _appRegistry;
private readonly AdminIdentityResolver _identityResolver;
public AuthFunction(
ILogger logger,
GlobalAdminService globalAdminService,
DelegatedAdminService delegatedAdminService,
TenantConfigurationService tenantConfigService,
TenantAdminsService tenantAdminsService,
IMetricsRepository metricsRepo,
PreviewWhitelistService previewWhitelistService,
TelegramNotificationService telegramNotificationService,
GlobalNotificationService globalNotificationService,
McpUserService mcpUserService,
Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer,
EntraAppRegistry appRegistry,
AdminIdentityResolver identityResolver)
{
_logger = logger;
_identityResolver = identityResolver;
_globalAdminService = globalAdminService;
_delegatedAdminService = delegatedAdminService;
_tenantConfigService = tenantConfigService;
_tenantAdminsService = tenantAdminsService;
_metricsRepo = metricsRepo;
_previewWhitelistService = previewWhitelistService;
_telegramNotificationService = telegramNotificationService;
_globalNotificationService = globalNotificationService;
_mcpUserService = mcpUserService;
_tenantAutoApproveEnqueuer = tenantAutoApproveEnqueuer;
_appRegistry = appRegistry;
}
///
/// GET /api/auth/me
/// Returns information about the currently authenticated user
///
[Function("GetCurrentUser")]
[Authorize]
public async Task GetCurrentUser(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "auth/me")] HttpRequestData req,
FunctionContext context)
{
var principal = context.GetUser();
if (principal == null)
{
_logger.LogWarning("GetCurrentUser - No authentication found");
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
var tenantId = principal.GetTenantId();
var upn = principal.GetUserPrincipalName();
var displayName = principal.GetDisplayName();
var objectId = principal.GetObjectId();
// Validate required claims
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
{
_logger.LogWarning("Missing required claims: tenantId or upn");
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = "Missing required claims" });
return badRequestResponse;
}
// --- Parallel data fetch: all independent queries run concurrently ---
// Fail-fast: if any fetch throws, AggregateException propagates → Azure Functions returns 500.
// This is intentional — all 6 queries are required for the auth decision.
// Non-creating read: do NOT auto-persist a default config row just because someone authenticated.
// A delegated-only external MSP login must not phantom-onboard its home tenant (see the side-effect
// gate below). A genuine first-user still gets their config created by HandleNewTenantDomainAsync.
var tenantConfigTask = _tenantConfigService.TryGetConfigurationAsync(tenantId);
// Cross-tenant roles resolve on the FULL identity (upn + tid + oid) against the UPN's identity
// binding — never on the UPN alone. Null (no oid in the token) ⇒ no platform/delegated role.
var identity = AdminIdentity.FromPrincipal(principal);
var globalRoleTask = _globalAdminService.GetGlobalRoleAsync(identity);
var delegatedScopeTask = _delegatedAdminService.GetScopeAsync(identity);
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(tenantId);
var membershipTask = _tenantAdminsService.GetTableMembershipAsync(tenantId, upn);
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, tenantId, objectId);
var existingAdminsTask = _tenantAdminsService.GetTenantAdminsAsync(tenantId);
await Task.WhenAll(tenantConfigTask, globalRoleTask, delegatedScopeTask, isApprovedTask,
membershipTask, mcpCheckTask, existingAdminsTask);
var (tenantConfig, _) = tenantConfigTask.Result;
var globalRole = globalRoleTask.Result;
var isGlobalAdmin = globalRole == Constants.GlobalRoles.GlobalAdmin;
var isGlobalReader = globalRole == Constants.GlobalRoles.GlobalReader;
// The tenants this caller manages as a delegated ("MSP") admin (empty for non-delegated users).
// Surfaced to the web app so it can show fleet/switcher UI and bound it to this set.
var delegatedTenantIds = delegatedScopeTask.Result.TenantIds;
var isApproved = isApprovedTask.Result;
var (tableState, tableRole) = membershipTask.Result;
var mcpCheck = mcpCheckTask.Result;
var existingAdmins = existingAdminsTask.Result;
// Reconcile the TenantAdmins table state with any Entra app-role claim. An enabled row
// wins; a disabled row is an explicit deny (claim ignored); only a missing row falls back
// to the claim, and only when the tenant has app-roles enabled.
var memberRole = EntraAppRoleResolver.Resolve(
tableState, tableRole, principal.GetAppRoles(), tenantConfig.EntraAppRolesEnabled);
// --- Side-effects that don't affect the auth decision ---
// Run first-login onboarding side-effects (domain/OnboardedBy write + "new tenant signup"
// notification, auto-re-enable, and the implicit config-row persistence) ONLY for a genuine
// home-tenant participant. A delegated-only external MSP caller — who bypassed the preview gate to
// manage OTHER tenants and has no stake in this home tenant (no membership, no platform role) — is
// excluded, so a read-only MSP login does not phantom-onboard its home tenant (false signup, default
// config row). A delegated user who legitimately participates in their own tenant (member/global)
// still onboards normally; the handlers self-gate when the config already exists.
var isDelegated = delegatedTenantIds.Count > 0;
var isHomeTenantParticipant = !isDelegated || isGlobalAdmin || isGlobalReader || memberRole != null;
// Which app registration minted this login's token (dual app-reg window) — drives the
// onboarding homing decision and the per-tenant last-seen provenance below.
var tokenAudience = principal.GetAudience();
if (isHomeTenantParticipant)
{
await HandleNewTenantDomainAsync(tenantConfig, tenantId, upn, tokenAudience);
await HandleAutoReEnableAsync(tenantConfig, tenantId);
await HandleAuthClientIdTrackingAsync(tenantConfig, tenantId, tokenAudience);
}
// --- Pure decision logic (tested by AuthFunctionTests) ---
var decision = BuildAuthResult(
tenantConfig, isGlobalAdmin, isGlobalReader, isApproved,
memberRole, mcpCheck, existingAdmins.Count > 0,
tenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty,
delegatedTenantIds,
homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary");
if (!decision.IsSuccess)
{
if (decision.StatusCode == HttpStatusCode.Forbidden)
{
var bodyType = decision.Body.GetType();
var errorProp = bodyType.GetProperty("error");
var errorValue = errorProp?.GetValue(decision.Body) as string;
if (errorValue == "TenantSuspended")
_logger.LogWarning("Login attempt for suspended tenant: {TenantId} by user {Upn}", tenantId, upn);
else if (errorValue == "PendingActivation")
_logger.LogInformation("Tenant {TenantId} blocked by activation gate (user: {Upn})", tenantId, upn);
}
var blockedResponse = req.CreateResponse(decision.StatusCode);
await blockedResponse.WriteAsJsonAsync(decision.Body);
return blockedResponse;
}
// --- Post-decision side-effects ---
await HandlePostDecisionSideEffectsAsync(decision, tenantId, upn, displayName, objectId);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(decision.Body);
return response;
}
///
/// GET /api/auth/is-global-admin
/// Checks if the current user is a Global Admin
///
[Function("IsGlobalAdmin")]
[Authorize]
public async Task IsGlobalAdmin(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "auth/is-global-admin")] HttpRequestData req,
FunctionContext context)
{
var principal = context.GetUser();
if (principal == null)
{
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
var upn = principal.GetUserPrincipalName();
var isAdmin = await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal));
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { isGlobalAdmin = isAdmin, upn });
return response;
}
///
/// GET /api/auth/global-admins
/// Lists all Global Admins (only accessible by Global Admins)
///
[Function("GetGlobalAdmins")]
[Authorize]
public async Task GetGlobalAdmins(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "auth/global-admins")] HttpRequestData req,
FunctionContext context)
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
var admins = await _globalAdminService.GetAllGlobalAdminsAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { admins });
return response;
}
///
/// POST /api/auth/global-admins
/// Adds a new Global Admin (only accessible by existing Global Admins)
///
[Function("AddGlobalAdmin")]
[Authorize]
public async Task AddGlobalAdmin(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "auth/global-admins")] HttpRequestData req,
FunctionContext context)
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
var principal = context.GetUser();
var currentUpn = principal?.GetUserPrincipalName();
// Parse request body
var body = await req.ReadFromJsonAsync();
if (body == null || string.IsNullOrWhiteSpace(body.Upn))
{
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = "UPN is required" });
return badRequestResponse;
}
// The row is inert until the UPN is bound to the identity that may use it. The home tenant is resolved
// automatically (sign-in history, then UPN domain → onboarded tenant); the body may override it.
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
if (bindingError != null)
{
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = bindingError });
return badRequestResponse;
}
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, body.Upn, body.HomeTenantId, body.ObjectId);
if (identity == null)
{
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
return unresolved;
}
GlobalAdminEntity newAdmin;
try
{
newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!, identity.Value.TenantId, identity.Value.ObjectId);
}
catch (IdentityBindingConflictException ex)
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new { error = ex.Message });
return conflict;
}
_logger.LogInformation($"Global Admin added: {body.Upn} by {currentUpn}");
var response = req.CreateResponse(HttpStatusCode.Created);
await response.WriteAsJsonAsync(new { admin = newAdmin });
return response;
}
///
/// DELETE /api/auth/global-admins/{upn}
/// Removes a Global Admin (only accessible by existing Global Admins)
///
[Function("RemoveGlobalAdmin")]
[Authorize]
public async Task RemoveGlobalAdmin(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "auth/global-admins/{upn}")] HttpRequestData req,
string upn,
FunctionContext context)
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
var principal = context.GetUser();
var currentUpn = principal?.GetUserPrincipalName();
// Prevent self-removal
if (upn.Equals(currentUpn, StringComparison.OrdinalIgnoreCase))
{
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = "You cannot remove yourself as a Global Admin" });
return badRequestResponse;
}
await _globalAdminService.RemoveGlobalAdminAsync(upn);
_logger.LogInformation($"Global Admin removed: {upn} by {currentUpn}");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "Global Admin removed successfully" });
return response;
}
///
/// If the tenant has no domain name yet, extracts it from the UPN, persists it,
/// and fires best-effort notifications (Telegram + global notification).
///
internal async Task HandleNewTenantDomainAsync(
TenantConfiguration tenantConfig, string tenantId, string upn, string? tokenAudience = null)
{
if (!string.IsNullOrEmpty(tenantConfig.DomainName) || string.IsNullOrEmpty(upn))
return;
var domain = ExtractDomainFromUpn(upn);
if (string.IsNullOrEmpty(domain))
return;
_logger.LogInformation("Setting domain name for tenant {TenantId}: {Domain}", tenantId, domain);
tenantConfig.DomainName = domain;
tenantConfig.UpdatedBy = upn;
// OnboardedBy is immutable once set: this is the only place that writes it, gated
// on the same "DomainName is empty" condition (first-ever user login for the tenant).
// Downstream auto-promote (PreviewWhitelistFunction) reads OnboardedBy so background
// sync jobs that mutate UpdatedBy cannot leak sentinel strings into TenantAdmins.
if (string.IsNullOrWhiteSpace(tenantConfig.OnboardedBy))
tenantConfig.OnboardedBy = upn;
// Dual app-reg window: home a NEW tenant on the primary app ONLY when its first login
// actually arrived via the primary app. A first login via the legacy app leaves the field
// null (= legacy) — keeps the "null = legacy" invariant clean and never routes a tenant
// to an app it hasn't consented to. Seed the last-seen provenance in the same write.
var onboardingClientId = EntraAppRegistry.NormalizeAudience(tokenAudience);
if (onboardingClientId != null)
{
if (_appRegistry.IsPrimary(onboardingClientId))
tenantConfig.HomedAppClientId = onboardingClientId;
tenantConfig.LastAuthClientId = onboardingClientId;
tenantConfig.LastAuthClientIdSince = DateTime.UtcNow;
}
try
{
await _tenantConfigService.SaveConfigurationAsync(tenantConfig, "auth", "first-login domain seed");
}
catch (Exception ex)
{
// Best-effort side-effect: SaveConfigurationAsync now throws on a failed persist
// (fail-loud for config endpoints), but a storage blip must not 500 the login.
// The handler self-gates on DomainName being empty, so the next login retries —
// skip the notifications too so they only fire once the write actually stuck.
_logger.LogWarning(ex, "First-login domain write failed for tenant {TenantId} — will retry on next login", tenantId);
return;
}
// Fire-and-forget: Telegram
_ = _telegramNotificationService.SendNewTenantSignupAsync(tenantId, upn)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget Telegram notification failed for tenant {TenantId}", tenantId),
TaskContinuationOptions.OnlyOnFaulted);
// Fire-and-forget: Global notification
_ = _globalNotificationService.CreateNotificationAsync(
"preview_signup",
"New Tenant Signup",
$"Tenant {tenantId} ({domain}), UPN: {upn}",
href: $"/admin/tenants/management?tenantId={Uri.EscapeDataString(tenantId)}");
// Fire-and-forget: delayed auto-approve. Enqueued unconditionally — the worker is the
// single decision point (checks AutoApproveNewTenants at processing time). A failed
// enqueue degrades to manual approval and must never fail the login.
_ = _tenantAutoApproveEnqueuer.EnqueueAsync(
new Services.Activation.TenantAutoApproveEnvelope
{
TenantId = tenantId,
SignupUpn = upn,
EnqueuedAtUtc = DateTime.UtcNow
},
Services.Activation.TenantAutoApproveEnvelope.ActivationDelay)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget auto-approve enqueue failed for tenant {TenantId}", tenantId),
TaskContinuationOptions.OnlyOnFaulted);
}
///
/// Pure observability for the app-reg migration: records which app registration this
/// tenant's logins actually arrive through (LastAuthClientId + since-when).
/// Write-on-change only — the common case (same app as last time) is a no-op, so this adds
/// zero table writes to steady-state logins. Best-effort: a failed persist must never break
/// auth/me; the next login that still differs retries. Gated on a non-empty DomainName so a
/// pre-onboarding login (row not persisted yet) can't create a phantom config row.
///
internal async Task HandleAuthClientIdTrackingAsync(
TenantConfiguration tenantConfig, string tenantId, string? tokenAudience)
{
var clientId = EntraAppRegistry.NormalizeAudience(tokenAudience);
if (clientId == null || string.IsNullOrEmpty(tenantConfig.DomainName))
return;
if (string.Equals(tenantConfig.LastAuthClientId, clientId, StringComparison.OrdinalIgnoreCase))
return;
_logger.LogInformation(
"Tenant {TenantId} logins now arrive via app registration {ClientId} (was: {Previous})",
tenantId, clientId, tenantConfig.LastAuthClientId ?? "(none recorded)");
try
{
// Mutate a cache-BYPASSING read, not the (up to 5 min stale on other instances)
// cached view: this write persists the WHOLE entity, and the login that triggers it
// is typically the very first one AFTER an app-homing flip — blind-saving the stale
// view here would silently revert HomedAppClientId (same failure class as the
// 2026-07-31 PUT revert incident). Change-detect above stays on the cached view as a
// cheap pre-filter; the fresh read re-checks before writing.
var fresh = await _tenantConfigService.GetConfigurationFreshAsync(tenantId);
if (fresh == null || string.Equals(fresh.LastAuthClientId, clientId, StringComparison.OrdinalIgnoreCase))
return;
fresh.LastAuthClientId = clientId;
fresh.LastAuthClientIdSince = DateTime.UtcNow;
await _tenantConfigService.SaveConfigurationAsync(fresh, "auth", "auth client-id tracking");
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Auth client-id tracking persist failed for tenant {TenantId} — will retry on a later login", tenantId);
}
}
///
/// If the tenant's suspension has expired (Disabled=true but DisabledUntil is past),
/// clears the disabled state and persists the change.
/// MUST run before BuildAuthResult because it mutates tenantConfig.Disabled.
///
internal async Task HandleAutoReEnableAsync(
TenantConfiguration tenantConfig, string tenantId)
{
if (!tenantConfig.Disabled || tenantConfig.IsCurrentlyDisabled())
return;
_logger.LogInformation(
"Tenant {TenantId} auto-re-enabled: DisabledUntil ({DisabledUntil}) has expired",
tenantId, tenantConfig.DisabledUntil?.ToString("o"));
tenantConfig.Disabled = false;
tenantConfig.DisabledReason = null;
tenantConfig.DisabledUntil = null;
tenantConfig.UpdatedBy = "System (auto-re-enable)";
try
{
await _tenantConfigService.SaveConfigurationAsync(tenantConfig, "auth", "auto-re-enable after suspension expiry");
}
catch (Exception ex)
{
// Best-effort: the in-memory flip above already lets THIS login through; a failed
// persist just means the next login re-runs the auto-re-enable. Must not 500 auth/me.
_logger.LogWarning(ex, "Auto-re-enable persist failed for tenant {TenantId} — will retry on next login", tenantId);
}
}
///
/// Executes post-decision side-effects: auto-admin assignment (awaited)
/// and metrics recording (fire-and-forget).
///
internal async Task HandlePostDecisionSideEffectsAsync(
AuthDecisionResult decision, string tenantId, string upn,
string? displayName, string? objectId)
{
if (decision.NeedsAutoAdmin)
{
_logger.LogInformation("First user login for tenant {TenantId}: {Upn} - Auto-assigning as admin", tenantId, upn);
await _tenantAdminsService.AddTenantAdminAsync(tenantId, upn, "System");
}
_ = _metricsRepo.RecordUserLoginAsync(tenantId, upn, displayName, objectId)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget RecordUserLoginAsync failed"),
TaskContinuationOptions.OnlyOnFaulted);
}
///
/// Pure decision logic for auth/me — no I/O, fully testable.
/// Takes all pre-fetched data and returns the auth decision.
///
internal static AuthDecisionResult BuildAuthResult(
TenantConfiguration tenantConfig,
bool isGlobalAdmin,
bool isGlobalReader,
bool isPreviewApproved,
MemberRoleInfo? memberRole,
McpAccessCheckResult mcpCheck,
bool hasTenantAdmins,
string tenantId, string upn, string displayName, string objectId,
IReadOnlyCollection? delegatedTenantIds = null,
string homedApp = "primary")
{
// A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so —
// like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant
// is not on the waitlist. Empty/null for non-delegated callers.
var managedTenantIds = delegatedTenantIds ?? System.Array.Empty();
var isDelegated = managedTenantIds.Count > 0;
// Gate 1: Suspended tenant
if (tenantConfig.IsCurrentlyDisabled())
{
return AuthDecisionResult.Blocked(HttpStatusCode.Forbidden, new
{
error = "TenantSuspended",
message = !string.IsNullOrEmpty(tenantConfig.DisabledReason)
? tenantConfig.DisabledReason
: "Your tenant has been suspended. Please contact support for more information.",
disabledUntil = tenantConfig.DisabledUntil?.ToString("o"),
contactSupport = true
});
}
// Gate 2: Activation gate (platform roles — GlobalAdmin / GlobalReader — and delegated MSP
// admins bypass). Wording is mode-neutral on purpose: with auto-approve on, activation
// completes within a couple of minutes; with it off, the tenant waits for manual approval —
// the message must hold for both without plumbing the flag into this pure method. The web
// app accepts the legacy "PrivatePreview" code too, so backend and web deploy in any order.
if (!isGlobalAdmin && !isGlobalReader && !isDelegated && !isPreviewApproved)
{
return AuthDecisionResult.Blocked(HttpStatusCode.Forbidden, new
{
error = "PendingActivation",
message = "Autopilot Monitor is free to use \u2014 every new organization goes through a short activation step. Your tenant is being activated; this usually completes within a couple of minutes."
});
}
// Determine admin status: auto-admin if first user (no existing admins and no role yet).
// needsAutoAdmin keys off the absence of ANY effective role (table or claim) so a
// claim-derived Admin/Operator in a claim-only tenant is NOT written into the table.
// GlobalReader semantics are ADDITIVE (the role adds cross-tenant read; it never removes a
// user's independent tenant-role write rights). Auto-admin, however, is not an existing right —
// it SILENTLY grants write on first login. We decline that for a read-only-flagged identity:
// it removes nothing, and a GlobalReader who should also administer their tenant can be added
// to TenantAdmins explicitly. (A pure GlobalReader stays read-only everywhere via the write
// evaluators denying a roleless caller.)
// Auto-admin is NOT an existing right — it SILENTLY grants write on first login. Decline it for any
// identity that reached this point on a cross-tenant ticket rather than as a genuine home-tenant
// first-user: a read-only GlobalReader, AND a delegated ("MSP") admin. A delegated caller logged in
// to manage OTHER tenants and bypassed the preview gate; auto-admining their own (possibly
// non-customer) home tenant would convert a read-only delegated assignment into write authority over
// it. A delegated user who legitimately administers their own tenant has a TenantAdmins row (memberRole
// != null), so this only suppresses the silent grant, never an existing right.
bool isTenantAdmin = memberRole?.Role == Constants.TenantRoles.Admin;
bool needsAutoAdmin = memberRole == null && !hasTenantAdmins && !isGlobalReader && !isDelegated;
if (needsAutoAdmin)
{
isTenantAdmin = true;
}
// After auto-admin, re-derive role: the auto-admin gets Admin role,
// otherwise use the fetched memberRole.
string? role = needsAutoAdmin ? Constants.TenantRoles.Admin : memberRole?.Role;
bool canManageBootstrapTokens = needsAutoAdmin || (memberRole?.CanManageBootstrapTokens ?? false);
return AuthDecisionResult.Success(new
{
tenantId,
upn,
displayName,
objectId,
isGlobalAdmin,
isGlobalReader,
isTenantAdmin,
// Delegated ("MSP") scope: the OTHER tenants this caller may manage (read-only this phase) and a
// convenience flag. The web app uses these for fleet/switcher UI, bounded to this set.
isDelegated,
delegatedTenantIds = managedTenantIds,
role,
canManageBootstrapTokens,
hasMcpAccess = mcpCheck.IsAllowed,
// Dual app-reg window: which app registration this tenant is homed on. The web app
// stores this per browser (localStorage) so the NEXT login uses the right app —
// the deferred, no-roundtrip learning mechanism of the parallel operation model.
homedApp,
// EFFECTIVE availability flags (drive sidebar/section visibility): bootstrap is
// included in Pro, Unrestricted Mode requires Pro + the GA on-request gate.
bootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow),
unrestrictedModeEnabled =
FeatureEntitlementCatalog.Get(TenantEntitlementService.ResolveEdition(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable
&& tenantConfig.UnrestrictedModeEnabled
}, needsAutoAdmin);
}
///
/// Extracts domain name from UPN (e.g., user@contoso.com -> contoso.com)
///
internal static string ExtractDomainFromUpn(string upn)
{
if (string.IsNullOrEmpty(upn))
return string.Empty;
var atIndex = upn.IndexOf('@');
if (atIndex > 0 && atIndex < upn.Length - 1)
{
return upn.Substring(atIndex + 1);
}
return string.Empty;
}
}
///
/// Result of the pure auth decision logic — no HTTP concerns.
///
internal class AuthDecisionResult
{
public HttpStatusCode StatusCode { get; init; }
public object Body { get; init; } = default!;
public bool IsSuccess => StatusCode == HttpStatusCode.OK;
/// True when the user should be auto-promoted to tenant admin (first user).
public bool NeedsAutoAdmin { get; init; }
public static AuthDecisionResult Success(object body, bool needsAutoAdmin = false) => new()
{
StatusCode = HttpStatusCode.OK,
Body = body,
NeedsAutoAdmin = needsAutoAdmin
};
public static AuthDecisionResult Blocked(HttpStatusCode statusCode, object body) => new()
{
StatusCode = statusCode,
Body = body
};
}
public class AddGlobalAdminRequest
{
public string Upn { get; set; } = string.Empty;
/// The grantee's home Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.
public string? HomeTenantId { get; set; }
/// The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.
public string? ObjectId { get; set; }
}