Authentication & Galactic Admin: Implement JWT-based multi-tenant access control with cross-tenant support

Backend Changes:
- Fix user identification to use UPN (User Principal Name) claim as primary identifier instead of display name
- Add JWT authentication validation to all API endpoints with Authorization header support
- Implement Galactic Admin role with cross-tenant access permissions for sessions, events, and SignalR groups
- Add cross-tenant validation in GetSessionEventsFunction, SignalRAddToGroupFunction, and SignalRRemoveFromGroupFunction
- Enhance AuthenticationMiddleware to log actual user identifier (UPN) instead of display name

Admin Configuration:
- Add GetAdminConfigurationFunction and UpdateAdminConfigurationFunction for global settings
- Create AdminConfigurationService for managing platform-wide configuration
- Add AdminConfiguration model with global rate limiting and maintenance settings
- Restrict admin configuration access to Galactic Admins only

SignalR Real-time Updates:
- Implement "galactic-admins" SignalR group for cross-tenant session visibility
- RegisterSessionFunction now sends "newSession" events to both tenant-specific and galactic-admins groups
- IngestEventsFunction sends "newevents" only to tenant-specific groups (prevents flooding)
- Frontend listens to both "newSession" and "newevents" for real-time updates
- Galactic Admins see new sessions from all tenants without being flooded with event updates

Frontend Changes:
- Add Authorization headers with JWT tokens to all API fetch calls (sessions, events, metrics, configuration)
- Implement automatic joining/leaving of "galactic-admins" SignalR group based on Galactic Admin mode
- Handle both "newSession" and "newevents" SignalR events for complete session list updates
- Add authentication error handling with user notifications for token failures

Security Improvements:
- Validate tenant access for all cross-tenant operations
- Ensure users can only access their own tenant data unless they're Galactic Admins
- Log all cross-tenant access attempts with user identifiers for audit trail
This commit is contained in:
Oliver Kieselbach
2026-02-05 20:49:36 +01:00
parent a20ff48ae6
commit 557dd22c53
36 changed files with 1325 additions and 240 deletions
+3
View File
@@ -93,3 +93,6 @@ appsettings.*.json
*.local.*
temp/
tmp/
# Claude Code temporary files
tmpclaude-*-cwd
@@ -13,7 +13,15 @@ public static class ClaimsPrincipalExtensions
/// </summary>
public static ClaimsPrincipal? GetUser(this FunctionContext context)
{
// Try to get ClaimsPrincipal from HTTP context
// Azure Functions Isolated Worker: Try FunctionContext.Items first
// This is set by AuthenticationMiddleware and is more reliable than httpContext.User
if (context.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
&& principalObj is ClaimsPrincipal principal)
{
return principal;
}
// Fallback to HTTP context (may not work reliably in isolated worker)
var httpContext = context.GetHttpContext();
return httpContext?.User;
}
@@ -27,6 +27,19 @@ namespace AutopilotMonitor.Functions.Functions
try
{
// Validate authentication
if (!TenantHelper.IsAuthenticated(req))
{
_logger.LogWarning($"Unauthenticated DeleteSession attempt for session {sessionId}");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauthorizedResponse.WriteAsJsonAsync(new
{
success = false,
message = "Authentication required. Please provide a valid JWT token."
});
return unauthorizedResponse;
}
var tenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
@@ -0,0 +1,81 @@
using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
public class GetAdminConfigurationFunction
{
private readonly ILogger<GetAdminConfigurationFunction> _logger;
private readonly AdminConfigurationService _adminConfigService;
private readonly GalacticAdminService _galacticAdminService;
public GetAdminConfigurationFunction(
ILogger<GetAdminConfigurationFunction> logger,
AdminConfigurationService adminConfigService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_adminConfigService = adminConfigService;
_galacticAdminService = galacticAdminService;
}
[Function("GetAdminConfiguration")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/config")] HttpRequestData req)
{
try
{
// Validate authentication
if (!TenantHelper.IsAuthenticated(req))
{
_logger.LogWarning("Unauthenticated GetAdminConfiguration attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauthorizedResponse.WriteAsJsonAsync(new
{
success = false,
message = "Authentication required. Please provide a valid JWT token."
});
return unauthorizedResponse;
}
// Get tenant ID and user identifier from JWT token
string tenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
// Validate Galactic Admin role
if (!await _galacticAdminService.IsGalacticAdminAsync(userIdentifier))
{
_logger.LogWarning($"Non-Galactic Admin user {userIdentifier} from tenant {tenantId} attempted to access admin configuration");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new
{
success = false,
message = "Access denied. Only Galactic Admins can access global configuration."
});
return forbiddenResponse;
}
_logger.LogInformation($"GetAdminConfiguration by Galactic Admin user {userIdentifier}");
var config = await _adminConfigService.GetConfigurationAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(config);
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting admin configuration");
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { error = "Internal server error" });
return response;
}
}
}
}
@@ -32,8 +32,7 @@ namespace AutopilotMonitor.Functions.Functions
try
{
// Validate authentication and Galactic Admin role
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated != true)
if (!TenantHelper.IsAuthenticated(req))
{
_logger.LogWarning("Unauthenticated GetAllSessions attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
@@ -26,16 +26,10 @@ namespace AutopilotMonitor.Functions.Functions
try
{
string tenantId;
string userIdentifier;
try
// Validate authentication
if (!TenantHelper.IsAuthenticated(req))
{
tenantId = TenantHelper.GetTenantId(req);
userIdentifier = TenantHelper.GetUserIdentifier(req);
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning($"Unauthorized audit logs attempt: {ex.Message}");
_logger.LogWarning("Unauthenticated GetAuditLogs attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauthorizedResponse.WriteAsJsonAsync(new
{
@@ -45,6 +39,9 @@ namespace AutopilotMonitor.Functions.Functions
return unauthorizedResponse;
}
string tenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
_logger.LogInformation($"Fetching audit logs for tenant {tenantId} by user {userIdentifier}");
// Get audit logs from storage
@@ -1,4 +1,5 @@
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
@@ -11,11 +12,16 @@ namespace AutopilotMonitor.Functions.Functions
{
private readonly ILogger<GetSessionEventsFunction> _logger;
private readonly TableStorageService _storageService;
private readonly GalacticAdminService _galacticAdminService;
public GetSessionEventsFunction(ILogger<GetSessionEventsFunction> logger, TableStorageService storageService)
public GetSessionEventsFunction(
ILogger<GetSessionEventsFunction> logger,
TableStorageService storageService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_storageService = storageService;
_galacticAdminService = galacticAdminService;
}
[Function("GetSessionEvents")]
@@ -35,10 +41,72 @@ namespace AutopilotMonitor.Functions.Functions
try
{
var tenantId = TenantHelper.GetTenantId(req);
// Validate authentication
if (!TenantHelper.IsAuthenticated(req))
{
_logger.LogWarning($"{sessionPrefix} Unauthenticated GetSessionEvents attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauthorizedResponse.WriteAsJsonAsync(new
{
success = false,
message = "Authentication required. Please provide a valid JWT token.",
sessionId = sessionId,
count = 0,
events = Array.Empty<object>()
});
return unauthorizedResponse;
}
// Get events from storage
var events = await _storageService.GetSessionEventsAsync(tenantId, sessionId);
// Get user's tenant ID and identifier from JWT
var userTenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
// Get requested tenant ID from query parameter
var query = HttpUtility.ParseQueryString(req.Url.Query);
var requestedTenantId = query["tenantId"];
if (string.IsNullOrEmpty(requestedTenantId))
{
_logger.LogWarning($"{sessionPrefix} Missing tenantId query parameter");
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new
{
success = false,
message = "tenantId query parameter is required",
sessionId = sessionId,
count = 0,
events = Array.Empty<object>()
});
return badRequest;
}
// Validate tenant access: user must either own the tenant or be Galactic Admin
if (requestedTenantId != userTenantId)
{
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userIdentifier);
if (!isGalacticAdmin)
{
_logger.LogWarning($"{sessionPrefix} User {userIdentifier} (tenant {userTenantId}) attempted to access session events for tenant {requestedTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new
{
success = false,
message = "Access denied. You can only view events for sessions in your own tenant.",
sessionId = sessionId,
count = 0,
events = Array.Empty<object>()
});
return forbiddenResponse;
}
else
{
_logger.LogInformation($"{sessionPrefix} Galactic Admin {userIdentifier} accessing cross-tenant session events (tenant: {requestedTenantId})");
}
}
// Get events from storage using the requested tenant ID
var events = await _storageService.GetSessionEventsAsync(requestedTenantId, sessionId);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
@@ -26,6 +26,21 @@ namespace AutopilotMonitor.Functions.Functions
try
{
// Validate authentication
if (!TenantHelper.IsAuthenticated(req))
{
_logger.LogWarning("Unauthenticated GetSessions attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauthorizedResponse.WriteAsJsonAsync(new
{
success = false,
message = "Authentication required. Please provide a valid JWT token.",
count = 0,
sessions = Array.Empty<object>()
});
return unauthorizedResponse;
}
var tenantId = TenantHelper.GetTenantId(req);
_logger.LogInformation($"Fetching sessions for tenant {tenantId}");
@@ -29,17 +29,10 @@ namespace AutopilotMonitor.Functions.Functions
{
try
{
// Get tenant ID from JWT token and validate access
string authenticatedTenantId;
string userIdentifier;
try
// Validate authentication
if (!TenantHelper.IsAuthenticated(req))
{
authenticatedTenantId = TenantHelper.GetTenantId(req);
userIdentifier = TenantHelper.GetUserIdentifier(req);
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning($"Unauthorized get tenant configuration attempt for tenant {tenantId}: {ex.Message}");
_logger.LogWarning($"Unauthenticated GetTenantConfiguration attempt for tenant {tenantId}");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauthorizedResponse.WriteAsJsonAsync(new
{
@@ -49,6 +42,10 @@ namespace AutopilotMonitor.Functions.Functions
return unauthorizedResponse;
}
// Get tenant ID from JWT token and validate access
string authenticatedTenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
// Validate tenant access: User can only access their own tenant's configuration
if (authenticatedTenantId != tenantId)
{
@@ -58,8 +58,7 @@ namespace AutopilotMonitor.Functions.Functions
_logger.LogInformation("Detailed health check requested");
// Validate authentication
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated != true)
if (!TenantHelper.IsAuthenticated(req))
{
_logger.LogWarning("Unauthenticated detailed health check attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
@@ -193,17 +193,21 @@ namespace AutopilotMonitor.Functions.Functions
// Send SignalR notifications using Groups for multi-tenancy and cost optimization
// Include session data so frontend doesn't need to fetch it (cost optimization)
// 1. Summary notification for session list updates (tenant-specific)
var messagePayload = new {
sessionId = request.SessionId,
tenantId = request.TenantId,
eventCount = processedCount,
session = updatedSession // Include full session data
};
// 1. Summary notification for session list updates (tenant-specific only)
// Only sent to clients in the tenant group - prevents cross-tenant data leaks
// Galactic Admins only receive "newSession" events from RegisterSessionFunction,
// not every event batch, to avoid flooding them with updates
var summaryMessage = new SignalRMessageAction("newevents")
{
GroupName = $"tenant-{request.TenantId}",
Arguments = new[] { new {
sessionId = request.SessionId,
tenantId = request.TenantId,
eventCount = processedCount,
session = updatedSession // Include full session data
} }
Arguments = new[] { messagePayload }
};
// 2. Detailed events for real-time event streaming on detail pages (session-specific)
@@ -29,16 +29,10 @@ namespace AutopilotMonitor.Functions.Functions
try
{
string tenantId;
string userIdentifier;
try
// Validate authentication
if (!TenantHelper.IsAuthenticated(req))
{
tenantId = TenantHelper.GetTenantId(req);
userIdentifier = TenantHelper.GetUserIdentifier(req);
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning($"Unauthorized mark-failed attempt for session {sessionId}: {ex.Message}");
_logger.LogWarning($"Unauthenticated MarkSessionFailed attempt for session {sessionId}");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauthorizedResponse.WriteAsJsonAsync(new
{
@@ -48,6 +42,9 @@ namespace AutopilotMonitor.Functions.Functions
return new MarkSessionFailedOutput { HttpResponse = unauthorizedResponse };
}
string tenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
_logger.LogInformation($"Marking session {sessionId} as failed for tenant {tenantId} by user {userIdentifier}");
// Update session status to Failed with manual failure reason
@@ -86,22 +83,26 @@ namespace AutopilotMonitor.Functions.Functions
message = $"Session {sessionId} marked as failed"
});
// Send SignalR notification to update all clients
var signalRMessage = new SignalRMessageAction("newevents")
var messagePayload = new {
sessionId = sessionId,
tenantId = tenantId,
eventCount = 0,
session = updatedSession
};
// Send SignalR notification to update all clients in the tenant
// Only sent to tenant-specific group (not galactic-admins) to avoid flooding
// Galactic Admins can refresh or view session details to see status changes
var tenantMessage = new SignalRMessageAction("newevents")
{
GroupName = $"tenant-{tenantId}",
Arguments = new[] { new {
sessionId = sessionId,
tenantId = tenantId,
eventCount = 0,
session = updatedSession
} }
Arguments = new[] { messagePayload }
};
return new MarkSessionFailedOutput
{
HttpResponse = response,
SignalRMessages = new[] { signalRMessage }
SignalRMessages = new[] { tenantMessage }
};
}
else
@@ -40,8 +40,7 @@ namespace AutopilotMonitor.Functions.Functions
try
{
// Validate authentication
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated != true)
if (!TenantHelper.IsAuthenticated(req))
{
_logger.LogWarning("Unauthenticated platform usage metrics attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
@@ -4,6 +4,7 @@ using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
@@ -29,7 +30,7 @@ namespace AutopilotMonitor.Functions.Functions
}
[Function("RegisterSession")]
public async Task<HttpResponseData> Run(
public async Task<RegisterSessionOutput> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "sessions/register")] HttpRequestData req)
{
_logger.LogInformation("RegisterSession function processing request");
@@ -42,22 +43,23 @@ namespace AutopilotMonitor.Functions.Functions
if (request?.Registration == null)
{
return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "Invalid request payload");
var errorResponse = await CreateErrorResponse(req, HttpStatusCode.BadRequest, "Invalid request payload");
return new RegisterSessionOutput { HttpResponse = errorResponse };
}
var registration = request.Registration;
// Validate request security (certificate, rate limit, hardware whitelist)
var (validation, errorResponse) = await req.ValidateSecurityAsync(
var (validation, errorResponse2) = await req.ValidateSecurityAsync(
registration.TenantId,
_configService,
_rateLimitService,
_logger
);
if (errorResponse != null)
if (errorResponse2 != null)
{
return errorResponse;
return new RegisterSessionOutput { HttpResponse = errorResponse2 };
}
_logger.LogInformation($"Registering session {registration.SessionId} for tenant {registration.TenantId} (Device: {validation.CertificateThumbprint})");
@@ -67,9 +69,13 @@ namespace AutopilotMonitor.Functions.Functions
if (!stored)
{
return await CreateErrorResponse(req, HttpStatusCode.InternalServerError, "Failed to store session");
var errorResponse = await CreateErrorResponse(req, HttpStatusCode.InternalServerError, "Failed to store session");
return new RegisterSessionOutput { HttpResponse = errorResponse };
}
// Retrieve the stored session to include full data in SignalR message
var session = await _storageService.GetSessionAsync(registration.TenantId, registration.SessionId);
var response = req.CreateResponse(HttpStatusCode.OK);
var responseData = new RegisterSessionResponse
{
@@ -80,12 +86,42 @@ namespace AutopilotMonitor.Functions.Functions
};
await response.WriteAsJsonAsync(responseData);
return response;
// Send SignalR notification for new session registration
// This is sent to BOTH tenant-specific group AND galactic-admins group
// so Galactic Admins can see new sessions from all tenants without being
// flooded with every single event update
var messagePayload = new {
sessionId = registration.SessionId,
tenantId = registration.TenantId,
session = session
};
// 1. Tenant-specific notification (normal users)
var tenantMessage = new SignalRMessageAction("newSession")
{
GroupName = $"tenant-{registration.TenantId}",
Arguments = new[] { messagePayload }
};
// 2. Galactic Admins notification (cross-tenant visibility)
var galacticAdminMessage = new SignalRMessageAction("newSession")
{
GroupName = "galactic-admins",
Arguments = new[] { messagePayload }
};
return new RegisterSessionOutput
{
HttpResponse = response,
SignalRMessages = new[] { tenantMessage, galacticAdminMessage }
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error registering session");
return await CreateErrorResponse(req, HttpStatusCode.InternalServerError, "Internal server error");
var errorResponse = await CreateErrorResponse(req, HttpStatusCode.InternalServerError, "Internal server error");
return new RegisterSessionOutput { HttpResponse = errorResponse };
}
}
@@ -102,4 +138,13 @@ namespace AutopilotMonitor.Functions.Functions
return response;
}
}
public class RegisterSessionOutput
{
[HttpResult]
public HttpResponseData? HttpResponse { get; set; }
[SignalROutput(HubName = "autopilotmonitor")]
public SignalRMessageAction[]? SignalRMessages { get; set; }
}
}
@@ -1,5 +1,6 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
@@ -11,10 +12,14 @@ namespace AutopilotMonitor.Functions.Functions
public class SignalRAddToGroupFunction
{
private readonly ILogger<SignalRAddToGroupFunction> _logger;
private readonly GalacticAdminService _galacticAdminService;
public SignalRAddToGroupFunction(ILogger<SignalRAddToGroupFunction> logger)
public SignalRAddToGroupFunction(
ILogger<SignalRAddToGroupFunction> logger,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_galacticAdminService = galacticAdminService;
}
[Function("AddToGroup")]
@@ -24,8 +29,7 @@ namespace AutopilotMonitor.Functions.Functions
try
{
// Validate authentication
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated != true)
if (!TenantHelper.IsAuthenticated(req))
{
_logger.LogWarning("Unauthenticated AddToGroup attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
@@ -50,17 +54,27 @@ namespace AutopilotMonitor.Functions.Functions
// Validate tenant access
// Group names are in format: "tenant-{tenantId}" or "session-{tenantId}-{sessionId}"
// Users can only join groups for their own tenant
// Users can only join groups for their own tenant (unless they are Galactic Admin)
var requestedTenantId = ExtractTenantIdFromGroupName(request.GroupName);
if (!string.IsNullOrEmpty(requestedTenantId))
{
// Check if user is allowed to join this tenant's group
if (requestedTenantId != userTenantId)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to join group for tenant {requestedTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: You can only join groups for your own tenant" });
return new AddToGroupOutput { HttpResponse = forbiddenResponse };
// Check if user is Galactic Admin (they can join any tenant's group)
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userEmail);
if (!isGalacticAdmin)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to join group for tenant {requestedTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: You can only join groups for your own tenant" });
return new AddToGroupOutput { HttpResponse = forbiddenResponse };
}
else
{
_logger.LogInformation($"Galactic Admin {userEmail} joining cross-tenant group: {request.GroupName}");
}
}
}
@@ -24,8 +24,22 @@ namespace AutopilotMonitor.Functions.Functions
_logger.LogInformation("SignalR negotiate request");
// Validate authentication (middleware already validated JWT)
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated != true)
// Azure Functions Isolated Worker: Check FunctionContext.Items first
bool isAuthenticated = false;
if (req.FunctionContext.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
&& principalObj is System.Security.Claims.ClaimsPrincipal principal)
{
isAuthenticated = principal.Identity?.IsAuthenticated == true;
}
else
{
// Fallback to HTTP context
var httpContext = req.FunctionContext.GetHttpContext();
isAuthenticated = httpContext?.User?.Identity?.IsAuthenticated == true;
}
if (!isAuthenticated)
{
_logger.LogWarning("Unauthenticated SignalR negotiate attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
@@ -1,5 +1,6 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
@@ -11,10 +12,14 @@ namespace AutopilotMonitor.Functions.Functions
public class SignalRRemoveFromGroupFunction
{
private readonly ILogger<SignalRRemoveFromGroupFunction> _logger;
private readonly GalacticAdminService _galacticAdminService;
public SignalRRemoveFromGroupFunction(ILogger<SignalRRemoveFromGroupFunction> logger)
public SignalRRemoveFromGroupFunction(
ILogger<SignalRRemoveFromGroupFunction> logger,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_galacticAdminService = galacticAdminService;
}
[Function("RemoveFromGroup")]
@@ -24,8 +29,7 @@ namespace AutopilotMonitor.Functions.Functions
try
{
// Validate authentication
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated != true)
if (!TenantHelper.IsAuthenticated(req))
{
_logger.LogWarning("Unauthenticated RemoveFromGroup attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
@@ -44,8 +48,36 @@ namespace AutopilotMonitor.Functions.Functions
return new RemoveFromGroupOutput { HttpResponse = errorResponse };
}
// Get user's tenant ID from JWT
var userTenantId = TenantHelper.GetTenantId(req);
var userEmail = TenantHelper.GetUserIdentifier(req);
// Validate tenant access (same logic as AddToGroup)
// Group names are in format: "tenant-{tenantId}" or "session-{tenantId}-{sessionId}"
// Users can only leave groups for their own tenant (unless they are Galactic Admin)
var requestedTenantId = ExtractTenantIdFromGroupName(request.GroupName);
if (!string.IsNullOrEmpty(requestedTenantId))
{
// Check if user is allowed to leave this tenant's group
if (requestedTenantId != userTenantId)
{
// Check if user is Galactic Admin (they can leave any tenant's group)
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userEmail);
if (!isGalacticAdmin)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to leave group for tenant {requestedTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: You can only leave groups for your own tenant" });
return new RemoveFromGroupOutput { HttpResponse = forbiddenResponse };
}
else
{
_logger.LogInformation($"Galactic Admin {userEmail} leaving cross-tenant group: {request.GroupName}");
}
}
}
// Extract session ID from group name if it's a session-specific group
var logPrefix = ExtractLogPrefix(request.GroupName);
_logger.LogInformation($"{logPrefix} RemoveFromGroup: {request.GroupName} (User: {userEmail})");
@@ -76,6 +108,31 @@ namespace AutopilotMonitor.Functions.Functions
}
}
/// <summary>
/// Extracts tenant ID from SignalR group name
/// Group formats: "tenant-{tenantId}" or "session-{tenantId}-{sessionId}"
/// </summary>
private string? ExtractTenantIdFromGroupName(string groupName)
{
if (groupName.StartsWith("tenant-"))
{
// Format: "tenant-{tenantId}"
return groupName.Substring("tenant-".Length);
}
else if (groupName.StartsWith("session-"))
{
// Format: "session-{tenantId}-{sessionId}"
// Extract everything between "session-" and the last 5 GUID segments
var parts = groupName.Split('-');
if (parts.Length >= 7) // "session" + 5 GUID parts (tenant) + 5 GUID parts (session)
{
// Reconstruct tenant GUID from parts 1-5
return string.Join("-", parts.Skip(1).Take(5));
}
}
return null;
}
private string ExtractLogPrefix(string groupName)
{
// Extract session ID from group name: "session-{tenantId}-{sessionId}"
@@ -44,8 +44,7 @@ namespace AutopilotMonitor.Functions.Functions
_logger.LogInformation("Manual maintenance trigger requested");
// Validate authentication
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated != true)
if (!TenantHelper.IsAuthenticated(req))
{
_logger.LogWarning("Unauthenticated maintenance trigger attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
@@ -0,0 +1,108 @@
using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
{
public class UpdateAdminConfigurationFunction
{
private readonly ILogger<UpdateAdminConfigurationFunction> _logger;
private readonly AdminConfigurationService _adminConfigService;
private readonly GalacticAdminService _galacticAdminService;
public UpdateAdminConfigurationFunction(
ILogger<UpdateAdminConfigurationFunction> logger,
AdminConfigurationService adminConfigService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_adminConfigService = adminConfigService;
_galacticAdminService = galacticAdminService;
}
[Function("UpdateAdminConfiguration")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", "post", Route = "global/config")] HttpRequestData req)
{
try
{
// Validate authentication
if (!TenantHelper.IsAuthenticated(req))
{
_logger.LogWarning("Unauthenticated UpdateAdminConfiguration attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauthorizedResponse.WriteAsJsonAsync(new
{
success = false,
message = "Authentication required. Please provide a valid JWT token."
});
return unauthorizedResponse;
}
// Get tenant ID and user identifier from JWT token
string tenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
// Validate Galactic Admin role
if (!await _galacticAdminService.IsGalacticAdminAsync(userIdentifier))
{
_logger.LogWarning($"Non-Galactic Admin user {userIdentifier} from tenant {tenantId} attempted to update admin configuration");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new
{
success = false,
message = "Access denied. Only Galactic Admins can modify global configuration."
});
return forbiddenResponse;
}
_logger.LogInformation($"UpdateAdminConfiguration by Galactic Admin user {userIdentifier}");
// Parse request body
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var config = JsonConvert.DeserializeObject<AdminConfiguration>(requestBody);
if (config == null)
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { error = "Invalid configuration" });
return badRequest;
}
// Set the actual user identifier for audit logging
config.UpdatedBy = userIdentifier;
// Ensure PartitionKey and RowKey are correct
config.PartitionKey = "GlobalConfig";
config.RowKey = "config";
// Save configuration
await _adminConfigService.SaveConfigurationAsync(config);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
message = "Admin configuration updated successfully",
config = config
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating admin configuration");
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { error = "Internal server error" });
return response;
}
}
}
}
@@ -32,17 +32,10 @@ namespace AutopilotMonitor.Functions.Functions
{
try
{
// Get tenant ID from JWT token and validate access
string authenticatedTenantId;
string userIdentifier;
try
// Validate authentication
if (!TenantHelper.IsAuthenticated(req))
{
authenticatedTenantId = TenantHelper.GetTenantId(req);
userIdentifier = TenantHelper.GetUserIdentifier(req);
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning($"Unauthorized update tenant configuration attempt for tenant {tenantId}: {ex.Message}");
_logger.LogWarning($"Unauthenticated UpdateTenantConfiguration attempt for tenant {tenantId}");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauthorizedResponse.WriteAsJsonAsync(new
{
@@ -52,6 +45,10 @@ namespace AutopilotMonitor.Functions.Functions
return unauthorizedResponse;
}
// Get tenant ID from JWT token and validate access
string authenticatedTenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
// Validate tenant access: User can only update their own tenant's configuration
if (authenticatedTenantId != tenantId)
{
@@ -36,16 +36,10 @@ namespace AutopilotMonitor.Functions.Functions
try
{
string tenantId;
string userIdentifier;
try
// Validate authentication
if (!TenantHelper.IsAuthenticated(req))
{
tenantId = TenantHelper.GetTenantId(req);
userIdentifier = TenantHelper.GetUserIdentifier(req);
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning($"Unauthorized usage metrics attempt: {ex.Message}");
_logger.LogWarning("Unauthenticated UsageMetrics attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauthorizedResponse.WriteAsJsonAsync(new
{
@@ -55,6 +49,9 @@ namespace AutopilotMonitor.Functions.Functions
return unauthorizedResponse;
}
string tenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
_logger.LogInformation($"Fetching usage metrics for tenant {tenantId} by user {userIdentifier}");
var metrics = await _usageMetricsService.ComputeTenantUsageMetricsAsync(tenantId);
@@ -9,6 +9,27 @@ namespace AutopilotMonitor.Functions.Helpers;
/// </summary>
public static class TenantHelper
{
/// <summary>
/// Checks if the request has an authenticated user
/// Azure Functions Isolated Worker compatible
/// </summary>
/// <param name="req">The HTTP request</param>
/// <returns>True if user is authenticated, false otherwise</returns>
public static bool IsAuthenticated(HttpRequestData req)
{
// Azure Functions Isolated Worker: Try FunctionContext.Items first
// This is set by AuthenticationMiddleware and is more reliable than httpContext.User
if (req.FunctionContext.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
&& principalObj is ClaimsPrincipal principal)
{
return principal.Identity?.IsAuthenticated == true;
}
// Fallback to HTTP context (may not work reliably in isolated worker)
var httpContext = req.FunctionContext.GetHttpContext();
return httpContext?.User?.Identity?.IsAuthenticated == true;
}
/// <summary>
/// Extracts the tenant ID from the authenticated user's JWT token claims.
/// Uses the Azure AD tenant ID ('tid' claim) which identifies which customer/organization owns the data.
@@ -20,16 +41,30 @@ public static class TenantHelper
/// <exception cref="UnauthorizedAccessException">Thrown when user is not authenticated or tenant ID is missing</exception>
public static string GetTenantId(HttpRequestData req)
{
var httpContext = req.FunctionContext.GetHttpContext();
var isAuthenticated = httpContext?.User?.Identity?.IsAuthenticated == true;
// Azure Functions Isolated Worker: Try FunctionContext.Items first
// This is set by AuthenticationMiddleware and is more reliable than httpContext.User
ClaimsPrincipal? user = null;
if (!isAuthenticated)
if (req.FunctionContext.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
&& principalObj is ClaimsPrincipal principal)
{
user = principal;
}
else
{
// Fallback to HTTP context (may not work reliably in isolated worker)
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated == true)
{
user = httpContext.User;
}
}
if (user?.Identity?.IsAuthenticated != true)
{
throw new UnauthorizedAccessException("User is not authenticated. JWT token required.");
}
var user = httpContext!.User;
// Extract tenant ID from JWT 'tid' claim (Azure AD tenant ID)
var tenantIdClaim = user.FindFirst("tid")?.Value;
if (string.IsNullOrEmpty(tenantIdClaim))
@@ -48,16 +83,34 @@ public static class TenantHelper
/// <returns>User email or name, or "Anonymous" if not authenticated</returns>
public static string GetUserIdentifier(HttpRequestData req)
{
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated != true)
// Azure Functions Isolated Worker: Try FunctionContext.Items first
ClaimsPrincipal? user = null;
if (req.FunctionContext.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
&& principalObj is ClaimsPrincipal principal)
{
user = principal;
}
else
{
// Fallback to HTTP context
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated == true)
{
user = httpContext.User;
}
}
if (user?.Identity?.IsAuthenticated != true)
{
return "Anonymous";
}
var user = httpContext.User;
// Try to get email first, then preferred_username, then name
return user.FindFirst(ClaimTypes.Email)?.Value ??
// Try to get UPN first (Azure AD User Principal Name - most reliable identifier)
// Then fall back to email, preferred_username, and finally name
return user.FindFirst("upn")?.Value ??
user.FindFirst(ClaimTypes.Upn)?.Value ??
user.FindFirst(ClaimTypes.Email)?.Value ??
user.FindFirst("preferred_username")?.Value ??
user.FindFirst(ClaimTypes.Name)?.Value ??
user.FindFirst("name")?.Value ??
@@ -21,7 +21,14 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
private readonly ILogger<AuthenticationMiddleware> _logger;
private readonly IConfiguration _configuration;
private readonly JsonWebTokenHandler _tokenHandler;
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configurationManager;
// Cache configuration managers per tenant to avoid repeated OIDC metadata fetches
private readonly Dictionary<string, IConfigurationManager<OpenIdConnectConfiguration>> _configManagerCache;
private readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1, 1);
// Track if we're currently initializing a tenant to prevent race conditions
private readonly HashSet<string> _initializingTenants = new HashSet<string>();
private readonly object _initLock = new object();
public AuthenticationMiddleware(
ILogger<AuthenticationMiddleware> logger,
@@ -30,16 +37,10 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
_logger = logger;
_configuration = configuration;
_tokenHandler = new JsonWebTokenHandler();
_configManagerCache = new Dictionary<string, IConfigurationManager<OpenIdConnectConfiguration>>();
// Disable PII logging in production for security
Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = false;
// Set up OpenID Connect configuration manager to get signing keys
var authority = "https://login.microsoftonline.com/organizations/v2.0";
var metadataAddress = $"{authority}/.well-known/openid-configuration";
_configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
metadataAddress,
new OpenIdConnectConfigurationRetriever());
}
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
@@ -77,13 +78,37 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
: $"https://login.microsoftonline.com/{tenantId}/v2.0") // v2.0
: "https://login.microsoftonline.com/common/v2.0";
var tenantMetadataAddress = $"{tenantSpecificAuthority}/.well-known/openid-configuration";
var tenantConfigManager = new ConfigurationManager<OpenIdConnectConfiguration>(
tenantMetadataAddress,
new OpenIdConnectConfigurationRetriever());
// Get or create cached configuration manager for this tenant
IConfigurationManager<OpenIdConnectConfiguration>? tenantConfigManager = null;
await _cacheLock.WaitAsync();
try
{
if (!_configManagerCache.TryGetValue(tenantSpecificAuthority, out tenantConfigManager))
{
var tenantMetadataAddress = $"{tenantSpecificAuthority}/.well-known/openid-configuration";
tenantConfigManager = new ConfigurationManager<OpenIdConnectConfiguration>(
tenantMetadataAddress,
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever() { RequireHttps = true })
{
// Cache configuration for 24 hours (signing keys rarely change)
AutomaticRefreshInterval = TimeSpan.FromHours(24),
RefreshInterval = TimeSpan.FromHours(24)
};
_configManagerCache[tenantSpecificAuthority] = tenantConfigManager;
_logger.LogInformation($"[Auth Middleware] Created new config manager for {tenantSpecificAuthority}");
}
}
finally
{
_cacheLock.Release();
}
if (tenantConfigManager == null)
{
throw new InvalidOperationException("Failed to get or create configuration manager");
}
// Request refresh to ensure we get the latest signing keys
tenantConfigManager.RequestRefresh();
var openIdConfig = await tenantConfigManager.GetConfigurationAsync(CancellationToken.None);
_logger.LogInformation($"[Auth Middleware] Loaded {openIdConfig.SigningKeys.Count} signing keys from {tenantSpecificAuthority} (v{(isV1Token ? "1.0" : "2.0")} token)");
@@ -153,11 +178,22 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
{
var principal = new ClaimsPrincipal(validationResult.ClaimsIdentity);
_logger.LogInformation($"[Auth Middleware] Token validated successfully. Claims count: {principal.Claims.Count()}");
_logger.LogInformation($"[Auth Middleware] User: {principal.Identity?.Name}, Authenticated: {principal.Identity?.IsAuthenticated}");
// Get user identifier for logging (same logic as TenantHelper.GetUserIdentifier)
var userIdentifier = principal.FindFirst("upn")?.Value ??
principal.FindFirst(ClaimTypes.Upn)?.Value ??
principal.FindFirst(ClaimTypes.Email)?.Value ??
principal.FindFirst("preferred_username")?.Value ??
principal.FindFirst(ClaimTypes.Name)?.Value ??
principal.FindFirst("name")?.Value ??
"Unknown";
// Set the principal on the HTTP context
_logger.LogInformation($"[Auth Middleware] Token validated successfully. Claims count: {principal.Claims.Count()}");
_logger.LogInformation($"[Auth Middleware] User: {userIdentifier}, Authenticated: {principal.Identity?.IsAuthenticated}");
// Set the principal on both the HTTP context AND the FunctionContext
// This is critical for Azure Functions Isolated Worker (.NET 8)
httpContext.User = principal;
context.Items["ClaimsPrincipal"] = principal;
}
else
{
@@ -92,6 +92,7 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<TableStorageService>();
builder.Services.AddSingleton<TenantConfigurationService>();
builder.Services.AddSingleton<AdminConfigurationService>();
builder.Services.AddSingleton<RateLimitService>();
builder.Services.AddSingleton<UsageMetricsService>();
builder.Services.AddSingleton<GalacticAdminService>();
@@ -40,12 +40,7 @@ namespace AutopilotMonitor.Functions.Security
// Load tenant configuration
var config = await _configService.GetConfigurationAsync(tenantId);
// If security is disabled for this tenant, allow all requests
if (!config.SecurityEnabled)
{
_logger.LogWarning($"Security validation disabled for tenant {tenantId}");
return new SecurityValidationResult { IsValid = true };
}
// Security validation is always enforced (no longer configurable per tenant)
// 1. Validate client certificate
var certHeader = req.Headers.Contains("X-Client-Certificate")
@@ -65,9 +60,12 @@ namespace AutopilotMonitor.Functions.Security
}
// 2. Check rate limit (DoS protection)
// Use custom tenant rate limit if set, otherwise use the synced global rate limit
var rateLimitValue = config.CustomRateLimitRequestsPerMinute ?? config.RateLimitRequestsPerMinute;
var rateLimitResult = _rateLimitService.CheckRateLimit(
certValidation.Thumbprint!,
config.RateLimitRequestsPerMinute
rateLimitValue
);
if (!rateLimitResult.IsAllowed)
@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Service for managing global admin configuration in Azure Table Storage
/// </summary>
public class AdminConfigurationService
{
private readonly TableClient _adminTableClient;
private readonly TableClient _tenantConfigTableClient;
private readonly ILogger<AdminConfigurationService> _logger;
// In-memory cache for admin configuration (to avoid table lookups on every request)
private AdminConfiguration? _cachedConfig;
private DateTime? _cacheTime;
public AdminConfigurationService(IConfiguration configuration, ILogger<AdminConfigurationService> logger)
{
_logger = logger;
var connectionString = configuration["AzureWebJobsStorage"];
var serviceClient = new TableServiceClient(connectionString);
_adminTableClient = serviceClient.GetTableClient("AdminConfiguration");
_adminTableClient.CreateIfNotExists();
_tenantConfigTableClient = serviceClient.GetTableClient("TenantConfiguration");
_tenantConfigTableClient.CreateIfNotExists();
}
/// <summary>
/// Gets global admin configuration (uses cache with 5-minute TTL)
/// </summary>
public async Task<AdminConfiguration> GetConfigurationAsync()
{
// Check cache first (5-minute TTL)
if (_cachedConfig != null && _cacheTime.HasValue)
{
if (DateTime.UtcNow.Subtract(_cacheTime.Value).TotalMinutes < 5)
{
return _cachedConfig;
}
}
try
{
// Load from Table Storage
var entity = await _adminTableClient.GetEntityAsync<TableEntity>("GlobalConfig", "config");
var config = ConvertFromTableEntity(entity.Value);
// Update cache
_cachedConfig = config;
_cacheTime = DateTime.UtcNow;
return config;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Configuration not found - create and save default immediately
_logger.LogInformation("Admin configuration not found, creating and saving default configuration");
var defaultConfig = AdminConfiguration.CreateDefault();
try
{
// Save the default configuration to Table Storage
var entity = ConvertToTableEntity(defaultConfig);
await _adminTableClient.UpsertEntityAsync(entity);
// Update cache
_cachedConfig = defaultConfig;
_cacheTime = DateTime.UtcNow;
_logger.LogInformation("Default admin configuration created and saved");
}
catch (Exception saveEx)
{
_logger.LogError(saveEx, "Failed to save default admin configuration");
}
return defaultConfig;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading admin configuration");
// Return default on error (fail-open)
return AdminConfiguration.CreateDefault();
}
}
/// <summary>
/// Saves global admin configuration and syncs rate limit to all tenant configurations
/// </summary>
public async Task SaveConfigurationAsync(AdminConfiguration config)
{
if (config == null)
{
throw new ArgumentException("Configuration is required");
}
try
{
config.LastUpdated = DateTime.UtcNow;
var entity = ConvertToTableEntity(config);
await _adminTableClient.UpsertEntityAsync(entity);
// Invalidate cache
_cachedConfig = null;
_cacheTime = null;
_logger.LogInformation($"Admin configuration saved by {config.UpdatedBy}");
// Sync global rate limit to all tenant configurations (only if not custom)
await SyncRateLimitToTenantsAsync(config.GlobalRateLimitRequestsPerMinute);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving admin configuration");
throw;
}
}
/// <summary>
/// Syncs the global rate limit to all tenant configurations that don't have a custom override
/// </summary>
private async Task SyncRateLimitToTenantsAsync(int globalRateLimit)
{
try
{
_logger.LogInformation($"Syncing global rate limit ({globalRateLimit}) to all tenant configurations...");
var tenantsUpdated = 0;
var tenantsSkipped = 0;
// Query all tenant configurations
var query = _tenantConfigTableClient.QueryAsync<TableEntity>(filter: $"RowKey eq 'config'");
await foreach (var entity in query)
{
// Check if tenant has a custom rate limit override
var customRateLimit = entity.GetInt32("CustomRateLimitRequestsPerMinute");
if (customRateLimit.HasValue)
{
// Skip tenants with custom rate limit
tenantsSkipped++;
_logger.LogDebug($"Skipping tenant {entity.PartitionKey} - has custom rate limit: {customRateLimit}");
continue;
}
// Update tenant's rate limit to match global default
entity["RateLimitRequestsPerMinute"] = globalRateLimit;
entity["LastUpdated"] = DateTime.UtcNow;
entity["UpdatedBy"] = "System (Global Rate Limit Sync)";
await _tenantConfigTableClient.UpdateEntityAsync(entity, ETag.All, TableUpdateMode.Replace);
tenantsUpdated++;
}
_logger.LogInformation($"Rate limit sync completed: {tenantsUpdated} tenants updated, {tenantsSkipped} tenants skipped (custom rate limit)");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error syncing rate limit to tenant configurations");
// Don't throw - we don't want to fail the admin config save if sync fails
}
}
/// <summary>
/// Invalidates cache (forces reload on next request)
/// </summary>
public void InvalidateCache()
{
_cachedConfig = null;
_cacheTime = null;
}
private TableEntity ConvertToTableEntity(AdminConfiguration config)
{
var entity = new TableEntity("GlobalConfig", "config")
{
{ "LastUpdated", config.LastUpdated },
{ "UpdatedBy", config.UpdatedBy },
{ "GlobalRateLimitRequestsPerMinute", config.GlobalRateLimitRequestsPerMinute },
{ "CustomSettings", config.CustomSettings }
};
return entity;
}
private AdminConfiguration ConvertFromTableEntity(TableEntity entity)
{
return new AdminConfiguration
{
PartitionKey = entity.PartitionKey,
RowKey = entity.RowKey,
LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow,
UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown",
GlobalRateLimitRequestsPerMinute = entity.GetInt32("GlobalRateLimitRequestsPerMinute") ?? 100,
CustomSettings = entity.GetString("CustomSettings")
};
}
}
}
@@ -145,8 +145,8 @@ namespace AutopilotMonitor.Functions.Services
{
{ "LastUpdated", config.LastUpdated },
{ "UpdatedBy", config.UpdatedBy },
{ "SecurityEnabled", config.SecurityEnabled },
{ "RateLimitRequestsPerMinute", config.RateLimitRequestsPerMinute },
{ "CustomRateLimitRequestsPerMinute", config.CustomRateLimitRequestsPerMinute },
{ "ManufacturerWhitelist", config.ManufacturerWhitelist },
{ "ModelWhitelist", config.ModelWhitelist },
{ "ValidateSerialNumber", config.ValidateSerialNumber },
@@ -166,8 +166,8 @@ namespace AutopilotMonitor.Functions.Services
TenantId = entity.PartitionKey,
LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow,
UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown",
SecurityEnabled = entity.GetBoolean("SecurityEnabled") ?? true,
RateLimitRequestsPerMinute = entity.GetInt32("RateLimitRequestsPerMinute") ?? 100,
CustomRateLimitRequestsPerMinute = entity.GetInt32("CustomRateLimitRequestsPerMinute"),
ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation",
ModelWhitelist = entity.GetString("ModelWhitelist") ?? "*",
ValidateSerialNumber = entity.GetBoolean("ValidateSerialNumber") ?? false,
@@ -0,0 +1,64 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Global platform configuration managed by Galactic Admins
/// Stored in Azure Table Storage with single instance
/// PartitionKey = "GlobalConfig"
/// RowKey = "config"
/// </summary>
public class AdminConfiguration
{
/// <summary>
/// Partition key (always "GlobalConfig")
/// </summary>
public string PartitionKey { get; set; } = "GlobalConfig";
/// <summary>
/// Row key (always "config")
/// </summary>
public string RowKey { get; set; } = "config";
/// <summary>
/// When the configuration was last updated
/// </summary>
public DateTime LastUpdated { get; set; }
/// <summary>
/// Updated by (Galactic Admin user email)
/// </summary>
public string UpdatedBy { get; set; }
// ===== RATE LIMITING SETTINGS =====
/// <summary>
/// Global default rate limit: Maximum requests per minute per device
/// This applies to all tenants unless they have a custom override
/// Default: 100
/// </summary>
public int GlobalRateLimitRequestsPerMinute { get; set; } = 100;
// ===== FUTURE SETTINGS (Extensible) =====
/// <summary>
/// Custom settings as JSON (for future extensibility)
/// </summary>
public string CustomSettings { get; set; }
/// <summary>
/// Creates default configuration
/// </summary>
public static AdminConfiguration CreateDefault()
{
return new AdminConfiguration
{
PartitionKey = "GlobalConfig",
RowKey = "config",
LastUpdated = DateTime.UtcNow,
UpdatedBy = "System",
GlobalRateLimitRequestsPerMinute = 100
};
}
}
}
@@ -27,17 +27,20 @@ namespace AutopilotMonitor.Shared.Models
// ===== SECURITY SETTINGS =====
/// <summary>
/// Whether request security validation is enabled
/// </summary>
public bool SecurityEnabled { get; set; } = true;
/// <summary>
/// Rate limit: Maximum requests per minute per device
/// This value is synchronized from the global AdminConfiguration
/// Default: 100
/// </summary>
public int RateLimitRequestsPerMinute { get; set; } = 100;
/// <summary>
/// Optional custom rate limit for this tenant (overrides RateLimitRequestsPerMinute)
/// If set (not null), this custom value takes precedence over the global default
/// Note: This is only configurable by Galactic Admins directly in the database
/// </summary>
public int? CustomRateLimitRequestsPerMinute { get; set; } = null;
/// <summary>
/// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*")
/// Comma-separated list
@@ -125,8 +128,8 @@ namespace AutopilotMonitor.Shared.Models
TenantId = tenantId,
LastUpdated = DateTime.UtcNow,
UpdatedBy = "System",
SecurityEnabled = true,
RateLimitRequestsPerMinute = 100,
CustomRateLimitRequestsPerMinute = null,
ManufacturerWhitelist = "Dell*,HP*,Lenovo*,Microsoft Corporation",
ModelWhitelist = "*",
ValidateSerialNumber = false,
@@ -3,15 +3,32 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useAuth } from "../../contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
interface AdminConfiguration {
partitionKey: string;
rowKey: string;
lastUpdated: string;
updatedBy: string;
globalRateLimitRequestsPerMinute: number;
customSettings?: string;
}
export default function AdminConfigurationPage() {
const router = useRouter();
const { getAccessToken } = useAuth();
const [galacticAdminMode, setGalacticAdminMode] = useState(false);
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Admin Configuration state
const [adminConfig, setAdminConfig] = useState<AdminConfiguration | null>(null);
const [loadingConfig, setLoadingConfig] = useState(false);
const [savingConfig, setSavingConfig] = useState(false);
const [globalRateLimit, setGlobalRateLimit] = useState(100);
// Load galactic admin mode from localStorage
useEffect(() => {
const galacticMode = localStorage.getItem("galacticAdminMode") === "true";
@@ -23,14 +40,62 @@ export default function AdminConfigurationPage() {
}
}, [router]);
// Fetch admin configuration
useEffect(() => {
if (!galacticAdminMode) return;
const fetchAdminConfig = async () => {
try {
setLoadingConfig(true);
setError(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/global/config`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`Failed to load admin configuration: ${response.statusText}`);
}
const data: AdminConfiguration = await response.json();
setAdminConfig(data);
setGlobalRateLimit(data.globalRateLimitRequestsPerMinute);
} catch (err) {
console.error("Error fetching admin configuration:", err);
setError(err instanceof Error ? err.message : "Failed to load admin configuration");
} finally {
setLoadingConfig(false);
}
};
fetchAdminConfig();
}, [galacticAdminMode]);
const handleTriggerMaintenance = async () => {
try {
setTriggeringMaintenance(true);
setError(null);
setSuccessMessage(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/maintenance/trigger`, {
method: "POST",
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
@@ -50,6 +115,58 @@ export default function AdminConfigurationPage() {
}
};
const handleSaveAdminConfig = async () => {
if (!adminConfig) return;
try {
setSavingConfig(true);
setError(null);
setSuccessMessage(null);
const updatedConfig: AdminConfiguration = {
...adminConfig,
globalRateLimitRequestsPerMinute: globalRateLimit,
};
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/global/config`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(updatedConfig),
});
if (!response.ok) {
throw new Error(`Failed to save admin configuration: ${response.statusText}`);
}
const result = await response.json();
setAdminConfig(result.config);
setSuccessMessage("Admin configuration saved successfully!");
// Auto-hide success message after 3 seconds
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
console.error("Error saving admin configuration:", err);
setError(err instanceof Error ? err.message : "Failed to save admin configuration");
} finally {
setSavingConfig(false);
}
};
const handleResetAdminConfig = () => {
if (!adminConfig) return;
setGlobalRateLimit(adminConfig.globalRateLimitRequestsPerMinute);
setSuccessMessage(null);
setError(null);
};
if (!galacticAdminMode) {
return null;
}
@@ -103,6 +220,89 @@ export default function AdminConfigurationPage() {
</div>
)}
{/* Global Rate Limiting Configuration */}
<div className="bg-gradient-to-br from-indigo-50 to-blue-50 border-2 border-indigo-300 rounded-lg shadow-lg">
<div className="p-6 border-b border-indigo-200 bg-gradient-to-r from-indigo-100 to-blue-100">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-indigo-900">Global Rate Limiting</h2>
<p className="text-sm text-indigo-600 mt-1">Configure default DoS protection limits for all tenants</p>
</div>
</div>
</div>
<div className="p-6">
{loadingConfig ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-3 text-gray-600 text-sm">Loading configuration...</p>
</div>
) : (
<div className="space-y-4">
<div>
<label className="block">
<span className="text-gray-700 font-medium">Global Rate Limit (Requests per Minute per Device)</span>
<p className="text-sm text-gray-600 mb-2">
This limit applies to all tenants by default. Normal enrollment generates ~2-3 requests/min.
<br />
<strong className="text-indigo-700">Note:</strong> Tenants cannot change this value. Only Galactic Admins can override per tenant directly in the database.
</p>
<input
type="number"
min="1"
max="1000"
value={globalRateLimit}
onChange={(e) => setGlobalRateLimit(parseInt(e.target.value) || 100)}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</label>
</div>
{adminConfig && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start space-x-2">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-800">
<p className="font-medium">Configuration Info</p>
<p className="mt-1">Last updated: {new Date(adminConfig.lastUpdated).toLocaleString()}</p>
<p>Updated by: {adminConfig.updatedBy}</p>
</div>
</div>
</div>
)}
<div className="flex items-center justify-end space-x-3 pt-2">
<button
onClick={handleResetAdminConfig}
disabled={savingConfig}
className="px-5 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Reset
</button>
<button
onClick={handleSaveAdminConfig}
disabled={savingConfig}
className="px-5 py-2 bg-gradient-to-r from-indigo-600 to-blue-600 text-white rounded-md hover:from-indigo-700 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg flex items-center space-x-2"
>
{savingConfig ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Saving...</span>
</>
) : (
<span>Save Configuration</span>
)}
</button>
</div>
</div>
)}
</div>
</div>
{/* Manual Maintenance Trigger */}
<div className="bg-gradient-to-br from-purple-50 to-violet-50 border-2 border-purple-300 rounded-lg shadow-lg">
<div className="p-6 border-b border-purple-200 bg-gradient-to-r from-purple-100 to-violet-100">
+93 -16
View File
@@ -26,7 +26,7 @@ interface Session {
export default function Home() {
const router = useRouter();
const { user, logout } = useAuth();
const { user, logout, getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const [apiStatus, setApiStatus] = useState<"unchecked" | "checking" | "healthy" | "error">("unchecked");
const [sessions, setSessions] = useState<Session[]>([]);
@@ -64,17 +64,6 @@ export default function Home() {
// Track if we've joined the tenant group to prevent duplicate joins
const hasJoinedGroup = useRef(false);
// Manual health check function
const checkApiHealth = async () => {
setApiStatus("checking");
try {
const res = await fetch(`${API_BASE_URL}/api/health`);
setApiStatus(res.ok ? "healthy" : "error");
} catch {
setApiStatus("error");
}
};
const fetchSessions = async () => {
try {
// Use different endpoint based on galactic admin mode
@@ -82,7 +71,20 @@ export default function Home() {
? `${API_BASE_URL}/api/galactic/sessions`
: `${API_BASE_URL}/api/sessions?tenantId=${tenantId}`;
const response = await fetch(endpoint);
// Get access token and include Authorization header
const token = await getAccessToken();
if (!token) {
addNotification('error', 'Authentication Error', 'Failed to get access token. Please try logging in again.', 'auth-error');
return;
}
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
const allSessions = data.sessions || [];
@@ -130,9 +132,56 @@ export default function Home() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isConnected, tenantId]);
// Join/leave galactic-admins group when Galactic Admin mode changes
useEffect(() => {
if (!isConnected) return;
if (galacticAdminMode) {
console.log('[Home] Galactic Admin mode enabled: joining galactic-admins group');
joinGroup('galactic-admins');
} else {
console.log('[Home] Galactic Admin mode disabled: leaving galactic-admins group');
leaveGroup('galactic-admins');
}
return () => {
// Clean up galactic-admins group on unmount if currently in Galactic Admin mode
if (galacticAdminMode) {
console.log('[Home] Component unmounting: leaving galactic-admins group');
leaveGroup('galactic-admins');
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isConnected, galacticAdminMode]);
// Setup SignalR listener - re-register when connection changes
useEffect(() => {
// Listen for real-time updates via SignalR (no more polling or HTTP requests!)
// Listen for real-time updates via SignalR
// - "newSession" events: Sent when a session is first registered (new session notification)
// - "newevents" events: Sent when events are ingested (status updates for existing sessions)
// Both are important for keeping the session list up-to-date
const handleNewSession = (data: { sessionId: string; tenantId: string; session: Session }) => {
console.log('New session registered', data);
// Add new session to the list (at the beginning - most recent first)
if (data.session) {
setSessions(prevSessions => {
// Check if session already exists (shouldn't happen, but be defensive)
const sessionIndex = prevSessions.findIndex(s => s.sessionId === data.session.sessionId);
if (sessionIndex >= 0) {
// Update existing session (just in case)
const updated = [...prevSessions];
updated[sessionIndex] = data.session;
return updated;
} else {
// Add new session at the beginning (most recent first)
return [data.session, ...prevSessions];
}
});
}
};
const handleNewEvents = (data: { sessionId: string; tenantId: string; eventCount: number; session: Session }) => {
console.log('New events notification received on home page', data);
@@ -146,16 +195,19 @@ export default function Home() {
updated[sessionIndex] = data.session;
return updated;
} else {
// Add new session at the beginning (most recent first)
// Session not in list yet - this can happen if we missed the newSession event
// Add it to the list anyway
return [data.session, ...prevSessions];
}
});
}
};
on('newSession', handleNewSession);
on('newevents', handleNewEvents);
return () => {
off('newSession', handleNewSession);
off('newevents', handleNewEvents);
};
}, [on, off]); // Re-register when SignalR connection changes
@@ -237,8 +289,19 @@ export default function Home() {
if (!sessionToDelete) return;
try {
// Get access token and include Authorization header
const token = await getAccessToken();
if (!token) {
alert('Authentication error: Failed to get access token. Please try logging in again.');
return;
}
const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionToDelete.sessionId}?tenantId=${sessionToDelete.tenantId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
@@ -774,11 +837,25 @@ function SettingsMenu({
const [loadingLogs, setLoadingLogs] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { tenantId } = useTenant();
const { getAccessToken } = useAuth(); // Add this line to get getAccessToken
const fetchAuditLogs = async () => {
setLoadingLogs(true);
try {
const response = await fetch(`${API_BASE_URL}/api/audit/logs?tenantId=${tenantId}`);
// Get access token and include Authorization header
const token = await getAccessToken();
if (!token) {
console.error('Failed to get access token for audit logs');
return;
}
const response = await fetch(`${API_BASE_URL}/api/audit/logs?tenantId=${tenantId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
setAuditLogs(data.logs || []);
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { API_BASE_URL } from '@/lib/config';
import { ProtectedRoute } from '../../components/ProtectedRoute';
import { useAuth } from '../../contexts/AuthContext';
interface SessionMetrics {
total: number;
@@ -61,6 +62,7 @@ interface PlatformUsageMetrics {
export default function PlatformUsageMetricsPage() {
const router = useRouter();
const { getAccessToken } = useAuth();
const [metrics, setMetrics] = useState<PlatformUsageMetrics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -76,7 +78,17 @@ export default function PlatformUsageMetricsPage() {
setError(null);
// Platform-wide metrics - cross-tenant (Galactic Admin only)
const response = await fetch(`${API_BASE_URL}/api/platform/usage-metrics`);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/platform/usage-metrics`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch platform usage metrics: ${response.statusText}`);
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSignalR } from "../../../contexts/SignalRContext";
import { useTenant } from "../../../contexts/TenantContext";
import { useAuth } from "../../../contexts/AuthContext";
import { ProtectedRoute } from '../../../components/ProtectedRoute';
import { API_BASE_URL } from "@/lib/config";
@@ -83,6 +84,7 @@ export default function SessionDetailPage() {
// Use global contexts
const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR();
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
// Initial data fetch
useEffect(() => {
@@ -199,7 +201,19 @@ export default function SessionDetailPage() {
? `${API_BASE_URL}/api/galactic/sessions`
: `${API_BASE_URL}/api/sessions?tenantId=${tenantId}`;
const response = await fetch(endpoint);
// Get access token and include Authorization header
const token = await getAccessToken();
if (!token) {
console.error('Failed to get access token for session details');
return;
}
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
const foundSession = data.sessions?.find((s: Session) => s.sessionId === sessionId);
@@ -219,7 +233,19 @@ export default function SessionDetailPage() {
const effectiveTenantId = sessionTenantId || tenantId;
try {
const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/events?tenantId=${effectiveTenantId}`);
// Get access token and include Authorization header
const token = await getAccessToken();
if (!token) {
console.error('Failed to get access token for events');
setLoading(false);
return;
}
const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/events?tenantId=${effectiveTenantId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
const eventsWithPhaseNames = data.events.map((e: EnrollmentEvent) => ({
@@ -244,8 +270,18 @@ export default function SessionDetailPage() {
const effectiveTenantId = sessionTenantId || tenantId;
try {
// Get access token and include Authorization header
const token = await getAccessToken();
if (!token) {
console.error('Failed to get access token for mark failed');
return;
}
const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/mark-failed?tenantId=${effectiveTenantId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
@@ -4,14 +4,13 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
interface TenantConfiguration {
tenantId: string;
lastUpdated: string;
updatedBy: string;
securityEnabled: boolean;
rateLimitRequestsPerMinute: number;
manufacturerWhitelist: string;
modelWhitelist: string;
validateSerialNumber: boolean;
@@ -23,6 +22,7 @@ interface TenantConfiguration {
export default function SettingsPage() {
const router = useRouter();
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const [config, setConfig] = useState<TenantConfiguration | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -30,8 +30,6 @@ export default function SettingsPage() {
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Form state
const [securityEnabled, setSecurityEnabled] = useState(true);
const [rateLimitRequestsPerMinute, setRateLimitRequestsPerMinute] = useState(100);
const [manufacturerWhitelist, setManufacturerWhitelist] = useState("Dell*,HP*,Lenovo*,Microsoft Corporation");
const [modelWhitelist, setModelWhitelist] = useState("*");
const [validateSerialNumber, setValidateSerialNumber] = useState(false);
@@ -48,7 +46,17 @@ export default function SettingsPage() {
setLoading(true);
setError(null);
const response = await fetch(`${API_BASE_URL}/api/config/${tenantId}`);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/config/${tenantId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`Failed to load configuration: ${response.statusText}`);
@@ -58,8 +66,6 @@ export default function SettingsPage() {
setConfig(data);
// Update form state
setSecurityEnabled(data.securityEnabled);
setRateLimitRequestsPerMinute(data.rateLimitRequestsPerMinute);
setManufacturerWhitelist(data.manufacturerWhitelist);
setModelWhitelist(data.modelWhitelist);
setValidateSerialNumber(data.validateSerialNumber);
@@ -86,8 +92,6 @@ export default function SettingsPage() {
const updatedConfig: TenantConfiguration = {
...config,
securityEnabled,
rateLimitRequestsPerMinute,
manufacturerWhitelist,
modelWhitelist,
validateSerialNumber,
@@ -95,10 +99,16 @@ export default function SettingsPage() {
sessionTimeoutHours,
};
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/config/${tenantId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(updatedConfig),
});
@@ -124,8 +134,6 @@ export default function SettingsPage() {
const handleReset = () => {
if (!config) return;
setSecurityEnabled(config.securityEnabled);
setRateLimitRequestsPerMinute(config.rateLimitRequestsPerMinute);
setManufacturerWhitelist(config.manufacturerWhitelist);
setModelWhitelist(config.modelWhitelist);
setValidateSerialNumber(config.validateSerialNumber);
@@ -191,68 +199,6 @@ export default function SettingsPage() {
</div>
)}
{/* Security Toggle */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">Security</h2>
<p className="text-sm text-gray-500 mt-1">Enable or disable security validation for this tenant</p>
</div>
<div className="p-6">
<label className="flex items-center justify-between cursor-pointer">
<div>
<p className="font-medium text-gray-900">Enable Security Validation</p>
<p className="text-sm text-gray-500">
When enabled, all API requests will be validated (certificate, rate limit, hardware whitelist)
</p>
</div>
<div className="relative">
<input
type="checkbox"
className="sr-only"
checked={securityEnabled}
onChange={(e) => setSecurityEnabled(e.target.checked)}
/>
<div
onClick={() => setSecurityEnabled(!securityEnabled)}
className={`w-14 h-8 rounded-full transition-colors ${
securityEnabled ? "bg-indigo-600" : "bg-gray-300"
}`}
>
<div
className={`absolute top-1 left-1 w-6 h-6 bg-white rounded-full transition-transform ${
securityEnabled ? "transform translate-x-6" : ""
}`}
></div>
</div>
</div>
</label>
</div>
</div>
{/* Rate Limiting */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">Rate Limiting</h2>
<p className="text-sm text-gray-500 mt-1">Configure DoS protection limits per device</p>
</div>
<div className="p-6">
<label className="block">
<span className="text-gray-700 font-medium">Maximum Requests per Minute (per Device)</span>
<p className="text-sm text-gray-500 mb-2">
Default: 100. Normal enrollment generates ~2-3 requests/min. Lower values provide stricter protection.
</p>
<input
type="number"
min="1"
max="1000"
value={rateLimitRequestsPerMinute}
onChange={(e) => setRateLimitRequestsPerMinute(parseInt(e.target.value) || 100)}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</label>
</div>
</div>
{/* Hardware Whitelist */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
@@ -316,7 +262,7 @@ export default function SettingsPage() {
<div>
<p className="font-medium text-gray-500">Enable Serial Number Validation</p>
<p className="text-sm text-gray-400">
Requires Graph API integration with Multi-Tenant support
Requires Graph API permission
</p>
</div>
<div className="relative">
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useTenant } from '../../contexts/TenantContext';
import { useAuth } from '../../contexts/AuthContext';
import { ProtectedRoute } from '../../components/ProtectedRoute';
import { API_BASE_URL } from '@/lib/config';
@@ -63,6 +64,7 @@ interface PlatformUsageMetrics {
export default function UsageMetricsPage() {
const router = useRouter();
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const [metrics, setMetrics] = useState<PlatformUsageMetrics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -78,7 +80,17 @@ export default function UsageMetricsPage() {
setError(null);
// Tenant-specific metrics - using tenantId
const response = await fetch(`${API_BASE_URL}/api/usage-metrics?tenantId=${tenantId}`);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/usage-metrics?tenantId=${tenantId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch usage metrics: ${response.statusText}`);
@@ -3,6 +3,7 @@
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react';
import * as signalR from '@microsoft/signalr';
import { API_BASE_URL } from '@/lib/config';
import { useAuth } from './AuthContext';
interface SignalRContextType {
connection: signalR.HubConnection | null;
@@ -18,6 +19,7 @@ interface SignalRContextType {
const SignalRContext = createContext<SignalRContextType | undefined>(undefined);
export function SignalRProvider({ children }: { children: React.ReactNode }) {
const { getAccessToken, isAuthenticated } = useAuth();
const [connection, setConnection] = useState<signalR.HubConnection | null>(null);
const [connectionState, setConnectionState] = useState<signalR.HubConnectionState>(signalR.HubConnectionState.Disconnected);
const connectionRef = useRef<signalR.HubConnection | null>(null);
@@ -26,6 +28,11 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
const maxRetries = 3;
useEffect(() => {
// Only create connection if authenticated
if (!isAuthenticated) {
return;
}
// Only create connection once
if (connectionRef.current) {
return;
@@ -33,7 +40,12 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
const hubUrl = `${API_BASE_URL}/api/realtime`;
const newConnection = new signalR.HubConnectionBuilder()
.withUrl(hubUrl)
.withUrl(hubUrl, {
accessTokenFactory: async () => {
const token = await getAccessToken();
return token || '';
}
})
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (retryContext) => {
// Exponential backoff: 0s, 2s, 10s, 30s, then 30s thereafter
@@ -95,7 +107,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
connectionRef.current = null;
}
};
}, []);
}, [isAuthenticated, getAccessToken]);
const on = (eventName: string, callback: (...args: any[]) => void) => {
if (connectionRef.current) {
@@ -136,9 +148,13 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
return;
}
const token = await getAccessToken();
const response = await fetch(`${API_BASE_URL}/api/realtime/groups/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ connectionId, groupName })
});
@@ -151,7 +167,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
// Remove from Set if API call failed (so we can retry)
joinedGroupsRef.current.delete(groupName);
}
}, [connection, connectionState]);
}, [connection, connectionState, getAccessToken]);
const leaveGroup = useCallback(async (groupName: string) => {
if (!connection || connectionState !== signalR.HubConnectionState.Connected) {
@@ -172,9 +188,13 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
return;
}
const token = await getAccessToken();
const response = await fetch(`${API_BASE_URL}/api/realtime/groups/leave`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ connectionId, groupName })
});
@@ -187,7 +207,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
// Re-add to Set if API call failed (so we can retry)
joinedGroupsRef.current.add(groupName);
}
}, [connection, connectionState]);
}, [connection, connectionState, getAccessToken]);
return (
<SignalRContext.Provider