mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
startup flow optimized (performance)
This commit is contained in:
@@ -1 +1 @@
|
||||
830
|
||||
831
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Functions.Infrastructure;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for AuthFunction.BuildAuthResult — the pure decision logic behind GET /api/auth/me.
|
||||
/// Covers all gate checks and edge cases to guard against regressions when parallelizing queries.
|
||||
/// </summary>
|
||||
public class AuthFunctionTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
private const string Upn = "user@contoso.com";
|
||||
private const string DisplayName = "Test User";
|
||||
private const string ObjectId = "oid-12345";
|
||||
|
||||
private static TenantConfiguration DefaultConfig() => TenantConfiguration.CreateDefault(TenantId);
|
||||
|
||||
private static McpAccessCheckResult McpAllowed() => McpAccessCheckResult.Allowed(Upn, "test-grant");
|
||||
private static McpAccessCheckResult McpDenied() => McpAccessCheckResult.Denied("not allowed");
|
||||
|
||||
private static MemberRoleInfo AdminRole() => new() { Role = Constants.TenantRoles.Admin, CanManageBootstrapTokens = true };
|
||||
private static MemberRoleInfo OperatorRole() => new() { Role = Constants.TenantRoles.Operator, CanManageBootstrapTokens = false };
|
||||
private static MemberRoleInfo ViewerRole() => new() { Role = Constants.TenantRoles.Viewer, CanManageBootstrapTokens = false };
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Happy Path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_AdminUser_Returns200WithCorrectFields()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(HttpStatusCode.OK, result.StatusCode);
|
||||
Assert.False(result.NeedsAutoAdmin);
|
||||
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.Equal(TenantId, (string)body.tenantId);
|
||||
Assert.Equal(Upn, (string)body.upn);
|
||||
Assert.Equal(DisplayName, (string)body.displayName);
|
||||
Assert.Equal(ObjectId, (string)body.objectId);
|
||||
Assert.False((bool)body.isGlobalAdmin);
|
||||
Assert.True((bool)body.isTenantAdmin);
|
||||
Assert.Equal(Constants.TenantRoles.Admin, (string)body.role);
|
||||
Assert.True((bool)body.canManageBootstrapTokens);
|
||||
Assert.True((bool)body.hasMcpAccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_OperatorUser_ReturnsCorrectRole()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
|
||||
memberRole: OperatorRole(), mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.Equal(Constants.TenantRoles.Operator, (string)body.role);
|
||||
Assert.False((bool)body.isTenantAdmin);
|
||||
Assert.False((bool)body.canManageBootstrapTokens);
|
||||
Assert.False((bool)body.hasMcpAccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_ViewerUser_ReturnsCorrectRole()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
|
||||
memberRole: ViewerRole(), mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, (string)body.role);
|
||||
Assert.False((bool)body.isTenantAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_GlobalAdmin_ReturnsIsGlobalAdminTrue()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: true, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.True((bool)body.isGlobalAdmin);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Gate 1: Suspended Tenant
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SuspendedTenant_Returns403WithTenantSuspendedError()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.Disabled = true;
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.Equal("TenantSuspended", (string)body.error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuspendedTenant_WithCustomReason_ReturnsCustomMessage()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.Disabled = true;
|
||||
config.DisabledReason = "Terms of service violation";
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.Equal("Terms of service violation", (string)body.message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuspendedTenant_BlocksEvenGlobalAdmin()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.Disabled = true;
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: true, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.Equal("TenantSuspended", (string)body.error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuspendedTenant_WithExpiredDisabledUntil_IsNotBlocked()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.Disabled = true;
|
||||
config.DisabledUntil = DateTime.UtcNow.AddHours(-1); // expired
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Gate 2: Preview Gate
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PreviewGate_NotApproved_Returns403PrivatePreview()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: false,
|
||||
memberRole: AdminRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.Equal("PrivatePreview", (string)body.error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreviewGate_GlobalAdminBypassesPreviewGate()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: true, isPreviewApproved: false,
|
||||
memberRole: AdminRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auto-Admin Logic
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AutoAdmin_FirstUser_NoExistingAdmins_BecomesAdmin()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
|
||||
memberRole: null, // no role yet
|
||||
mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: false, // no existing admins
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.NeedsAutoAdmin);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.True((bool)body.isTenantAdmin);
|
||||
Assert.Equal(Constants.TenantRoles.Admin, (string)body.role);
|
||||
Assert.True((bool)body.canManageBootstrapTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoAdmin_NotTriggered_WhenAdminsExist()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
|
||||
memberRole: null, // no role — new user but admins exist
|
||||
mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: true, // admins already exist
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.NeedsAutoAdmin);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.False((bool)body.isTenantAdmin);
|
||||
Assert.Null((string?)body.role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoAdmin_NotTriggered_WhenUserAlreadyAdmin()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
|
||||
memberRole: AdminRole(),
|
||||
mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.NeedsAutoAdmin);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.True((bool)body.isTenantAdmin);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MCP Access
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void McpAccess_Allowed_ReturnsTrue()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.True((bool)body.hasMcpAccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpAccess_Denied_ReturnsFalse()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.False((bool)body.hasMcpAccess);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Gate Priority: Suspended takes precedence over Preview
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GatePriority_SuspendedTakesPrecedenceOverPreviewGate()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.Disabled = true;
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isPreviewApproved: false,
|
||||
memberRole: null, mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: false,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.Equal("TenantSuspended", (string)body.error);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ExtractDomainFromUpn
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("user@contoso.com", "contoso.com")]
|
||||
[InlineData("admin@sub.domain.org", "sub.domain.org")]
|
||||
[InlineData("", "")]
|
||||
[InlineData("noemail", "")]
|
||||
public void ExtractDomainFromUpn_ReturnsExpected(string upn, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AuthFunction.ExtractDomainFromUpn(upn));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helper
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Converts anonymous object to dynamic for property access in tests.
|
||||
/// </summary>
|
||||
private static dynamic ToDynamic(object obj)
|
||||
{
|
||||
var type = obj.GetType();
|
||||
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
dict[prop.Name] = prop.GetValue(obj);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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;
|
||||
@@ -79,8 +81,25 @@ public class AuthFunction
|
||||
return badRequestResponse;
|
||||
}
|
||||
|
||||
// Load tenant configuration
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationAsync(tenantId);
|
||||
// --- 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))
|
||||
@@ -88,7 +107,7 @@ public class AuthFunction
|
||||
var domain = ExtractDomainFromUpn(upn);
|
||||
if (!string.IsNullOrEmpty(domain))
|
||||
{
|
||||
_logger.LogInformation($"Setting domain name for tenant {tenantId}: {domain}");
|
||||
_logger.LogInformation("Setting domain name for tenant {TenantId}: {Domain}", tenantId, domain);
|
||||
tenantConfig.DomainName = domain;
|
||||
tenantConfig.UpdatedBy = upn;
|
||||
await _tenantConfigService.SaveConfigurationAsync(tenantConfig);
|
||||
@@ -107,28 +126,11 @@ public class AuthFunction
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
_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;
|
||||
@@ -136,67 +138,46 @@ public class AuthFunction
|
||||
await _tenantConfigService.SaveConfigurationAsync(tenantConfig);
|
||||
}
|
||||
|
||||
// Check if user is global admin (must happen before preview gate)
|
||||
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(upn);
|
||||
// --- 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);
|
||||
|
||||
// Preview gate: only approved tenants get full portal access.
|
||||
// Global Admins bypass the gate (they need access to manage the whitelist).
|
||||
if (!isGlobalAdmin && !await _previewWhitelistService.IsApprovedAsync(tenantId))
|
||||
if (!decision.IsSuccess)
|
||||
{
|
||||
_logger.LogInformation("Tenant {TenantId} blocked by preview gate (user: {Upn})", tenantId, upn);
|
||||
|
||||
var previewResponse = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await previewResponse.WriteAsJsonAsync(new
|
||||
if (decision.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
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;
|
||||
}
|
||||
var bodyType = decision.Body.GetType();
|
||||
var errorProp = bodyType.GetProperty("error");
|
||||
var errorValue = errorProp?.GetValue(decision.Body) as string;
|
||||
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// --- 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);
|
||||
|
||||
// Check MCP access (cached, lightweight)
|
||||
var mcpCheck = await _mcpUserService.IsAllowedAsync(upn);
|
||||
|
||||
var userInfo = new
|
||||
{
|
||||
tenantId,
|
||||
upn,
|
||||
displayName,
|
||||
objectId,
|
||||
isGlobalAdmin,
|
||||
isTenantAdmin,
|
||||
role,
|
||||
canManageBootstrapTokens,
|
||||
hasMcpAccess = mcpCheck.IsAllowed
|
||||
};
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(userInfo);
|
||||
await response.WriteAsJsonAsync(decision.Body);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -307,10 +288,74 @@ public class AuthFunction
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <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 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts domain name from UPN (e.g., user@contoso.com -> contoso.com)
|
||||
/// </summary>
|
||||
private static string ExtractDomainFromUpn(string upn)
|
||||
internal static string ExtractDomainFromUpn(string upn)
|
||||
{
|
||||
if (string.IsNullOrEmpty(upn))
|
||||
return string.Empty;
|
||||
@@ -325,6 +370,32 @@ public class AuthFunction
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Threading;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Azure.Identity;
|
||||
@@ -87,23 +88,27 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var successCount = 0;
|
||||
var failCount = 0;
|
||||
|
||||
foreach (var tableName in Constants.TableNames.All)
|
||||
{
|
||||
try
|
||||
await Parallel.ForEachAsync(
|
||||
Constants.TableNames.All,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = 8 },
|
||||
async (tableName, ct) =>
|
||||
{
|
||||
await _tableServiceClient.CreateTableIfNotExistsAsync(tableName);
|
||||
_logger.LogDebug($"Table '{tableName}' initialized");
|
||||
successCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Failed to initialize table '{tableName}'");
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
await _tableServiceClient.CreateTableIfNotExistsAsync(tableName, ct);
|
||||
_logger.LogDebug("Table '{TableName}' initialized", tableName);
|
||||
Interlocked.Increment(ref successCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to initialize table '{TableName}'", tableName);
|
||||
Interlocked.Increment(ref failCount);
|
||||
}
|
||||
});
|
||||
|
||||
stopwatch.Stop();
|
||||
_logger.LogInformation($"Table initialization completed in {stopwatch.ElapsedMilliseconds}ms: {successCount} succeeded, {failCount} failed");
|
||||
_logger.LogInformation("Table initialization completed in {ElapsedMs}ms: {Success} succeeded, {Failed} failed",
|
||||
stopwatch.ElapsedMilliseconds, successCount, failCount);
|
||||
|
||||
// CPE mapping seed is imported via Admin UI "Re-Seed Mappings" button
|
||||
// (pulls from GitHub, not embedded resource). No auto-import at startup.
|
||||
|
||||
@@ -11,11 +11,39 @@ const msalInstance = new PublicClientApplication(msalConfig);
|
||||
|
||||
// Track MSAL initialization state so components can wait for it.
|
||||
let msalReady = false;
|
||||
|
||||
// Prefetch: store auth/me result fetched during MSAL init so fetchUserInfo
|
||||
// can use it immediately without an extra network round-trip.
|
||||
// Runs as a fire-and-forget side-effect — MUST NOT block msalInitPromise,
|
||||
// otherwise a cold backend would keep the UI on a white screen.
|
||||
let prefetchedAuthMe: Record<string, unknown> | null = null;
|
||||
|
||||
const msalInitPromise = msalInstance
|
||||
.initialize()
|
||||
.then(() => msalInstance.handleRedirectPromise())
|
||||
.then(() => {
|
||||
msalReady = true;
|
||||
|
||||
// Fire-and-forget: prefetch auth/me while React is still mounting.
|
||||
// Not awaited — if it finishes before fetchUserInfo runs, great;
|
||||
// if not, fetchUserInfo does its own fetch as before.
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
if (accounts.length > 0) {
|
||||
msalInstance.acquireTokenSilent({
|
||||
scopes: apiRequest.scopes,
|
||||
account: accounts[0],
|
||||
}).then(async (tokenResponse) => {
|
||||
const res = await fetch(api.auth.me(), {
|
||||
headers: { 'Authorization': `Bearer ${tokenResponse.accessToken}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (res.ok) {
|
||||
prefetchedAuthMe = await res.json();
|
||||
}
|
||||
}).catch(() => {
|
||||
// Best-effort; fetchUserInfo will retry normally.
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[Auth] MSAL initialization/redirect error:', error);
|
||||
@@ -111,6 +139,24 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
|
||||
*/
|
||||
const fetchUserInfo = useCallback(async (account: AccountInfo): Promise<UserInfo | null> => {
|
||||
try {
|
||||
// Use prefetched auth/me result if available (fetched during MSAL init).
|
||||
// Consume it exactly once to avoid stale data on subsequent calls.
|
||||
if (prefetchedAuthMe) {
|
||||
const data = prefetchedAuthMe;
|
||||
prefetchedAuthMe = null;
|
||||
return {
|
||||
displayName: (data.displayName as string) || account.name || '',
|
||||
upn: (data.upn as string) || account.username || '',
|
||||
tenantId: (data.tenantId as string) || account.tenantId || '',
|
||||
objectId: (data.objectId as string) || account.homeAccountId || '',
|
||||
isGlobalAdmin: (data.isGlobalAdmin as boolean) || false,
|
||||
isTenantAdmin: (data.isTenantAdmin as boolean) || false,
|
||||
role: (data.role as 'Admin' | 'Operator' | 'Viewer' | null) || null,
|
||||
canManageBootstrapTokens: (data.canManageBootstrapTokens as boolean) || false,
|
||||
hasMcpAccess: (data.hasMcpAccess as boolean) || false,
|
||||
};
|
||||
}
|
||||
|
||||
// Get access token for API
|
||||
const tokenResponse = await instance.acquireTokenSilent({
|
||||
scopes: apiRequest.scopes,
|
||||
|
||||
@@ -144,10 +144,13 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
startConnection();
|
||||
// Defer SignalR connection to avoid competing with critical API calls
|
||||
// (auth/me, sessions, tenant config) during initial page load.
|
||||
const deferTimer = setTimeout(startConnection, 2000);
|
||||
|
||||
// Cleanup only when provider unmounts (app closes)
|
||||
return () => {
|
||||
clearTimeout(deferTimer);
|
||||
// Clear any pending retry timeout to prevent reconnection after unmount
|
||||
if (retryTimeoutRef.current) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user