added private preview support

This commit is contained in:
Oliver Kieselbach
2026-02-15 20:07:45 +01:00
parent 526c742d96
commit 8227c909a5
10 changed files with 511 additions and 29 deletions
@@ -1 +1 @@
29
30
@@ -19,19 +19,22 @@ public class AuthFunction
private readonly TenantConfigurationService _tenantConfigService;
private readonly TenantAdminsService _tenantAdminsService;
private readonly TableStorageService _storageService;
private readonly PreviewWhitelistService _previewWhitelistService;
public AuthFunction(
ILogger<AuthFunction> logger,
GalacticAdminService galacticAdminService,
TenantConfigurationService tenantConfigService,
TenantAdminsService tenantAdminsService,
TableStorageService storageService)
TableStorageService storageService,
PreviewWhitelistService previewWhitelistService)
{
_logger = logger;
_galacticAdminService = galacticAdminService;
_tenantConfigService = tenantConfigService;
_tenantAdminsService = tenantAdminsService;
_storageService = storageService;
_previewWhitelistService = previewWhitelistService;
}
/// <summary>
@@ -111,9 +114,24 @@ public class AuthFunction
await _tenantConfigService.SaveConfigurationAsync(tenantConfig);
}
// Check if user is galactic admin
// 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);
@@ -0,0 +1,131 @@
using System.Net;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions;
/// <summary>
/// CRUD endpoints for managing the Private Preview tenant whitelist.
/// All endpoints are Galactic Admin only.
/// Temporary — remove after GA.
/// </summary>
public class PreviewWhitelistFunction
{
private readonly ILogger<PreviewWhitelistFunction> _logger;
private readonly PreviewWhitelistService _previewWhitelistService;
private readonly GalacticAdminService _galacticAdminService;
public PreviewWhitelistFunction(
ILogger<PreviewWhitelistFunction> logger,
PreviewWhitelistService previewWhitelistService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_previewWhitelistService = previewWhitelistService;
_galacticAdminService = galacticAdminService;
}
/// <summary>
/// GET /api/preview-whitelist
/// Returns all approved tenants.
/// </summary>
[Function("GetPreviewWhitelist")]
[Authorize]
public async Task<HttpResponseData> GetAll(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "preview-whitelist")] HttpRequestData req,
FunctionContext context)
{
var principal = context.GetUser();
if (principal == null)
return req.CreateResponse(HttpStatusCode.Unauthorized);
var upn = principal.GetUserPrincipalName();
if (!await _galacticAdminService.IsGalacticAdminAsync(upn))
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Only Galactic Admins can access this endpoint" });
return forbidden;
}
var approved = await _previewWhitelistService.GetAllApprovedAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { tenants = approved });
return response;
}
/// <summary>
/// POST /api/preview-whitelist/{tenantId}
/// Approves a tenant for Private Preview.
/// </summary>
[Function("ApprovePreviewTenant")]
[Authorize]
public async Task<HttpResponseData> Approve(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "preview-whitelist/{tenantId}")] HttpRequestData req,
string tenantId,
FunctionContext context)
{
var principal = context.GetUser();
if (principal == null)
return req.CreateResponse(HttpStatusCode.Unauthorized);
var upn = principal.GetUserPrincipalName();
if (!await _galacticAdminService.IsGalacticAdminAsync(upn))
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Only Galactic Admins can approve tenants" });
return forbidden;
}
if (string.IsNullOrWhiteSpace(tenantId))
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { error = "tenantId is required" });
return bad;
}
await _previewWhitelistService.ApproveAsync(tenantId, upn!);
_logger.LogInformation("Preview tenant approved: {TenantId} by {Upn}", tenantId, upn);
var response = req.CreateResponse(HttpStatusCode.Created);
await response.WriteAsJsonAsync(new { message = "Tenant approved for preview", tenantId });
return response;
}
/// <summary>
/// DELETE /api/preview-whitelist/{tenantId}
/// Revokes a tenant's Private Preview access.
/// </summary>
[Function("RevokePreviewTenant")]
[Authorize]
public async Task<HttpResponseData> Revoke(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "preview-whitelist/{tenantId}")] HttpRequestData req,
string tenantId,
FunctionContext context)
{
var principal = context.GetUser();
if (principal == null)
return req.CreateResponse(HttpStatusCode.Unauthorized);
var upn = principal.GetUserPrincipalName();
if (!await _galacticAdminService.IsGalacticAdminAsync(upn))
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Only Galactic Admins can revoke tenant access" });
return forbidden;
}
await _previewWhitelistService.RevokeAsync(tenantId);
_logger.LogInformation("Preview tenant revoked: {TenantId} by {Upn}", tenantId, upn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "Tenant removed from preview", tenantId });
return response;
}
}
@@ -99,6 +99,7 @@ builder.Services.AddSingleton<AdminConfigurationService>();
builder.Services.AddSingleton<RateLimitService>();
builder.Services.AddSingleton<UsageMetricsService>();
builder.Services.AddSingleton<GalacticAdminService>();
builder.Services.AddSingleton<PreviewWhitelistService>();
builder.Services.AddSingleton<TenantAdminsService>();
builder.Services.AddSingleton<HealthCheckService>();
builder.Services.AddSingleton<GatherRuleService>();
@@ -0,0 +1,130 @@
using AutopilotMonitor.Shared;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Service for managing the Private Preview tenant whitelist.
/// Tenants in this list are allowed full portal access; others see a waitlist page.
/// Temporary — remove after GA.
/// </summary>
public class PreviewWhitelistService
{
private readonly TableServiceClient _tableServiceClient;
private readonly IMemoryCache _cache;
private readonly ILogger<PreviewWhitelistService> _logger;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public PreviewWhitelistService(
IConfiguration configuration,
IMemoryCache cache,
ILogger<PreviewWhitelistService> logger)
{
_cache = cache;
_logger = logger;
var connectionString = configuration["AzureTableStorageConnectionString"];
_tableServiceClient = new TableServiceClient(connectionString);
// Table is initialized centrally by TableInitializerService at startup
}
/// <summary>
/// Checks whether a tenant is approved for Private Preview (cached).
/// </summary>
public async Task<bool> IsApprovedAsync(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
return false;
var cacheKey = $"preview:{tenantId}";
if (_cache.TryGetValue<bool>(cacheKey, out var approved))
return approved;
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist);
var entity = await tableClient.GetEntityAsync<PreviewWhitelistEntity>(tenantId, "approved");
var result = entity?.Value != null;
_cache.Set(cacheKey, result, CacheDuration);
return result;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
_cache.Set(cacheKey, false, CacheDuration);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking preview whitelist for tenant {TenantId}", tenantId);
// Fail-closed: if we can't check, deny access
return false;
}
}
/// <summary>
/// Approves a tenant for Private Preview access.
/// </summary>
public async Task ApproveAsync(string tenantId, string approvedBy)
{
var entity = new PreviewWhitelistEntity
{
PartitionKey = tenantId,
RowKey = "approved",
ApprovedAt = DateTime.UtcNow,
ApprovedBy = approvedBy
};
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist);
await tableClient.UpsertEntityAsync(entity);
_cache.Remove($"preview:{tenantId}");
_logger.LogInformation("Tenant {TenantId} approved for preview by {ApprovedBy}", tenantId, approvedBy);
}
/// <summary>
/// Revokes a tenant's Private Preview access.
/// </summary>
public async Task RevokeAsync(string tenantId)
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist);
await tableClient.DeleteEntityAsync(tenantId, "approved");
_cache.Remove($"preview:{tenantId}");
_logger.LogInformation("Tenant {TenantId} revoked from preview", tenantId);
}
/// <summary>
/// Returns all approved tenants (for Galactic Admin overview).
/// </summary>
public async Task<List<PreviewWhitelistEntity>> GetAllApprovedAsync()
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist);
var results = new List<PreviewWhitelistEntity>();
await foreach (var entity in tableClient.QueryAsync<PreviewWhitelistEntity>(
filter: "RowKey eq 'approved'"))
{
results.Add(entity);
}
return results.OrderBy(e => e.PartitionKey).ToList();
}
}
/// <summary>
/// Entity representing an approved tenant in the PreviewWhitelist table.
/// </summary>
public class PreviewWhitelistEntity : ITableEntity
{
public string PartitionKey { get; set; } = string.Empty; // TenantId
public string RowKey { get; set; } = "approved";
public DateTimeOffset? Timestamp { get; set; }
public ETag ETag { get; set; }
public DateTime ApprovedAt { get; set; }
public string ApprovedBy { get; set; } = string.Empty;
}
@@ -146,6 +146,9 @@ namespace AutopilotMonitor.Shared
public const string GalacticAdmins = "GalacticAdmins";
public const string TenantAdmins = "TenantAdmins";
// Preview gating (temporary — remove after GA)
public const string PreviewWhitelist = "PreviewWhitelist";
/// <summary>
/// Returns all table names for initialization
/// </summary>
@@ -164,7 +167,8 @@ namespace AutopilotMonitor.Shared
TenantConfiguration,
AdminConfiguration,
GalacticAdmins,
TenantAdmins
TenantAdmins,
PreviewWhitelist
};
}
}
@@ -66,6 +66,11 @@ export default function AdminConfigurationPage() {
const [currentPage, setCurrentPage] = useState(0);
const tenantsPerPage = 3;
// Preview Whitelist state
const [previewApproved, setPreviewApproved] = useState<Set<string>>(new Set());
const [loadingPreview, setLoadingPreview] = useState(false);
const [togglingPreviewTenant, setTogglingPreviewTenant] = useState<string | null>(null);
// Admin Management state (for Edit Modal)
const [tenantAdmins, setTenantAdmins] = useState<TenantAdmin[]>([]);
const [loadingAdmins, setLoadingAdmins] = useState(false);
@@ -127,7 +132,7 @@ export default function AdminConfigurationPage() {
fetchAdminConfig();
}, [galacticAdminMode]);
// Fetch all tenant configurations
// Fetch all tenant configurations + preview whitelist
useEffect(() => {
if (!galacticAdminMode) return;
@@ -140,18 +145,29 @@ export default function AdminConfigurationPage() {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/config/all`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
const [tenantsRes, previewRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/config/all`, {
headers: { 'Authorization': `Bearer ${token}` }
}),
fetch(`${API_BASE_URL}/api/preview-whitelist`, {
headers: { 'Authorization': `Bearer ${token}` }
})
]);
if (!response.ok) {
throw new Error(`Failed to load tenants: ${response.statusText}`);
if (!tenantsRes.ok) {
throw new Error(`Failed to load tenants: ${tenantsRes.statusText}`);
}
const data: TenantConfiguration[] = await response.json();
const data: TenantConfiguration[] = await tenantsRes.json();
setTenants(data);
if (previewRes.ok) {
const previewData = await previewRes.json();
const approvedIds = new Set<string>(
(previewData.tenants || []).map((t: { partitionKey: string }) => t.partitionKey)
);
setPreviewApproved(approvedIds);
}
} catch (err) {
console.error("Error fetching tenants:", err);
setError(err instanceof Error ? err.message : "Failed to load tenants");
@@ -343,6 +359,52 @@ export default function AdminConfigurationPage() {
}
};
const handleTogglePreview = async (tenantId: string) => {
try {
setTogglingPreviewTenant(tenantId);
setError(null);
setSuccessMessage(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const isCurrentlyApproved = previewApproved.has(tenantId);
const response = await fetch(`${API_BASE_URL}/api/preview-whitelist/${tenantId}`, {
method: isCurrentlyApproved ? "DELETE" : "POST",
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to update preview access: ${response.statusText}`);
}
setPreviewApproved(prev => {
const next = new Set(prev);
if (isCurrentlyApproved) {
next.delete(tenantId);
} else {
next.add(tenantId);
}
return next;
});
setSuccessMessage(
isCurrentlyApproved
? `Preview access revoked for tenant ${tenantId}`
: `Preview access granted for tenant ${tenantId}`
);
setTimeout(() => setSuccessMessage(null), 4000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update preview access");
} finally {
setTogglingPreviewTenant(null);
}
};
// Fetch tenant admins for editing
const fetchTenantAdmins = async (tenantId: string) => {
try {
@@ -645,20 +707,46 @@ export default function AdminConfigurationPage() {
Suspended
</span>
)}
{previewApproved.has(tenant.tenantId) ? (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
Preview
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
Waitlist
</span>
)}
</div>
<p className="text-sm text-gray-500 mt-1">
Tenant ID: {tenant.tenantId}
</p>
</div>
<button
onClick={() => {
setEditingTenant(tenant);
fetchTenantAdmins(tenant.tenantId);
}}
className="ml-4 px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Edit
</button>
<div className="flex items-center space-x-2 ml-4">
<button
onClick={() => handleTogglePreview(tenant.tenantId)}
disabled={togglingPreviewTenant === tenant.tenantId}
className={`px-3 py-2 text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
previewApproved.has(tenant.tenantId)
? 'bg-amber-500 text-white hover:bg-amber-600'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{togglingPreviewTenant === tenant.tenantId
? "..."
: previewApproved.has(tenant.tenantId)
? "Revoke"
: "Approve"}
</button>
<button
onClick={() => {
setEditingTenant(tenant);
fetchTenantAdmins(tenant.tenantId);
}}
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Edit
</button>
</div>
</div>
</div>
))}
@@ -12,19 +12,21 @@ const PLATFORM_STATS = {
};
export default function LandingPage() {
const { login, isAuthenticated, isLoading, user } = useAuth();
const { login, isAuthenticated, isLoading, user, isPreviewBlocked } = useAuth();
const router = useRouter();
// Redirect after login: regular users go to /progress, admins go to dashboard
// Redirect after login: preview-blocked → /preview, admins → /, users → /progress
useEffect(() => {
if (isAuthenticated && !isLoading && user) {
if (user.isTenantAdmin || user.isGalacticAdmin) {
if (isPreviewBlocked) {
router.push("/preview");
} else if (user.isTenantAdmin || user.isGalacticAdmin) {
router.push("/");
} else {
router.push("/progress");
}
}
}, [isAuthenticated, isLoading, user, router]);
}, [isAuthenticated, isLoading, user, isPreviewBlocked, router]);
if (isLoading) {
return (
@@ -0,0 +1,91 @@
"use client";
import { useAuth } from "../../contexts/AuthContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function PreviewPage() {
const { isAuthenticated, isLoading, user, isPreviewBlocked, previewMessage, logout } = useAuth();
const router = useRouter();
// If not preview-blocked (e.g. approved tenant navigates here), redirect away
useEffect(() => {
if (!isLoading && isAuthenticated && user && !isPreviewBlocked) {
if (user.isTenantAdmin || user.isGalacticAdmin) {
router.push("/");
} else {
router.push("/progress");
}
}
if (!isLoading && !isAuthenticated) {
router.push("/landing");
}
}, [isAuthenticated, isLoading, user, isPreviewBlocked, router]);
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex items-center justify-center px-6">
<div className="max-w-lg w-full text-center">
{/* Logo */}
<div className="flex items-center justify-center space-x-3 mb-8">
<div className="w-12 h-12 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<svg className="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
Autopilot Monitor
</span>
</div>
{/* Card */}
<div className="bg-white rounded-2xl shadow-xl p-10">
{/* Clock icon */}
<div className="w-16 h-16 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-3">
Private Preview
</h1>
<p className="text-gray-600 mb-6 leading-relaxed">
{previewMessage || "Autopilot Monitor is currently in Private Preview. Your organization is on the waitlist \u2014 we'll notify you when access is granted."}
</p>
<div className="bg-blue-50 rounded-lg p-4 mb-6">
<p className="text-sm text-blue-700">
Signed in as <span className="font-semibold">{user?.upn}</span>
</p>
<p className="text-xs text-blue-500 mt-1">
Tenant: {user?.tenantId}
</p>
</div>
<button
onClick={logout}
className="px-6 py-3 bg-gray-100 text-gray-700 rounded-lg font-semibold hover:bg-gray-200 transition-colors"
>
Sign Out
</button>
</div>
<p className="mt-6 text-sm text-gray-400">
&copy; 2026 Autopilot Monitor. Powered by Azure and Microsoft Identity.
</p>
</div>
</div>
);
}
@@ -30,6 +30,8 @@ interface AuthContextType {
isAuthenticated: boolean;
user: UserInfo | null;
isLoading: boolean;
isPreviewBlocked: boolean;
previewMessage: string;
login: () => Promise<void>;
logout: () => Promise<void>;
getAccessToken: () => Promise<string | null>;
@@ -47,6 +49,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
const isAuthenticated = useIsAuthenticated();
const [user, setUser] = useState<UserInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isPreviewBlocked, setPreviewBlocked] = useState(false);
const [previewMessage, setPreviewMessage] = useState('');
// Handle SSR - if we're on the server, MSAL won't initialize
// Set loading to false immediately on mount in browser
@@ -75,17 +79,28 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
});
if (!response.ok) {
// Check for tenant suspension
if (response.status === 403) {
const errorData = await response.json();
if (errorData.error === 'TenantSuspended') {
console.error('[Auth] Tenant suspended:', errorData.message);
// Display error to user
alert(`Access Denied\n\n${errorData.message}`);
// Logout user
await instance.logoutRedirect({ account });
return null;
}
if (errorData.error === 'PrivatePreview') {
console.log('[Auth] Tenant not yet approved for preview');
setPreviewBlocked(true);
setPreviewMessage(errorData.message || 'Your organization is on the waitlist.');
// Return basic user info so the user stays logged in but sees the preview page
return {
displayName: account.name || '',
upn: account.username || '',
tenantId: account.tenantId || '',
objectId: account.homeAccountId || '',
isGalacticAdmin: false,
isTenantAdmin: false,
};
}
}
throw new Error(`Failed to fetch user info: ${response.statusText}`);
}
@@ -231,6 +246,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
isAuthenticated,
user,
isLoading,
isPreviewBlocked,
previewMessage,
login,
logout,
getAccessToken,