diff --git a/.gitignore b/.gitignore index 17742924..f03b735c 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,6 @@ appsettings.*.json *.local.* temp/ tmp/ + +# Claude Code temporary files +tmpclaude-*-cwd diff --git a/src/Backend/AutopilotMonitor.Functions/Extensions/ClaimsPrincipalExtensions.cs b/src/Backend/AutopilotMonitor.Functions/Extensions/ClaimsPrincipalExtensions.cs index 434f5f3b..d165423f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Extensions/ClaimsPrincipalExtensions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Extensions/ClaimsPrincipalExtensions.cs @@ -13,7 +13,15 @@ public static class ClaimsPrincipalExtensions /// 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; } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/DeleteSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/DeleteSessionFunction.cs index 7762fd69..d84b9f01 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/DeleteSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/DeleteSessionFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAdminConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAdminConfigurationFunction.cs new file mode 100644 index 00000000..47b9d4dd --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAdminConfigurationFunction.cs @@ -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 _logger; + private readonly AdminConfigurationService _adminConfigService; + private readonly GalacticAdminService _galacticAdminService; + + public GetAdminConfigurationFunction( + ILogger logger, + AdminConfigurationService adminConfigService, + GalacticAdminService galacticAdminService) + { + _logger = logger; + _adminConfigService = adminConfigService; + _galacticAdminService = galacticAdminService; + } + + [Function("GetAdminConfiguration")] + public async Task 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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAllSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAllSessionsFunction.cs index b77d751d..21fa917d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetAllSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAllSessionsFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAuditLogsFunction.cs index 79162d0a..00acb555 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAuditLogsFunction.cs @@ -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 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionEventsFunction.cs index abb73f2c..7ab0948c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionEventsFunction.cs @@ -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 _logger; private readonly TableStorageService _storageService; + private readonly GalacticAdminService _galacticAdminService; - public GetSessionEventsFunction(ILogger logger, TableStorageService storageService) + public GetSessionEventsFunction( + ILogger 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() + }); + 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() + }); + 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() + }); + 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 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionsFunction.cs index fb3b1910..a22f8779 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionsFunction.cs @@ -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() + }); + return unauthorizedResponse; + } + var tenantId = TenantHelper.GetTenantId(req); _logger.LogInformation($"Fetching sessions for tenant {tenantId}"); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetTenantConfigurationFunction.cs index f5e82875..9e0b2a2d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetTenantConfigurationFunction.cs @@ -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) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs index 67141d96..25d53f56 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs index 83ebd9f2..035ce0de 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs @@ -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) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/MarkSessionFailedFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/MarkSessionFailedFunction.cs index 463fff5b..c7e9955e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/MarkSessionFailedFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/MarkSessionFailedFunction.cs @@ -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 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/PlatformUsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/PlatformUsageMetricsFunction.cs index f8b1f260..535c9556 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/PlatformUsageMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/PlatformUsageMetricsFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs index 35f61e60..61a21973 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs @@ -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 Run( + public async Task 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; } + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRAddToGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRAddToGroupFunction.cs index 1a817209..b26951e0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRAddToGroupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRAddToGroupFunction.cs @@ -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 _logger; + private readonly GalacticAdminService _galacticAdminService; - public SignalRAddToGroupFunction(ILogger logger) + public SignalRAddToGroupFunction( + ILogger 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}"); + } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRNegotiateFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRNegotiateFunction.cs index eaabd68e..d390e3f8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRNegotiateFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRNegotiateFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRRemoveFromGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRRemoveFromGroupFunction.cs index 280f0a27..414e55bd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRRemoveFromGroupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRRemoveFromGroupFunction.cs @@ -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 _logger; + private readonly GalacticAdminService _galacticAdminService; - public SignalRRemoveFromGroupFunction(ILogger logger) + public SignalRRemoveFromGroupFunction( + ILogger 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 } } + /// + /// Extracts tenant ID from SignalR group name + /// Group formats: "tenant-{tenantId}" or "session-{tenantId}-{sessionId}" + /// + 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}" diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/TriggerMaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/TriggerMaintenanceFunction.cs index 319d726c..88364fd0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/TriggerMaintenanceFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/TriggerMaintenanceFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/UpdateAdminConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/UpdateAdminConfigurationFunction.cs new file mode 100644 index 00000000..aa5b9910 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/UpdateAdminConfigurationFunction.cs @@ -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 _logger; + private readonly AdminConfigurationService _adminConfigService; + private readonly GalacticAdminService _galacticAdminService; + + public UpdateAdminConfigurationFunction( + ILogger logger, + AdminConfigurationService adminConfigService, + GalacticAdminService galacticAdminService) + { + _logger = logger; + _adminConfigService = adminConfigService; + _galacticAdminService = galacticAdminService; + } + + [Function("UpdateAdminConfiguration")] + public async Task 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(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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantConfigurationFunction.cs index ed7d4323..1b8cec1c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantConfigurationFunction.cs @@ -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) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/UsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/UsageMetricsFunction.cs index eb5cd7ec..b164feef 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/UsageMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/UsageMetricsFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantHelper.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantHelper.cs index e7165994..f80bc6f5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantHelper.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantHelper.cs @@ -9,6 +9,27 @@ namespace AutopilotMonitor.Functions.Helpers; /// public static class TenantHelper { + /// + /// Checks if the request has an authenticated user + /// Azure Functions Isolated Worker compatible + /// + /// The HTTP request + /// True if user is authenticated, false otherwise + 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; + } + /// /// 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 /// Thrown when user is not authenticated or tenant ID is missing 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 /// User email or name, or "Anonymous" if not authenticated 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 ?? diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs index 931b7068..53adf9cc 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs @@ -21,7 +21,14 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware private readonly ILogger _logger; private readonly IConfiguration _configuration; private readonly JsonWebTokenHandler _tokenHandler; - private readonly IConfigurationManager _configurationManager; + + // Cache configuration managers per tenant to avoid repeated OIDC metadata fetches + private readonly Dictionary> _configManagerCache; + private readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1, 1); + + // Track if we're currently initializing a tenant to prevent race conditions + private readonly HashSet _initializingTenants = new HashSet(); + private readonly object _initLock = new object(); public AuthenticationMiddleware( ILogger logger, @@ -30,16 +37,10 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware _logger = logger; _configuration = configuration; _tokenHandler = new JsonWebTokenHandler(); + _configManagerCache = new Dictionary>(); // 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( - 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( - tenantMetadataAddress, - new OpenIdConnectConfigurationRetriever()); + // Get or create cached configuration manager for this tenant + IConfigurationManager? tenantConfigManager = null; + await _cacheLock.WaitAsync(); + try + { + if (!_configManagerCache.TryGetValue(tenantSpecificAuthority, out tenantConfigManager)) + { + var tenantMetadataAddress = $"{tenantSpecificAuthority}/.well-known/openid-configuration"; + tenantConfigManager = new ConfigurationManager( + 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 { diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 1e1dd982..e24012ce 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -92,6 +92,7 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs index 5670dea7..532637f0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -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) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs new file mode 100644 index 00000000..76fe72a6 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs @@ -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 +{ + /// + /// Service for managing global admin configuration in Azure Table Storage + /// + public class AdminConfigurationService + { + private readonly TableClient _adminTableClient; + private readonly TableClient _tenantConfigTableClient; + private readonly ILogger _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 logger) + { + _logger = logger; + + var connectionString = configuration["AzureWebJobsStorage"]; + var serviceClient = new TableServiceClient(connectionString); + _adminTableClient = serviceClient.GetTableClient("AdminConfiguration"); + _adminTableClient.CreateIfNotExists(); + + _tenantConfigTableClient = serviceClient.GetTableClient("TenantConfiguration"); + _tenantConfigTableClient.CreateIfNotExists(); + } + + /// + /// Gets global admin configuration (uses cache with 5-minute TTL) + /// + public async Task 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("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(); + } + } + + /// + /// Saves global admin configuration and syncs rate limit to all tenant configurations + /// + 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; + } + } + + /// + /// Syncs the global rate limit to all tenant configurations that don't have a custom override + /// + 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(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 + } + } + + /// + /// Invalidates cache (forces reload on next request) + /// + 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") + }; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs index 3230de2f..af0caa26 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs @@ -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, diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs new file mode 100644 index 00000000..ed5f7020 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs @@ -0,0 +1,64 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Global platform configuration managed by Galactic Admins + /// Stored in Azure Table Storage with single instance + /// PartitionKey = "GlobalConfig" + /// RowKey = "config" + /// + public class AdminConfiguration + { + /// + /// Partition key (always "GlobalConfig") + /// + public string PartitionKey { get; set; } = "GlobalConfig"; + + /// + /// Row key (always "config") + /// + public string RowKey { get; set; } = "config"; + + /// + /// When the configuration was last updated + /// + public DateTime LastUpdated { get; set; } + + /// + /// Updated by (Galactic Admin user email) + /// + public string UpdatedBy { get; set; } + + // ===== RATE LIMITING SETTINGS ===== + + /// + /// Global default rate limit: Maximum requests per minute per device + /// This applies to all tenants unless they have a custom override + /// Default: 100 + /// + public int GlobalRateLimitRequestsPerMinute { get; set; } = 100; + + // ===== FUTURE SETTINGS (Extensible) ===== + + /// + /// Custom settings as JSON (for future extensibility) + /// + public string CustomSettings { get; set; } + + /// + /// Creates default configuration + /// + public static AdminConfiguration CreateDefault() + { + return new AdminConfiguration + { + PartitionKey = "GlobalConfig", + RowKey = "config", + LastUpdated = DateTime.UtcNow, + UpdatedBy = "System", + GlobalRateLimitRequestsPerMinute = 100 + }; + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs index 97459876..0a36c40c 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs @@ -27,17 +27,20 @@ namespace AutopilotMonitor.Shared.Models // ===== SECURITY SETTINGS ===== - /// - /// Whether request security validation is enabled - /// - public bool SecurityEnabled { get; set; } = true; - /// /// Rate limit: Maximum requests per minute per device + /// This value is synchronized from the global AdminConfiguration /// Default: 100 /// public int RateLimitRequestsPerMinute { get; set; } = 100; + /// + /// 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 + /// + public int? CustomRateLimitRequestsPerMinute { get; set; } = null; + /// /// 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, diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx index 6be7f8d5..c21e5dc4 100644 --- a/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx @@ -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(null); const [successMessage, setSuccessMessage] = useState(null); + // Admin Configuration state + const [adminConfig, setAdminConfig] = useState(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() { )} + {/* Global Rate Limiting Configuration */} +
+
+
+ + + +
+

Global Rate Limiting

+

Configure default DoS protection limits for all tenants

+
+
+
+
+ {loadingConfig ? ( +
+
+

Loading configuration...

+
+ ) : ( +
+
+ +
+ + {adminConfig && ( +
+
+ + + +
+

Configuration Info

+

Last updated: {new Date(adminConfig.lastUpdated).toLocaleString()}

+

Updated by: {adminConfig.updatedBy}

+
+
+
+ )} + +
+ + +
+
+ )} +
+
+ {/* Manual Maintenance Trigger */}
diff --git a/src/Web/autopilot-monitor-web/app/page.tsx b/src/Web/autopilot-monitor-web/app/page.tsx index 0067cbb8..b9fe58ab 100644 --- a/src/Web/autopilot-monitor-web/app/page.tsx +++ b/src/Web/autopilot-monitor-web/app/page.tsx @@ -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([]); @@ -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(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 || []); diff --git a/src/Web/autopilot-monitor-web/app/platform-usage-metrics/page.tsx b/src/Web/autopilot-monitor-web/app/platform-usage-metrics/page.tsx index d222f455..81f06d2f 100644 --- a/src/Web/autopilot-monitor-web/app/platform-usage-metrics/page.tsx +++ b/src/Web/autopilot-monitor-web/app/platform-usage-metrics/page.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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}`); diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx index d92c612b..6a84d3bb 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -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) { diff --git a/src/Web/autopilot-monitor-web/app/settings/page.tsx b/src/Web/autopilot-monitor-web/app/settings/page.tsx index 0f341753..89dfe3ca 100644 --- a/src/Web/autopilot-monitor-web/app/settings/page.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/page.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -30,8 +30,6 @@ export default function SettingsPage() { const [successMessage, setSuccessMessage] = useState(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() {
)} - {/* Security Toggle */} -
-
-

Security

-

Enable or disable security validation for this tenant

-
-
- -
-
- - {/* Rate Limiting */} -
-
-

Rate Limiting

-

Configure DoS protection limits per device

-
-
- -
-
- {/* Hardware Whitelist */}
@@ -316,7 +262,7 @@ export default function SettingsPage() {

Enable Serial Number Validation

- Requires Graph API integration with Multi-Tenant support + Requires Graph API permission

diff --git a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx index ec055f3f..1fb80b6a 100644 --- a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx +++ b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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}`); diff --git a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx index 438da25f..87836b5d 100644 --- a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx @@ -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(undefined); export function SignalRProvider({ children }: { children: React.ReactNode }) { + const { getAccessToken, isAuthenticated } = useAuth(); const [connection, setConnection] = useState(null); const [connectionState, setConnectionState] = useState(signalR.HubConnectionState.Disconnected); const connectionRef = useRef(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 (