mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
a306cd9e40
Granting a cross-tenant role is "type the UPN" again. AdminIdentityResolver derives the home tenant + object id from sign-in history (UserActivity: both values came from validated tokens, most recent sign-in wins), falls back to the UPN domain of an onboarded tenant (tid only, oid pinned on first sign-in), and never guesses when a UPN was seen under two tenants. Only a never-seen domain yields 422 HomeTenantUnresolved, on which the portal shows a home-tenant picker. - IMetricsRepository.GetSignInIdentitiesByUpnAsync (filtered UserActivity scan, grant-time only) - Grant endpoints: homeTenantId/objectId optional overrides; bindings dropped from the list bodies - Web: identity fields and binding pills removed from the delegated / tenant-group forms; new collapsed "Identity bindings" panel in Tenant Management -> Edit Tenant Configuration (bindings homed in that tenant; inline edit / remove via global/identity-bindings) - Tests: AdminIdentityResolverTests; grant validation now accepts an absent home tenant Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
654 lines
32 KiB
C#
654 lines
32 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Authentication and authorization endpoints
|
|
/// </summary>
|
|
public class AuthFunction
|
|
{
|
|
private readonly ILogger<AuthFunction> _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<AuthFunction> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /api/auth/me
|
|
/// Returns information about the currently authenticated user
|
|
/// </summary>
|
|
[Function("GetCurrentUser")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /api/auth/is-global-admin
|
|
/// Checks if the current user is a Global Admin
|
|
/// </summary>
|
|
[Function("IsGlobalAdmin")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /api/auth/global-admins
|
|
/// Lists all Global Admins (only accessible by Global Admins)
|
|
/// </summary>
|
|
[Function("GetGlobalAdmins")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// POST /api/auth/global-admins
|
|
/// Adds a new Global Admin (only accessible by existing Global Admins)
|
|
/// </summary>
|
|
[Function("AddGlobalAdmin")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> 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<AddGlobalAdminRequest>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// DELETE /api/auth/global-admins/{upn}
|
|
/// Removes a Global Admin (only accessible by existing Global Admins)
|
|
/// </summary>
|
|
[Function("RemoveGlobalAdmin")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// If the tenant has no domain name yet, extracts it from the UPN, persists it,
|
|
/// and fires best-effort notifications (Telegram + global notification).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure observability for the app-reg migration: records which app registration this
|
|
/// tenant's logins actually arrive through (<c>LastAuthClientId</c> + 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes post-decision side-effects: auto-admin assignment (awaited)
|
|
/// and metrics recording (fire-and-forget).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure decision logic for auth/me — no I/O, fully testable.
|
|
/// Takes all pre-fetched data and returns the auth decision.
|
|
/// </summary>
|
|
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<string>? 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<string>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts domain name from UPN (e.g., user@contoso.com -> contoso.com)
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of the pure auth decision logic — no HTTP concerns.
|
|
/// </summary>
|
|
internal class AuthDecisionResult
|
|
{
|
|
public HttpStatusCode StatusCode { get; init; }
|
|
public object Body { get; init; } = default!;
|
|
public bool IsSuccess => StatusCode == HttpStatusCode.OK;
|
|
|
|
/// <summary>True when the user should be auto-promoted to tenant admin (first user).</summary>
|
|
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;
|
|
/// <summary>The grantee's home Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
|
|
public string? HomeTenantId { get; set; }
|
|
/// <summary>The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
|
|
public string? ObjectId { get; set; }
|
|
}
|