mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
e704aa7359
Security finding (CWE-367): the activation wrote the approved row first and the OnboardedBy admin row a few storage round-trips later. In between the tenant was activated with no members, and auth/me seated any roleless caller of that moment as Admin; every member of the customer's Entra tenant could poll auth/me across the auto-approve moment. - TenantApprovalService: uncached pre-check, then the OnboardedBy Admin row (conditional insert, best-effort, fresh config read), then the activation row (still the arbiter), then the welcome mail. The two tables cannot share a batch, so "before" is the guarantee. - AuthFunction.BuildAuthResult: with a promotable OnboardedBy the first-user auto-admin goes to that UPN only; other roleless callers stay roleless. Without one (rows from before the field existed) the first sign-in still becomes the admin. The grant logs at Warning. - Both first-admin writers use IAdminRepository.TryAddTenantMemberAsync (AddEntityAsync, 409 = left untouched) instead of an upsert; TenantAdminsService.TryAddTenantAdminAsync replaces AddTenantAdminAsync. PreviewWhitelistService.IsApprovedFreshAsync is the pre-check. - Dead after this: IsTenantAdminAsync on the service, the repository interface and the table repository, plus its test. - Tests pin the write order, the designated-member guard (unit and auth/me end to end) and the conditional write; each part was mutated back to the old code and failed its tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
943 lines
46 KiB
C#
943 lines
46 KiB
C#
using System.Collections.Generic;
|
|
using System.Net;
|
|
using AutopilotMonitor.Functions.Extensions;
|
|
using AutopilotMonitor.Functions.Functions.Admin;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
using AutopilotMonitor.Functions.Security;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using AutopilotMonitor.Shared.Models.Offboarding;
|
|
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 static readonly LogThrottle ExternalIdentityLog = new(TimeSpan.FromHours(1));
|
|
|
|
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;
|
|
private readonly ISignalRNotificationService _signalRService;
|
|
private readonly AdminConfigurationService _adminConfigService;
|
|
private readonly IOffboardingAuditRepository _offboardingRepo;
|
|
private readonly Services.Push.PushOwnerSignInService? _pushSignIn;
|
|
private readonly OpsEventService? _opsEvents;
|
|
|
|
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,
|
|
ISignalRNotificationService signalRService,
|
|
AdminConfigurationService adminConfigService,
|
|
IOffboardingAuditRepository offboardingRepo,
|
|
Services.Push.PushOwnerSignInService? pushSignIn = null,
|
|
OpsEventService? opsEvents = null)
|
|
{
|
|
_logger = logger;
|
|
_pushSignIn = pushSignIn;
|
|
_opsEvents = opsEvents;
|
|
_offboardingRepo = offboardingRepo;
|
|
_adminConfigService = adminConfigService;
|
|
_identityResolver = identityResolver;
|
|
_signalRService = signalRService;
|
|
_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");
|
|
return await req.BadRequestAsync("Missing required claims");
|
|
}
|
|
|
|
// --- Parallel data fetch: all independent queries run concurrently ---
|
|
// Fail-fast: if any fetch throws, AggregateException propagates → Azure Functions returns 500.
|
|
// This is intentional — the auth-decision queries are required; the What's-new marker read
|
|
// is fail-soft inside the repository because it is only a UX convenience.
|
|
// 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);
|
|
// The caller's row and whether the tenant has any member come from ONE read: the first-user
|
|
// auto-admin decision must never combine two states of the TenantAdmins partition.
|
|
var membershipTask = _tenantAdminsService.GetMembershipSnapshotAsync(tenantId, upn);
|
|
// App roles ride along so the AllMembers path sees a claim-derived member the same way the policy
|
|
// middleware does (the table-vs-claim reconciliation happens inside the shared role resolver).
|
|
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, tenantId, objectId, principal.GetAppRoles());
|
|
var whatsNewSeenTask = _metricsRepo.GetUserWhatsNewSeenAsync(tenantId, upn);
|
|
|
|
await Task.WhenAll(tenantConfigTask, globalRoleTask, delegatedScopeTask, isApprovedTask,
|
|
membershipTask, mcpCheckTask, whatsNewSeenTask);
|
|
|
|
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, tenantHasMembers) = membershipTask.Result;
|
|
var mcpCheck = mcpCheckTask.Result;
|
|
var whatsNewSeen = whatsNewSeenTask.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.
|
|
// An external identity (a guest: this tid is not its home) never participates, whatever it was
|
|
// granted: its home domain would become this tenant's DomainName, which resolves cross-tenant grants,
|
|
// and its UPN the OnboardedBy that activation promotes to Tenant Admin.
|
|
var isDelegated = delegatedTenantIds.Count > 0;
|
|
var isExternalIdentity = principal.IsExternalIdentity();
|
|
var isHomeTenantParticipant = !isExternalIdentity
|
|
&& (!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();
|
|
// The get-started CTA is inert until its Terms + DPA tick is set; the portal forwards that
|
|
// sign-in path here so the onboarding records the acceptance instead of asking again.
|
|
var signupConsent = string.Equals(req.Query?["signupConsent"], "1", StringComparison.Ordinal);
|
|
if (isHomeTenantParticipant)
|
|
{
|
|
await HandleNewTenantDomainAsync(tenantConfig, tenantId, upn, tokenAudience, signupConsent);
|
|
await HandleAutoReEnableAsync(tenantConfig, tenantId);
|
|
await HandleAuthClientIdTrackingAsync(tenantConfig, tenantId, tokenAudience);
|
|
}
|
|
else if (isExternalIdentity && string.IsNullOrEmpty(tenantConfig.DomainName)
|
|
&& ExternalIdentityLog.ShouldLog($"{tenantId}|{objectId}"))
|
|
{
|
|
// The activation page polls auth/me, so this is throttled per caller.
|
|
_logger.LogWarning(
|
|
"External identity {Upn} signed in to tenant {TenantId}, which no member has onboarded yet; it does not onboard it",
|
|
upn, tenantId);
|
|
}
|
|
|
|
// --- Pure decision logic (tested by AuthFunctionTests) ---
|
|
var decision = BuildAuthResult(
|
|
tenantConfig, isGlobalAdmin, isGlobalReader, isApproved,
|
|
memberRole, mcpCheck, tenantHasMembers,
|
|
tenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty,
|
|
delegatedTenantIds,
|
|
homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary",
|
|
whatsNewSeenPlatformUtc: whatsNewSeen.PlatformUtc,
|
|
whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc,
|
|
mcpClientRegistrationEnabled: (await _adminConfigService.GetConfigurationAsync())?.McpClientRegistrationEnabled ?? false,
|
|
isExternalIdentity: isExternalIdentity);
|
|
|
|
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 ---
|
|
// The push stamp records the app-role claim's role only where no TenantAdmins row decides.
|
|
await HandlePostDecisionSideEffectsAsync(decision, tenantId, upn, displayName, objectId,
|
|
pushClaimRole: tableState == TableMemberState.NotPresent ? memberRole?.Role : null,
|
|
isGlobalAdmin: isGlobalAdmin);
|
|
|
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
|
await response.WriteAsJsonAsync(decision.Body);
|
|
return response;
|
|
}
|
|
|
|
/// <summary>
|
|
/// PUT /api/auth/me/whats-new-seen
|
|
/// Marks the caller's What's-new channel as seen.
|
|
/// </summary>
|
|
[Function("MarkWhatsNewSeen")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> MarkWhatsNewSeen(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "auth/me/whats-new-seen")] HttpRequestData req,
|
|
FunctionContext context)
|
|
{
|
|
var principal = context.GetUser();
|
|
if (principal == null)
|
|
return req.CreateResponse(HttpStatusCode.Unauthorized);
|
|
|
|
var tenantId = principal.GetTenantId();
|
|
var upn = principal.GetUserPrincipalName();
|
|
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
|
|
return await req.BadRequestAsync("Missing required claims");
|
|
|
|
var read = await req.ReadAsync<WhatsNewSeenRequest>();
|
|
if (read.Error != null) return read.Error;
|
|
|
|
if (!TryNormalizeWhatsNewSeen(read.Value, DateTime.UtcNow, out var channel, out var seenUtc, out var error))
|
|
return await req.BadRequestAsync(error);
|
|
|
|
await _metricsRepo.MarkUserWhatsNewSeenAsync(tenantId, upn, channel, seenUtc);
|
|
return req.CreateResponse(HttpStatusCode.NoContent);
|
|
}
|
|
|
|
/// <summary>
|
|
/// POST /api/auth/dpa-acceptance
|
|
/// Records that the caller accepted the Terms + DPA for their own tenant, which clears the
|
|
/// portal's acceptance dialog (<see cref="TenantConfiguration.DpaAcceptancePending"/>). Only the
|
|
/// caller's home tenant is ever written. Nothing pending (already accepted, or a tenant that was
|
|
/// never asked) is a no-op 204, so a double click or a second user is harmless.
|
|
/// </summary>
|
|
[Function("AcceptDpa")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> AcceptDpa(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "auth/dpa-acceptance")] HttpRequestData req,
|
|
FunctionContext context)
|
|
{
|
|
var principal = context.GetUser();
|
|
if (principal == null)
|
|
return req.CreateResponse(HttpStatusCode.Unauthorized);
|
|
|
|
var tenantId = principal.GetTenantId();
|
|
var upn = principal.GetUserPrincipalName();
|
|
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
|
|
return await req.BadRequestAsync("Missing required claims");
|
|
|
|
var update = await _tenantConfigService.UpdateAsync(tenantId, row =>
|
|
{
|
|
if (!row.DpaAcceptancePending)
|
|
return false;
|
|
row.DpaAcceptancePending = false;
|
|
row.DpaVersion = Constants.CurrentDpaVersion;
|
|
row.DpaAcceptedBy = upn;
|
|
row.DpaAcceptedAt = DateTime.UtcNow;
|
|
row.UpdatedBy = upn;
|
|
return true;
|
|
}, "auth", "terms + dpa acceptance");
|
|
|
|
switch (update.Status)
|
|
{
|
|
case TenantConfigUpdateStatus.Updated:
|
|
_logger.LogInformation("Terms + DPA {DpaVersion} accepted for tenant {TenantId} by {Upn}",
|
|
Constants.CurrentDpaVersion, tenantId, upn);
|
|
return req.CreateResponse(HttpStatusCode.NoContent);
|
|
case TenantConfigUpdateStatus.Declined:
|
|
case TenantConfigUpdateStatus.NotFound:
|
|
return req.CreateResponse(HttpStatusCode.NoContent);
|
|
case TenantConfigUpdateStatus.OffboardingInProgress:
|
|
return await req.ForbiddenAsync("This tenant is being offboarded.", Constants.ApiErrorCodes.TenantSuspended);
|
|
default:
|
|
return await req.ConflictAsync("The acceptance could not be saved. Please try again.");
|
|
}
|
|
}
|
|
|
|
/// <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 IsGlobalAdminResponse { IsGlobalAdmin = isAdmin, Upn = 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 GetGlobalAdminsResponse { Admins = 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 read = await req.ReadAsync<AddGlobalAdminRequest>();
|
|
if (read.Error != null) return read.Error;
|
|
var body = read.Value!;
|
|
if (string.IsNullOrWhiteSpace(body.Upn))
|
|
{
|
|
return await req.BadRequestAsync("UPN is required");
|
|
}
|
|
// 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 = IdentityBindingRules.ValidateOptional(body.HomeTenantId, body.ObjectId);
|
|
if (bindingError != null)
|
|
{
|
|
return await req.BadRequestAsync(bindingError);
|
|
}
|
|
var identity = await IdentityBindingRules.ResolveForGrantAsync(_identityResolver, body.Upn, body.HomeTenantId, body.ObjectId);
|
|
if (identity == null)
|
|
{
|
|
return await req.ErrorAsync(HttpStatusCode.UnprocessableEntity, IdentityBindingRules.HomeTenantUnresolvedCode, IdentityBindingRules.HomeTenantUnresolvedMessage);
|
|
}
|
|
|
|
GlobalAdminRow newAdmin;
|
|
try
|
|
{
|
|
newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!, identity.Value.TenantId, identity.Value.ObjectId);
|
|
}
|
|
catch (IdentityBindingConflictException ex)
|
|
{
|
|
return await req.ConflictAsync(ex.Message);
|
|
}
|
|
|
|
_logger.LogInformation($"Global Admin added: {body.Upn} by {currentUpn}");
|
|
|
|
var response = req.CreateResponse(HttpStatusCode.Created);
|
|
await response.WriteAsJsonAsync(new AddGlobalAdminResponse { 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))
|
|
{
|
|
return await req.BadRequestAsync("You cannot remove yourself as a Global Admin");
|
|
}
|
|
|
|
await _globalAdminService.RemoveGlobalAdminAsync(upn);
|
|
|
|
// SignalR group authorization is join-time only: a removed Global Admin/Reader with a live
|
|
// connection in 'global-admins' would keep receiving cross-tenant pushes until the socket
|
|
// drops. Cut the UPN's connections (negotiate binds userId = lowercased UPN); a reconnect
|
|
// re-runs the join gates against the now-missing platform role.
|
|
await _signalRService.DisconnectUserAsync(upn.ToLowerInvariant());
|
|
|
|
_logger.LogInformation($"Global Admin removed: {upn} by {currentUpn}");
|
|
|
|
return await req.OkAsync(new MessageResponse { Message = "Global Admin removed successfully" });
|
|
}
|
|
|
|
/// <summary>
|
|
/// If the tenant has no domain name yet, extracts it from the UPN, persists it,
|
|
/// and fires best-effort notifications (Telegram + global notification). The write lands
|
|
/// only while the STORED row still has no domain (conditional create or update), and only
|
|
/// the login that wrote it fires the signup side effects.
|
|
/// </summary>
|
|
internal async Task HandleNewTenantDomainAsync(
|
|
TenantConfiguration tenantConfig, string tenantId, string upn, string? tokenAudience = null,
|
|
bool signupConsent = false)
|
|
{
|
|
if (!string.IsNullOrEmpty(tenantConfig.DomainName) || string.IsNullOrEmpty(upn))
|
|
return;
|
|
|
|
var domain = ExtractDomainFromUpn(upn);
|
|
if (string.IsNullOrEmpty(domain))
|
|
return;
|
|
|
|
// The seeded value is rendered into transactional mails — persist only a strict host name,
|
|
// regardless of what the identity provider's UPN charset happens to allow.
|
|
if (!TenantConfigValidation.IsValidDomainName(domain))
|
|
{
|
|
_logger.LogWarning("Refusing to seed DomainName for tenant {TenantId}: UPN domain is not a valid host name", tenantId);
|
|
return;
|
|
}
|
|
|
|
// A returning tenant: the offboarding deleted its configuration, so the pointer is the only
|
|
// record of a consumed self-service trial. Carried over in this same write as OnboardedBy, so
|
|
// no onboarded row (and no auto-promoted admin) ever exists without it (D-288). Unreadable ⇒
|
|
// no write at all; the self-gate above retries on the next login.
|
|
OffboardingByTenantPointer? offboardingPointer;
|
|
try
|
|
{
|
|
(offboardingPointer, _) = await _offboardingRepo.TryGetByTenantPointerAsync(tenantId.ToLowerInvariant());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "First-login offboarding lookup failed for tenant {TenantId} — will retry on next login", tenantId);
|
|
return;
|
|
}
|
|
|
|
var onboardingClientId = EntraAppRegistry.NormalizeAudience(tokenAudience);
|
|
var seededAt = DateTime.UtcNow;
|
|
void Seed(TenantConfiguration row)
|
|
{
|
|
if (offboardingPointer is { TrialConsumed: true })
|
|
row.TrialConsumed = true;
|
|
row.DomainName = domain;
|
|
row.UpdatedBy = upn;
|
|
// OnboardedBy is immutable once set: this is the only place that writes it, and the write
|
|
// below lands only while the STORED row has no domain yet (first-ever login for the tenant),
|
|
// so two concurrent first logins cannot both write it. Downstream auto-promote reads
|
|
// OnboardedBy so background sync jobs that mutate UpdatedBy cannot leak sentinel strings
|
|
// into TenantAdmins.
|
|
if (string.IsNullOrWhiteSpace(row.OnboardedBy))
|
|
row.OnboardedBy = upn;
|
|
// Terms + DPA, decided once in this same first write — later logins never touch it. A first
|
|
// sign-in through the get-started tick accepts with the onboarding (D-251/D-252); any other
|
|
// first sign-in leaves the acceptance pending, and the portal asks before it opens.
|
|
if (string.IsNullOrWhiteSpace(row.DpaVersion) && !row.DpaAcceptancePending)
|
|
{
|
|
if (signupConsent)
|
|
{
|
|
row.DpaVersion = Constants.CurrentDpaVersion;
|
|
row.DpaAcceptedBy = upn;
|
|
row.DpaAcceptedAt = seededAt;
|
|
}
|
|
else
|
|
{
|
|
row.DpaAcceptancePending = true;
|
|
}
|
|
}
|
|
// 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.
|
|
if (onboardingClientId != null)
|
|
{
|
|
if (_appRegistry.IsPrimary(onboardingClientId))
|
|
row.HomedAppClientId = onboardingClientId;
|
|
row.LastAuthClientId = onboardingClientId;
|
|
row.LastAuthClientIdSince = seededAt;
|
|
}
|
|
}
|
|
|
|
TenantConfigUpdate update;
|
|
try
|
|
{
|
|
update = await _tenantConfigService.CreateOrUpdateAsync(tenantId, row =>
|
|
{
|
|
if (!string.IsNullOrEmpty(row.DomainName))
|
|
return false; // another login seeded it since this one read the configuration
|
|
Seed(row);
|
|
return true;
|
|
}, "auth", "first-login domain seed");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// 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;
|
|
}
|
|
|
|
if (update.Status != TenantConfigUpdateStatus.Updated)
|
|
{
|
|
// Declined: a concurrent login won the seed and fires the signup side effects itself.
|
|
// Conflict: the next login retries. Tombstone: the tenant is being offboarded.
|
|
_logger.LogInformation("First-login domain seed for tenant {TenantId} not written ({Status})", tenantId, update.Status);
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("Seeded domain name for tenant {TenantId}: {Domain}", tenantId, domain);
|
|
// This login's view follows what was just stored.
|
|
Seed(tenantConfig);
|
|
|
|
// The ops event is the routable record (alert rules → any channel, Push included); it is
|
|
// written before the response and never throws. The Telegram ping below stays until retired.
|
|
if (_opsEvents != null)
|
|
await _opsEvents.RecordTenantSignupAsync(tenantId, upn);
|
|
|
|
// 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;
|
|
// Cheap pre-filter on the cached view; the write decides again on the fresh row.
|
|
if (string.Equals(tenantConfig.LastAuthClientId, clientId, StringComparison.OrdinalIgnoreCase))
|
|
return;
|
|
|
|
try
|
|
{
|
|
string? previous = null;
|
|
var update = await _tenantConfigService.UpdateAsync(tenantId, row =>
|
|
{
|
|
if (string.Equals(row.LastAuthClientId, clientId, StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
previous = row.LastAuthClientId;
|
|
row.LastAuthClientId = clientId;
|
|
row.LastAuthClientIdSince = DateTime.UtcNow;
|
|
return true;
|
|
}, "auth", "auth client-id tracking");
|
|
|
|
if (update.Status == TenantConfigUpdateStatus.Updated)
|
|
{
|
|
_logger.LogInformation(
|
|
"Tenant {TenantId} logins now arrive via app registration {ClientId} (was: {Previous})",
|
|
tenantId, clientId, previous ?? "(none recorded)");
|
|
}
|
|
}
|
|
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. The cached view only triggers the check:
|
|
/// the decision runs on the FRESH row, so a suspension a Global Admin renewed meanwhile (or the
|
|
/// offboarding tombstone) is never lifted, and this login's view takes over the stored state.
|
|
/// MUST run before BuildAuthResult because it mutates tenantConfig.Disabled.
|
|
/// </summary>
|
|
internal async Task HandleAutoReEnableAsync(
|
|
TenantConfiguration tenantConfig, string tenantId)
|
|
{
|
|
if (!tenantConfig.Disabled || tenantConfig.IsCurrentlyDisabled())
|
|
return;
|
|
|
|
try
|
|
{
|
|
var update = await _tenantConfigService.UpdateAsync(tenantId, row =>
|
|
{
|
|
if (!row.Disabled || row.IsCurrentlyDisabled())
|
|
return false;
|
|
row.Disabled = false;
|
|
row.DisabledReason = null;
|
|
row.DisabledUntil = null;
|
|
row.UpdatedBy = "System (auto-re-enable)";
|
|
return true;
|
|
}, "auth", "auto-re-enable after suspension expiry");
|
|
|
|
if (update.Config != null)
|
|
{
|
|
if (update.Status == TenantConfigUpdateStatus.Updated)
|
|
{
|
|
_logger.LogInformation(
|
|
"Tenant {TenantId} auto-re-enabled: DisabledUntil ({DisabledUntil}) has expired",
|
|
tenantId, tenantConfig.DisabledUntil?.ToString("o"));
|
|
}
|
|
tenantConfig.Disabled = update.Config.Disabled;
|
|
tenantConfig.DisabledReason = update.Config.DisabledReason;
|
|
tenantConfig.DisabledUntil = update.Config.DisabledUntil;
|
|
return;
|
|
}
|
|
// NotFound / Conflict: storage could not settle it — fall through.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Auto-re-enable persist failed for tenant {TenantId} — will retry on next login", tenantId);
|
|
}
|
|
|
|
// Best-effort: the expired suspension still lets THIS login through; the next login
|
|
// re-runs the auto-re-enable. Must not 500 auth/me.
|
|
tenantConfig.Disabled = false;
|
|
tenantConfig.DisabledReason = null;
|
|
tenantConfig.DisabledUntil = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes post-decision side-effects: auto-admin assignment (awaited), metrics recording
|
|
/// (fire-and-forget) and the push sign-in (awaited, fail-soft).
|
|
/// </summary>
|
|
internal async Task HandlePostDecisionSideEffectsAsync(
|
|
AuthDecisionResult decision, string tenantId, string upn,
|
|
string? displayName, string? objectId,
|
|
string? pushClaimRole = null, bool isGlobalAdmin = false)
|
|
{
|
|
if (decision.NeedsAutoAdmin)
|
|
{
|
|
// Warning on purpose: activation seats the onboarding member before it activates the tenant, so
|
|
// this path is the exception (a tenant from before OnboardedBy existed, or a seat that went
|
|
// missing), and Information never reaches Application Insights. A lost conditional insert means
|
|
// a row for this caller appeared meanwhile (activation seating the same person, or a Global
|
|
// Admin); it is left as written and the next request reads it.
|
|
if (await _tenantAdminsService.TryAddTenantAdminAsync(tenantId, upn, "System"))
|
|
_logger.LogWarning("First-user auto-admin: {Upn} seated as Admin of tenant {TenantId}", upn, tenantId);
|
|
else
|
|
_logger.LogInformation("First-user auto-admin for {Upn} in tenant {TenantId}: a member row appeared meanwhile — left untouched", upn, tenantId);
|
|
}
|
|
|
|
_ = _metricsRepo.RecordUserLoginAsync(tenantId, upn, displayName, objectId)
|
|
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
|
"Fire-and-forget RecordUserLoginAsync failed"),
|
|
TaskContinuationOptions.OnlyOnFaulted);
|
|
|
|
// Push sign-in (K3): one stamp per portal sign-in, keyed by the person (home tenant + oid),
|
|
// so paired devices of someone who stopped signing in pause after the inactivity window; it
|
|
// also carries the app-role claim's role, revokes when that role is gone and lifts an
|
|
// inactivity pause. Awaited — a lost stamp is an early pause; the service never throws.
|
|
if (_pushSignIn != null && !string.IsNullOrEmpty(objectId))
|
|
await _pushSignIn.OnPortalSignInAsync(tenantId, objectId, upn, pushClaimRole, isGlobalAdmin, DateTime.UtcNow);
|
|
}
|
|
|
|
/// <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",
|
|
DateTime? whatsNewSeenPlatformUtc = null,
|
|
DateTime? whatsNewSeenAgentUtc = null,
|
|
bool mcpClientRegistrationEnabled = false,
|
|
bool isExternalIdentity = false)
|
|
{
|
|
// 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. The same holds for an
|
|
// external identity (a guest), which is not a member of this tenant at all.
|
|
// Once the tenant has a designated onboarding member (a promotable OnboardedBy), the first-user
|
|
// seat is theirs alone: activation seats them before it activates the tenant, and should that
|
|
// seat ever be missing (removed, or the write failed) only they may take it on sign-in. Any other
|
|
// roleless caller is answered roleless, never the first caller to see an activated tenant with
|
|
// no rows. Without a designated member (rows from before OnboardedBy existed) the first sign-in
|
|
// still becomes the admin.
|
|
var onboardingMember = TenantApprovalService.IsPromotableUpn(tenantConfig.OnboardedBy) ? tenantConfig.OnboardedBy : null;
|
|
bool isTenantAdmin = memberRole?.Role == Constants.TenantRoles.Admin;
|
|
bool needsAutoAdmin = memberRole == null && !hasTenantAdmins && !isGlobalReader && !isDelegated && !isExternalIdentity
|
|
&& (onboardingMember == null || string.Equals(onboardingMember, upn, StringComparison.OrdinalIgnoreCase));
|
|
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 AuthMeResponse
|
|
{
|
|
TenantId = tenantId,
|
|
Upn = upn,
|
|
DisplayName = displayName,
|
|
ObjectId = objectId,
|
|
IsGlobalAdmin = isGlobalAdmin,
|
|
IsGlobalReader = isGlobalReader,
|
|
IsTenantAdmin = 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 = isDelegated,
|
|
DelegatedTenantIds = managedTenantIds,
|
|
Role = role,
|
|
CanManageBootstrapTokens = 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 = 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.Resolve(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable
|
|
&& tenantConfig.UnrestrictedModeEnabled,
|
|
McpClientRegistrationEnabled = mcpClientRegistrationEnabled,
|
|
WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc,
|
|
WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc,
|
|
DpaAcceptancePending = tenantConfig.DpaAcceptancePending
|
|
}, needsAutoAdmin);
|
|
}
|
|
|
|
internal static bool TryNormalizeWhatsNewSeen(
|
|
WhatsNewSeenRequest? body,
|
|
DateTime nowUtc,
|
|
out string channel,
|
|
out DateTime seenUtc,
|
|
out string error)
|
|
{
|
|
channel = string.Empty;
|
|
seenUtc = default;
|
|
error = string.Empty;
|
|
|
|
if (body == null)
|
|
{
|
|
error = "Request body is required";
|
|
return false;
|
|
}
|
|
|
|
channel = (body.Channel ?? string.Empty).Trim().ToLowerInvariant();
|
|
if (channel is not ("platform" or "agent"))
|
|
{
|
|
error = "channel must be 'platform' or 'agent'";
|
|
return false;
|
|
}
|
|
|
|
if (body.SeenUtc == default)
|
|
{
|
|
error = "seenUtc is required";
|
|
return false;
|
|
}
|
|
|
|
nowUtc = NormalizeUtc(nowUtc);
|
|
seenUtc = NormalizeUtc(body.SeenUtc);
|
|
if (seenUtc > nowUtc.AddMinutes(5))
|
|
{
|
|
// Client clocks can run ahead; clamp rather than reject so the marker remains harmless.
|
|
seenUtc = nowUtc;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static DateTime NormalizeUtc(DateTime value)
|
|
=> value.Kind switch
|
|
{
|
|
DateTimeKind.Utc => value,
|
|
DateTimeKind.Local => value.ToUniversalTime(),
|
|
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
|
};
|
|
|
|
/// <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
|
|
};
|
|
}
|