using System.Net; using AutopilotMonitor.Functions.Extensions; using AutopilotMonitor.Functions.Services; 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 GalacticAdminService _galacticAdminService; private readonly TenantConfigurationService _tenantConfigService; private readonly TenantAdminsService _tenantAdminsService; private readonly TableStorageService _storageService; private readonly PreviewWhitelistService _previewWhitelistService; private readonly TelegramNotificationService _telegramNotificationService; public AuthFunction( ILogger logger, GalacticAdminService galacticAdminService, TenantConfigurationService tenantConfigService, TenantAdminsService tenantAdminsService, TableStorageService storageService, PreviewWhitelistService previewWhitelistService, TelegramNotificationService telegramNotificationService) { _logger = logger; _galacticAdminService = galacticAdminService; _tenantConfigService = tenantConfigService; _tenantAdminsService = tenantAdminsService; _storageService = storageService; _previewWhitelistService = previewWhitelistService; _telegramNotificationService = telegramNotificationService; } /// /// 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; } // Load tenant configuration var tenantConfig = await _tenantConfigService.GetConfigurationAsync(tenantId); // 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}"); 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); } } // Check if tenant is disabled/suspended if (tenantConfig.IsCurrentlyDisabled()) { _logger.LogWarning($"Login attempt for suspended tenant: {tenantId} by user {upn}"); var suspendedResponse = req.CreateResponse(HttpStatusCode.Forbidden); await suspendedResponse.WriteAsJsonAsync(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 }); return suspendedResponse; } // 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 ({tenantConfig.DisabledUntil:o}) has expired"); tenantConfig.Disabled = false; tenantConfig.DisabledReason = null; tenantConfig.DisabledUntil = null; tenantConfig.UpdatedBy = "System (auto-re-enable)"; await _tenantConfigService.SaveConfigurationAsync(tenantConfig); } // Check if user is galactic admin (must happen before preview gate) var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(upn); // Preview gate: only approved tenants get full portal access. // Galactic Admins bypass the gate (they need access to manage the whitelist). if (!isGalacticAdmin && !await _previewWhitelistService.IsApprovedAsync(tenantId)) { _logger.LogInformation("Tenant {TenantId} blocked by preview gate (user: {Upn})", tenantId, upn); var previewResponse = req.CreateResponse(HttpStatusCode.Forbidden); await previewResponse.WriteAsJsonAsync(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." }); return previewResponse; } // Check if user is tenant admin bool isTenantAdmin = await _tenantAdminsService.IsTenantAdminAsync(tenantId, upn); // Auto-admin logic: First user becomes admin if (!isTenantAdmin) { // Check if tenant has any admins at all var existingAdmins = await _tenantAdminsService.GetTenantAdminsAsync(tenantId); if (existingAdmins.Count == 0) { _logger.LogInformation($"First user login for tenant {tenantId}: {upn} - Auto-assigning as admin"); await _tenantAdminsService.AddTenantAdminAsync(tenantId, upn, "System"); isTenantAdmin = true; } } // Get role info for the user (Admin, Operator, Viewer, or null if not a member) var memberRole = await _tenantAdminsService.GetMemberRoleAsync(tenantId, upn); string? role = memberRole?.Role; bool canManageBootstrapTokens = memberRole?.CanManageBootstrapTokens ?? false; // Record user login activity for metrics tracking _ = _storageService.RecordUserLoginAsync(tenantId, upn, displayName, objectId) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget RecordUserLoginAsync failed"), TaskContinuationOptions.OnlyOnFaulted); var userInfo = new { tenantId, upn, displayName, objectId, isGalacticAdmin, isTenantAdmin, role, canManageBootstrapTokens }; var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(userInfo); return response; } /// /// GET /api/auth/is-galactic-admin /// Checks if the current user is a Galactic Admin /// [Function("IsGalacticAdmin")] [Authorize] public async Task IsGalacticAdmin( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "auth/is-galactic-admin")] HttpRequestData req, FunctionContext context) { var principal = context.GetUser(); if (principal == null) { return req.CreateResponse(HttpStatusCode.Unauthorized); } var upn = principal.GetUserPrincipalName(); var isAdmin = await _galacticAdminService.IsGalacticAdminAsync(upn); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new { isGalacticAdmin = isAdmin, upn }); return response; } /// /// GET /api/auth/galactic-admins /// Lists all Galactic Admins (only accessible by Galactic Admins) /// [Function("GetGalacticAdmins")] [Authorize] public async Task GetGalacticAdmins( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "auth/galactic-admins")] HttpRequestData req, FunctionContext context) { // Authentication + GalacticAdminOnly authorization enforced by PolicyEnforcementMiddleware var admins = await _galacticAdminService.GetAllGalacticAdminsAsync(); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new { admins }); return response; } /// /// POST /api/auth/galactic-admins /// Adds a new Galactic Admin (only accessible by existing Galactic Admins) /// [Function("AddGalacticAdmin")] [Authorize] public async Task AddGalacticAdmin( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "auth/galactic-admins")] HttpRequestData req, FunctionContext context) { // Authentication + GalacticAdminOnly 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 _galacticAdminService.AddGalacticAdminAsync(body.Upn, currentUpn!); _logger.LogInformation($"Galactic Admin added: {body.Upn} by {currentUpn}"); var response = req.CreateResponse(HttpStatusCode.Created); await response.WriteAsJsonAsync(new { admin = newAdmin }); return response; } /// /// DELETE /api/auth/galactic-admins/{upn} /// Removes a Galactic Admin (only accessible by existing Galactic Admins) /// [Function("RemoveGalacticAdmin")] [Authorize] public async Task RemoveGalacticAdmin( [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "auth/galactic-admins/{upn}")] HttpRequestData req, string upn, FunctionContext context) { // Authentication + GalacticAdminOnly 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 Galactic Admin" }); return badRequestResponse; } await _galacticAdminService.RemoveGalacticAdminAsync(upn); _logger.LogInformation($"Galactic Admin removed: {upn} by {currentUpn}"); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new { message = "Galactic Admin removed successfully" }); return response; } /// /// Extracts domain name from UPN (e.g., user@contoso.com -> contoso.com) /// private 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; } } public class AddGalacticAdminRequest { public string Upn { get; set; } = string.Empty; }