using System.Net; using AutopilotMonitor.Functions.Extensions; 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 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; public AuthFunction( ILogger logger, GlobalAdminService globalAdminService, TenantConfigurationService tenantConfigService, TenantAdminsService tenantAdminsService, IMetricsRepository metricsRepo, PreviewWhitelistService previewWhitelistService, TelegramNotificationService telegramNotificationService, GlobalNotificationService globalNotificationService, McpUserService mcpUserService) { _logger = logger; _globalAdminService = globalAdminService; _tenantConfigService = tenantConfigService; _tenantAdminsService = tenantAdminsService; _metricsRepo = metricsRepo; _previewWhitelistService = previewWhitelistService; _telegramNotificationService = telegramNotificationService; _globalNotificationService = globalNotificationService; _mcpUserService = mcpUserService; } /// /// 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 --- var tenantConfigTask = _tenantConfigService.GetConfigurationAsync(tenantId); var isGlobalAdminTask = _globalAdminService.IsGlobalAdminAsync(upn); var isApprovedTask = _previewWhitelistService.IsApprovedAsync(tenantId); var memberRoleTask = _tenantAdminsService.GetMemberRoleAsync(tenantId, upn); var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn); var existingAdminsTask = _tenantAdminsService.GetTenantAdminsAsync(tenantId); await Task.WhenAll(tenantConfigTask, isGlobalAdminTask, isApprovedTask, memberRoleTask, mcpCheckTask, existingAdminsTask); var tenantConfig = tenantConfigTask.Result; var isGlobalAdmin = isGlobalAdminTask.Result; var isApproved = isApprovedTask.Result; var memberRole = memberRoleTask.Result; var mcpCheck = mcpCheckTask.Result; var existingAdmins = existingAdminsTask.Result; // --- Side-effects that don't affect the auth decision --- // Extract and save domain name from first user if not set if (string.IsNullOrEmpty(tenantConfig.DomainName) && !string.IsNullOrEmpty(upn)) { var domain = ExtractDomainFromUpn(upn); if (!string.IsNullOrEmpty(domain)) { _logger.LogInformation("Setting domain name for tenant {TenantId}: {Domain}", tenantId, domain); tenantConfig.DomainName = domain; tenantConfig.UpdatedBy = upn; await _tenantConfigService.SaveConfigurationAsync(tenantConfig); // Notify via Telegram that a new tenant has signed up for Private Preview _ = _telegramNotificationService.SendNewTenantSignupAsync(tenantId, upn) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget Telegram notification failed for tenant {TenantId}", tenantId), TaskContinuationOptions.OnlyOnFaulted); // Persistent in-app notification for Global Admins — best effort _ = _globalNotificationService.CreateNotificationAsync( "preview_signup", "New Preview Signup", $"Tenant {tenantId} ({domain}), UPN: {upn}"); } } // Auto-re-enable: if Disabled was true but DisabledUntil has expired, persist the re-enable if (tenantConfig.Disabled && !tenantConfig.IsCurrentlyDisabled()) { _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)"; await _tenantConfigService.SaveConfigurationAsync(tenantConfig); } // --- Pure decision logic (tested by AuthFunctionTests) --- var decision = BuildAuthResult( tenantConfig, isGlobalAdmin, isApproved, memberRole, mcpCheck, existingAdmins.Count > 0, tenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty); 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 == "PrivatePreview") _logger.LogInformation("Tenant {TenantId} blocked by preview gate (user: {Upn})", tenantId, upn); } var blockedResponse = req.CreateResponse(decision.StatusCode); await blockedResponse.WriteAsJsonAsync(decision.Body); return blockedResponse; } // --- Post-decision side-effects --- // Auto-admin: first user becomes admin (flagged by BuildAuthResult) if (decision.NeedsAutoAdmin) { _logger.LogInformation("First user login for tenant {TenantId}: {Upn} - Auto-assigning as admin", tenantId, upn); await _tenantAdminsService.AddTenantAdminAsync(tenantId, upn, "System"); } // Record user login activity for metrics tracking _ = _metricsRepo.RecordUserLoginAsync(tenantId, upn, displayName, objectId) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget RecordUserLoginAsync failed"), TaskContinuationOptions.OnlyOnFaulted); 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(upn); 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; } var newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!); _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; } /// /// 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 isPreviewApproved, MemberRoleInfo? memberRole, McpAccessCheckResult mcpCheck, bool hasTenantAdmins, string tenantId, string upn, string displayName, string objectId) { // 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: Preview gate (Global Admins bypass) if (!isGlobalAdmin && !isPreviewApproved) { return AuthDecisionResult.Blocked(HttpStatusCode.Forbidden, new { error = "PrivatePreview", message = "Autopilot Monitor is currently in Private Preview. Your organization is on the waitlist \u2014 we'll notify you when access is granted." }); } // Determine admin status: auto-admin if first user (no existing admins and no role yet) bool isTenantAdmin = memberRole?.Role == Constants.TenantRoles.Admin; bool needsAutoAdmin = !isTenantAdmin && !hasTenantAdmins; 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, isTenantAdmin, role, canManageBootstrapTokens, hasMcpAccess = mcpCheck.IsAllowed }, 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; }