diff --git a/src/Agent/AutopilotMonitor.Agent/Program.cs b/src/Agent/AutopilotMonitor.Agent/Program.cs index 411e7970..5a3a82f8 100644 --- a/src/Agent/AutopilotMonitor.Agent/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent/Program.cs @@ -6,6 +6,7 @@ using System.ServiceProcess; using AutopilotMonitor.Agent.Core.Configuration; using AutopilotMonitor.Agent.Core.Logging; using AutopilotMonitor.Agent.Core.Monitoring; +using Microsoft.Win32; using Newtonsoft.Json; namespace AutopilotMonitor.Agent @@ -94,6 +95,60 @@ namespace AutopilotMonitor.Agent ServiceBase.Run(service); } + /// + /// Reads the Azure AD Tenant ID from the Windows Registry. + /// Looks for enrollments with EnrollmentType = 6 (AAD Join). + /// + /// The AAD Tenant ID if found, otherwise null. + static string GetTenantIdFromRegistry() + { + try + { + const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments"; + + using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath)) + { + if (enrollmentsKey == null) + { + Console.WriteLine("Registry key not found: HKLM\\" + enrollmentsKeyPath); + return null; + } + + // Iterate through all enrollment GUIDs + foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames()) + { + using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid)) + { + if (enrollmentKey == null) + continue; + + // Check if this is an AAD Join enrollment (EnrollmentType = 6) + var enrollmentType = enrollmentKey.GetValue("EnrollmentType"); + if (enrollmentType != null && Convert.ToInt32(enrollmentType) == 6) + { + // Try to get AADTenantID + var tenantId = enrollmentKey.GetValue("AADTenantID"); + if (tenantId != null) + { + var tenantIdString = tenantId.ToString(); + Console.WriteLine($"Found AAD Tenant ID in registry: {tenantIdString} (Enrollment: {enrollmentGuid})"); + return tenantIdString; + } + } + } + } + + Console.WriteLine("No AAD Join enrollment (EnrollmentType=6) with AADTenantID found in registry."); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error reading Tenant ID from registry: {ex.Message}"); + } + + return null; + } + static AgentConfiguration LoadConfiguration(string[] args = null) { // Parse command-line arguments @@ -188,12 +243,32 @@ namespace AutopilotMonitor.Agent selfDestructOnComplete = false; } + // Determine TenantId: Command-line arg > Registry > Fallback + string tenantId; + if (!string.IsNullOrEmpty(tenantIdOverride)) + { + // Command-line argument takes precedence + tenantId = tenantIdOverride; + Console.WriteLine($"Using Tenant ID from command-line argument: {tenantId}"); + } + else + { + // Try to read from registry + tenantId = GetTenantIdFromRegistry(); + + if (string.IsNullOrEmpty(tenantId)) + { + // Fallback to default (for testing/development) + tenantId = "b54dc1af-5320-4f60-b5d4-821e0cf2a359"; + Console.WriteLine($"Using fallback Tenant ID: {tenantId}"); + } + } + return new AgentConfiguration { ApiBaseUrl = apiBaseUrl, SessionId = Guid.NewGuid().ToString(), - //TenantId = "deadbeef-dead-beef-dead-beefdeadbeef", - TenantId = tenantIdOverride ?? "b54dc1af-5320-4f60-b5d4-821e0cf2a359", + TenantId = tenantId, SpoolDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Spool"), LogDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Logs"), UploadIntervalSeconds = uploadIntervalSeconds, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs index 298de521..99709d87 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs @@ -64,8 +64,7 @@ public class AuthFunction upn, displayName, objectId, - isGalacticAdmin, - claims = principal.GetAllClaims() // For debugging + isGalacticAdmin }; var response = req.CreateResponse(HttpStatusCode.OK); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/DeleteSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/DeleteSessionFunction.cs index a1946534..7762fd69 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/DeleteSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/DeleteSessionFunction.cs @@ -1,4 +1,5 @@ using System.Net; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -26,10 +27,10 @@ namespace AutopilotMonitor.Functions.Functions try { - // Get tenant ID from query parameter (for now, default to demo GUID) - var tenantId = req.Query["tenantId"] ?? "deadbeef-dead-beef-dead-beefdeadbeef"; + var tenantId = TenantHelper.GetTenantId(req); + var userIdentifier = TenantHelper.GetUserIdentifier(req); - _logger.LogInformation($"Deleting session {sessionId} for tenant {tenantId}"); + _logger.LogInformation($"Deleting session {sessionId} for tenant {tenantId} by user {userIdentifier}"); // Delete all events for this session var eventsDeleted = await _storageService.DeleteSessionEventsAsync(tenantId, sessionId); @@ -40,13 +41,13 @@ namespace AutopilotMonitor.Functions.Functions if (sessionDeleted) { - // Log audit entry + // Log audit entry with actual user identifier await _storageService.LogAuditEntryAsync( tenantId, "DELETE", "Session", sessionId, - "Admin", // TODO: Get from authenticated user + userIdentifier, new Dictionary { { "EventsDeleted", eventsDeleted.ToString() } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAllSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAllSessionsFunction.cs index 6136928f..b77d751d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetAllSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAllSessionsFunction.cs @@ -1,4 +1,5 @@ using System.Net; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -10,11 +11,16 @@ namespace AutopilotMonitor.Functions.Functions { private readonly ILogger _logger; private readonly TableStorageService _storageService; + private readonly GalacticAdminService _galacticAdminService; - public GetAllSessionsFunction(ILogger logger, TableStorageService storageService) + public GetAllSessionsFunction( + ILogger logger, + TableStorageService storageService, + GalacticAdminService galacticAdminService) { _logger = logger; _storageService = storageService; + _galacticAdminService = galacticAdminService; } [Function("GetAllSessions")] @@ -25,7 +31,37 @@ namespace AutopilotMonitor.Functions.Functions try { - _logger.LogInformation("Fetching all sessions across all tenants"); + // Validate authentication and Galactic Admin role + var httpContext = req.FunctionContext.GetHttpContext(); + if (httpContext?.User?.Identity?.IsAuthenticated != true) + { + _logger.LogWarning("Unauthenticated GetAllSessions attempt"); + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorizedResponse.WriteAsJsonAsync(new + { + success = false, + message = "Authentication required. Please provide a valid JWT token." + }); + return unauthorizedResponse; + } + + // Check if user is Galactic Admin via GalacticAdminService (Azure Table Storage) + var userEmail = TenantHelper.GetUserIdentifier(req); + var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userEmail); + + if (!isGalacticAdmin) + { + _logger.LogWarning($"Non-Galactic Admin user {userEmail} attempted to access GetAllSessions"); + var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden); + await forbiddenResponse.WriteAsJsonAsync(new + { + success = false, + message = "Access denied. Galactic Admin role required." + }); + return forbiddenResponse; + } + + _logger.LogInformation($"Fetching all sessions across all tenants (User: {userEmail})"); // Get all sessions from storage (no tenant filter) var sessions = await _storageService.GetAllSessionsAsync(maxResults: 100); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAuditLogsFunction.cs index 49906def..79162d0a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAuditLogsFunction.cs @@ -1,4 +1,5 @@ using System.Net; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -25,10 +26,26 @@ namespace AutopilotMonitor.Functions.Functions try { - // Get tenant ID from query parameter (for now, default to demo GUID) - var tenantId = req.Query["tenantId"] ?? "deadbeef-dead-beef-dead-beefdeadbeef"; + string tenantId; + string userIdentifier; + try + { + tenantId = TenantHelper.GetTenantId(req); + userIdentifier = TenantHelper.GetUserIdentifier(req); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning($"Unauthorized audit logs attempt: {ex.Message}"); + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorizedResponse.WriteAsJsonAsync(new + { + success = false, + message = "Authentication required. Please provide a valid JWT token." + }); + return unauthorizedResponse; + } - _logger.LogInformation($"Fetching audit logs for tenant {tenantId}"); + _logger.LogInformation($"Fetching audit logs for tenant {tenantId} by user {userIdentifier}"); // Get audit logs from storage var logs = await _storageService.GetAuditLogsAsync(tenantId, maxResults: 100); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionEventsFunction.cs index 6cc4053b..abb73f2c 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 AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -34,8 +35,7 @@ namespace AutopilotMonitor.Functions.Functions try { - // Get tenant ID from query parameter (for now, default to demo GUID) - var tenantId = req.Query["tenantId"] ?? "deadbeef-dead-beef-dead-beefdeadbeef"; + var tenantId = TenantHelper.GetTenantId(req); // Get events from storage var events = await _storageService.GetSessionEventsAsync(tenantId, sessionId); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionsFunction.cs index 3a3cbb00..fb3b1910 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionsFunction.cs @@ -1,4 +1,5 @@ using System.Net; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -25,8 +26,7 @@ namespace AutopilotMonitor.Functions.Functions try { - // Get tenant ID from query parameter (for now, default to demo GUID) - var tenantId = req.Query["tenantId"] ?? "deadbeef-dead-beef-dead-beefdeadbeef"; + 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 49f634d5..f5e82875 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetTenantConfigurationFunction.cs @@ -1,6 +1,7 @@ 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; @@ -28,7 +29,40 @@ namespace AutopilotMonitor.Functions.Functions { try { - _logger.LogInformation($"GetTenantConfiguration: {tenantId}"); + // Get tenant ID from JWT token and validate access + string authenticatedTenantId; + string userIdentifier; + try + { + authenticatedTenantId = TenantHelper.GetTenantId(req); + userIdentifier = TenantHelper.GetUserIdentifier(req); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning($"Unauthorized get tenant configuration attempt for tenant {tenantId}: {ex.Message}"); + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorizedResponse.WriteAsJsonAsync(new + { + success = false, + message = "Authentication required. Please provide a valid JWT token." + }); + return unauthorizedResponse; + } + + // Validate tenant access: User can only access their own tenant's configuration + if (authenticatedTenantId != tenantId) + { + _logger.LogWarning($"User {userIdentifier} from tenant {authenticatedTenantId} attempted to access configuration for tenant {tenantId}"); + var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden); + await forbiddenResponse.WriteAsJsonAsync(new + { + success = false, + message = "Access denied. You can only access your own tenant's configuration." + }); + return forbiddenResponse; + } + + _logger.LogInformation($"GetTenantConfiguration: {tenantId} by user {userIdentifier}"); var config = await _configService.GetConfigurationAsync(tenantId); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs index 4b10dfde..67141d96 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs @@ -1,7 +1,7 @@ using System.Net; using AutopilotMonitor.Functions.Extensions; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; -using Microsoft.AspNetCore.Authorization; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -51,30 +51,44 @@ namespace AutopilotMonitor.Functions.Functions /// Detailed health check with comprehensive system checks (Galactic Admin only) /// [Function("DetailedHealthCheck")] - [Authorize] public async Task GetDetailedHealthCheck( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health/detailed")] HttpRequestData req, FunctionContext context) { _logger.LogInformation("Detailed health check requested"); - // Check if user is galactic admin - var principal = context.GetUser(); - if (principal == null) + // Validate authentication + var httpContext = req.FunctionContext.GetHttpContext(); + if (httpContext?.User?.Identity?.IsAuthenticated != true) { - return req.CreateResponse(HttpStatusCode.Unauthorized); + _logger.LogWarning("Unauthenticated detailed health check attempt"); + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorizedResponse.WriteAsJsonAsync(new + { + success = false, + message = "Authentication required. Please provide a valid JWT token." + }); + return unauthorizedResponse; } - var upn = principal.GetUserPrincipalName(); - var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(upn); + // Check if user is Galactic Admin via GalacticAdminService (Azure Table Storage) + var userEmail = TenantHelper.GetUserIdentifier(req); + var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userEmail); if (!isGalacticAdmin) { + _logger.LogWarning($"Non-Galactic Admin user {userEmail} attempted to access detailed health check"); var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden); - await forbiddenResponse.WriteAsJsonAsync(new { error = "Only Galactic Admins can access detailed health checks" }); + await forbiddenResponse.WriteAsJsonAsync(new + { + success = false, + message = "Access denied. Galactic Admin role required." + }); return forbiddenResponse; } + _logger.LogInformation($"Detailed health check accessed by Galactic Admin: {userEmail}"); + // Perform comprehensive health checks var healthCheckResult = await _healthCheckService.PerformAllChecksAsync(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs index 3db8883b..83ebd9f2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs @@ -53,7 +53,13 @@ namespace AutopilotMonitor.Functions.Functions { // New format: NDJSON + gzip _logger.LogDebug("Parsing gzip-compressed NDJSON request"); - request = await ParseNdjsonGzipRequest(req.Body); + + // Extract tenantId from header to get config (for payload size limit) + var tenantIdHeader = req.Headers.Contains("X-Tenant-Id") + ? req.Headers.GetValues("X-Tenant-Id").FirstOrDefault() + : null; + + request = await ParseNdjsonGzipRequest(req.Body, tenantIdHeader); } else { @@ -230,13 +236,41 @@ namespace AutopilotMonitor.Functions.Functions /// Parses NDJSON + gzip compressed request /// Format: First line is metadata (sessionId, tenantId), subsequent lines are events /// - private async Task ParseNdjsonGzipRequest(Stream body) + private async Task ParseNdjsonGzipRequest(Stream body, string? tenantId = null) { - // Decompress gzip + // Get configuration for payload size limit (use default if tenantId not available yet) + var config = !string.IsNullOrEmpty(tenantId) + ? await _configService.GetConfigurationAsync(tenantId) + : null; + + var maxPayloadSizeBytes = (config?.MaxNdjsonPayloadSizeMB ?? 5) * 1024 * 1024; + + // Decompress gzip with size limit protection using var decompressed = new MemoryStream(); using (var gzip = new GZipStream(body, CompressionMode.Decompress, leaveOpen: true)) { - await gzip.CopyToAsync(decompressed); + // Copy with size limit to prevent memory exhaustion attacks + var buffer = new byte[8192]; + int bytesRead; + long totalBytesRead = 0; + + while ((bytesRead = await gzip.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + totalBytesRead += bytesRead; + + // Check if we've exceeded the maximum payload size + if (totalBytesRead > maxPayloadSizeBytes) + { + throw new InvalidOperationException( + $"NDJSON payload size exceeds maximum allowed size of {config?.MaxNdjsonPayloadSizeMB ?? 5} MB (decompressed). " + + $"Current size: {totalBytesRead / 1024.0 / 1024.0:F2} MB" + ); + } + + await decompressed.WriteAsync(buffer, 0, bytesRead); + } + + _logger.LogDebug($"NDJSON payload decompressed: {totalBytesRead / 1024.0:F2} KB (limit: {maxPayloadSizeBytes / 1024.0 / 1024.0} MB)"); } decompressed.Position = 0; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/MarkSessionFailedFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/MarkSessionFailedFunction.cs index 7588ef42..463fff5b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/MarkSessionFailedFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/MarkSessionFailedFunction.cs @@ -1,4 +1,5 @@ using System.Net; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; @@ -28,10 +29,26 @@ namespace AutopilotMonitor.Functions.Functions try { - // Get tenant ID from query parameter (for now, default to demo GUID) - var tenantId = req.Query["tenantId"] ?? "deadbeef-dead-beef-dead-beefdeadbeef"; + string tenantId; + string userIdentifier; + try + { + tenantId = TenantHelper.GetTenantId(req); + userIdentifier = TenantHelper.GetUserIdentifier(req); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning($"Unauthorized mark-failed attempt for session {sessionId}: {ex.Message}"); + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorizedResponse.WriteAsJsonAsync(new + { + success = false, + message = "Authentication required. Please provide a valid JWT token." + }); + return new MarkSessionFailedOutput { HttpResponse = unauthorizedResponse }; + } - _logger.LogInformation($"Marking session {sessionId} as failed for tenant {tenantId}"); + _logger.LogInformation($"Marking session {sessionId} as failed for tenant {tenantId} by user {userIdentifier}"); // Update session status to Failed with manual failure reason var success = await _storageService.UpdateSessionStatusAsync( @@ -44,13 +61,13 @@ namespace AutopilotMonitor.Functions.Functions if (success) { - // Log audit entry + // Log audit entry with actual user identifier await _storageService.LogAuditEntryAsync( tenantId, "UPDATE", "Session", sessionId, - "Admin", // TODO: Get from authenticated user + userIdentifier, new Dictionary { { "Action", "MarkAsFailed" }, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/PlatformUsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/PlatformUsageMetricsFunction.cs index 7dd9f088..f8b1f260 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/PlatformUsageMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/PlatformUsageMetricsFunction.cs @@ -1,4 +1,5 @@ using System.Net; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -13,13 +14,16 @@ namespace AutopilotMonitor.Functions.Functions { private readonly ILogger _logger; private readonly UsageMetricsService _usageMetricsService; + private readonly GalacticAdminService _galacticAdminService; public PlatformUsageMetricsFunction( ILogger logger, - UsageMetricsService usageMetricsService) + UsageMetricsService usageMetricsService, + GalacticAdminService galacticAdminService) { _logger = logger; _usageMetricsService = usageMetricsService; + _galacticAdminService = galacticAdminService; } /// @@ -31,18 +35,41 @@ namespace AutopilotMonitor.Functions.Functions [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "platform/usage-metrics")] HttpRequestData req) { - _logger.LogInformation("Usage metrics requested"); + _logger.LogInformation("Platform usage metrics requested"); try { - // TODO: Add Entra ID authentication check here (Galactic Admin role required) - // When Entra ID is implemented: - // 1. Extract JWT token from Authorization header - // 2. Validate token with Azure AD - // 3. Check user has "GalacticAdmin" role - // 4. Return 403 Forbidden if not authorized - // - // For now, anyone can access (will be restricted with Entra ID later) + // Validate authentication + var httpContext = req.FunctionContext.GetHttpContext(); + if (httpContext?.User?.Identity?.IsAuthenticated != true) + { + _logger.LogWarning("Unauthenticated platform usage metrics attempt"); + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorizedResponse.WriteAsJsonAsync(new + { + success = false, + message = "Authentication required. Please provide a valid JWT token." + }); + return unauthorizedResponse; + } + + // Check if user is Galactic Admin via GalacticAdminService (Azure Table Storage) + var userEmail = TenantHelper.GetUserIdentifier(req); + var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userEmail); + + if (!isGalacticAdmin) + { + _logger.LogWarning($"Non-Galactic Admin user {userEmail} attempted to access platform usage metrics"); + var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden); + await forbiddenResponse.WriteAsJsonAsync(new + { + success = false, + message = "Access denied. Galactic Admin role required." + }); + return forbiddenResponse; + } + + _logger.LogInformation($"Platform usage metrics accessed by Galactic Admin: {userEmail}"); var metrics = await _usageMetricsService.ComputeUsageMetricsAsync(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRAddToGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRAddToGroupFunction.cs index 5871d922..1a817209 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRAddToGroupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRAddToGroupFunction.cs @@ -1,4 +1,5 @@ using System.Net; +using AutopilotMonitor.Functions.Helpers; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; @@ -22,6 +23,16 @@ namespace AutopilotMonitor.Functions.Functions { try { + // Validate authentication + var httpContext = req.FunctionContext.GetHttpContext(); + if (httpContext?.User?.Identity?.IsAuthenticated != true) + { + _logger.LogWarning("Unauthenticated AddToGroup attempt"); + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorizedResponse.WriteAsJsonAsync(new { success = false, message = "Authentication required" }); + return new AddToGroupOutput { HttpResponse = unauthorizedResponse }; + } + // Parse request var requestBody = await new StreamReader(req.Body).ReadToEndAsync(); var request = JsonConvert.DeserializeObject(requestBody); @@ -33,9 +44,29 @@ namespace AutopilotMonitor.Functions.Functions return new AddToGroupOutput { HttpResponse = errorResponse }; } + // Get user's tenant ID from JWT + var userTenantId = TenantHelper.GetTenantId(req); + var userEmail = TenantHelper.GetUserIdentifier(req); + + // Validate tenant access + // Group names are in format: "tenant-{tenantId}" or "session-{tenantId}-{sessionId}" + // Users can only join groups for their own tenant + 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 }; + } + } + // Extract session ID from group name if it's a session-specific group var logPrefix = ExtractLogPrefix(request.GroupName); - _logger.LogInformation($"{logPrefix} AddToGroup: {request.GroupName}"); + _logger.LogInformation($"{logPrefix} AddToGroup: {request.GroupName} (User: {userEmail})"); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new @@ -63,6 +94,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/SignalRNegotiateFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRNegotiateFunction.cs index 606371fe..eaabd68e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRNegotiateFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRNegotiateFunction.cs @@ -1,3 +1,5 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; @@ -21,7 +23,20 @@ namespace AutopilotMonitor.Functions.Functions { _logger.LogInformation("SignalR negotiate request"); - var response = req.CreateResponse(System.Net.HttpStatusCode.OK); + // Validate authentication (middleware already validated JWT) + var httpContext = req.FunctionContext.GetHttpContext(); + if (httpContext?.User?.Identity?.IsAuthenticated != true) + { + _logger.LogWarning("Unauthenticated SignalR negotiate attempt"); + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + unauthorizedResponse.WriteAsJsonAsync(new { success = false, message = "Authentication required" }); + return unauthorizedResponse; + } + + var userEmail = TenantHelper.GetUserIdentifier(req); + _logger.LogInformation($"SignalR connection negotiated for user: {userEmail}"); + + var response = req.CreateResponse(HttpStatusCode.OK); response.WriteAsJsonAsync(connectionInfo); return response; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRRemoveFromGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRRemoveFromGroupFunction.cs index 6526a103..280f0a27 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRRemoveFromGroupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRRemoveFromGroupFunction.cs @@ -1,4 +1,5 @@ using System.Net; +using AutopilotMonitor.Functions.Helpers; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; @@ -22,6 +23,16 @@ namespace AutopilotMonitor.Functions.Functions { try { + // Validate authentication + var httpContext = req.FunctionContext.GetHttpContext(); + if (httpContext?.User?.Identity?.IsAuthenticated != true) + { + _logger.LogWarning("Unauthenticated RemoveFromGroup attempt"); + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorizedResponse.WriteAsJsonAsync(new { success = false, message = "Authentication required" }); + return new RemoveFromGroupOutput { HttpResponse = unauthorizedResponse }; + } + // Parse request var requestBody = await new StreamReader(req.Body).ReadToEndAsync(); var request = JsonConvert.DeserializeObject(requestBody); @@ -33,9 +44,11 @@ namespace AutopilotMonitor.Functions.Functions return new RemoveFromGroupOutput { HttpResponse = errorResponse }; } + var userEmail = TenantHelper.GetUserIdentifier(req); + // Extract session ID from group name if it's a session-specific group var logPrefix = ExtractLogPrefix(request.GroupName); - _logger.LogInformation($"{logPrefix} RemoveFromGroup: {request.GroupName}"); + _logger.LogInformation($"{logPrefix} RemoveFromGroup: {request.GroupName} (User: {userEmail})"); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/TriggerMaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/TriggerMaintenanceFunction.cs index 6ba3e972..319d726c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/TriggerMaintenanceFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/TriggerMaintenanceFunction.cs @@ -2,8 +2,8 @@ using System; using System.Net; using System.Threading.Tasks; using AutopilotMonitor.Functions.Extensions; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; -using Microsoft.AspNetCore.Authorization; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -37,30 +37,44 @@ namespace AutopilotMonitor.Functions.Functions /// - aggregateOnly: If true, only runs aggregation (skips timeout and cleanup) /// [Function("TriggerMaintenance")] - [Authorize] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "maintenance/trigger")] HttpRequestData req, FunctionContext context) { _logger.LogInformation("Manual maintenance trigger requested"); - // Check if user is galactic admin - var principal = context.GetUser(); - if (principal == null) + // Validate authentication + var httpContext = req.FunctionContext.GetHttpContext(); + if (httpContext?.User?.Identity?.IsAuthenticated != true) { - return req.CreateResponse(HttpStatusCode.Unauthorized); + _logger.LogWarning("Unauthenticated maintenance trigger attempt"); + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorizedResponse.WriteAsJsonAsync(new + { + success = false, + message = "Authentication required. Please provide a valid JWT token." + }); + return unauthorizedResponse; } - var upn = principal.GetUserPrincipalName(); - var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(upn); + // Check if user is Galactic Admin via GalacticAdminService (Azure Table Storage) + var userEmail = TenantHelper.GetUserIdentifier(req); + var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userEmail); if (!isGalacticAdmin) { + _logger.LogWarning($"Non-Galactic Admin user {userEmail} attempted to trigger maintenance"); var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden); - await forbiddenResponse.WriteAsJsonAsync(new { error = "Only Galactic Admins can trigger maintenance tasks" }); + await forbiddenResponse.WriteAsJsonAsync(new + { + success = false, + message = "Access denied. Galactic Admin role required." + }); return forbiddenResponse; } + _logger.LogInformation($"Maintenance trigger initiated by Galactic Admin: {userEmail}"); + try { // Parse query parameters @@ -86,7 +100,7 @@ namespace AutopilotMonitor.Functions.Functions bool aggregateOnly = aggregateOnlyParam?.ToLower() == "true"; // Execute maintenance tasks - var result = await _maintenanceFunction.RunManualAsync(targetDate, aggregateOnly, upn); + var result = await _maintenanceFunction.RunManualAsync(targetDate, aggregateOnly, userEmail); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new @@ -94,7 +108,7 @@ namespace AutopilotMonitor.Functions.Functions success = true, message = "Maintenance tasks completed", result = result, - triggeredBy = upn, + triggeredBy = userEmail, triggeredAt = DateTime.UtcNow }); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantConfigurationFunction.cs index 2982fbfd..ed7d4323 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantConfigurationFunction.cs @@ -2,6 +2,7 @@ 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; @@ -31,7 +32,40 @@ namespace AutopilotMonitor.Functions.Functions { try { - _logger.LogInformation($"UpdateTenantConfiguration: {tenantId}"); + // Get tenant ID from JWT token and validate access + string authenticatedTenantId; + string userIdentifier; + try + { + authenticatedTenantId = TenantHelper.GetTenantId(req); + userIdentifier = TenantHelper.GetUserIdentifier(req); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning($"Unauthorized update tenant configuration attempt for tenant {tenantId}: {ex.Message}"); + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorizedResponse.WriteAsJsonAsync(new + { + success = false, + message = "Authentication required. Please provide a valid JWT token." + }); + return unauthorizedResponse; + } + + // Validate tenant access: User can only update their own tenant's configuration + if (authenticatedTenantId != tenantId) + { + _logger.LogWarning($"User {userIdentifier} from tenant {authenticatedTenantId} attempted to update configuration for tenant {tenantId}"); + var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden); + await forbiddenResponse.WriteAsJsonAsync(new + { + success = false, + message = "Access denied. You can only update your own tenant's configuration." + }); + return forbiddenResponse; + } + + _logger.LogInformation($"UpdateTenantConfiguration: {tenantId} by user {userIdentifier}"); // Parse request body var requestBody = await new StreamReader(req.Body).ReadToEndAsync(); @@ -47,8 +81,8 @@ namespace AutopilotMonitor.Functions.Functions // Ensure tenant ID matches config.TenantId = tenantId; - // TODO: Get current user from authentication context - config.UpdatedBy = "Admin"; // Placeholder until authentication is implemented + // Set the actual user identifier for audit logging + config.UpdatedBy = userIdentifier; // Save configuration await _configService.SaveConfigurationAsync(config); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/UsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/UsageMetricsFunction.cs index e4da7a8d..eb5cd7ec 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/UsageMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/UsageMetricsFunction.cs @@ -1,4 +1,5 @@ using System.Net; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -35,28 +36,26 @@ namespace AutopilotMonitor.Functions.Functions try { - // Get tenant ID from query parameter - var tenantId = req.Query["tenantId"]; - - if (string.IsNullOrEmpty(tenantId)) + string tenantId; + string userIdentifier; + try { - var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); - await badRequest.WriteAsJsonAsync(new + tenantId = TenantHelper.GetTenantId(req); + userIdentifier = TenantHelper.GetUserIdentifier(req); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning($"Unauthorized usage metrics attempt: {ex.Message}"); + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorizedResponse.WriteAsJsonAsync(new { success = false, - message = "tenantId query parameter is required" + message = "Authentication required. Please provide a valid JWT token." }); - return badRequest; + return unauthorizedResponse; } - // TODO: Add Entra ID authentication check here (Tenant Admin role required) - // When Entra ID is implemented: - // 1. Extract JWT token from Authorization header - // 2. Validate token with Azure AD - // 3. Check user has "TenantAdmin" role for the requested tenantId - // 4. Return 403 Forbidden if not authorized - // - // For now, anyone can access (will be restricted with Entra ID later) + _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 new file mode 100644 index 00000000..e7165994 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantHelper.cs @@ -0,0 +1,66 @@ +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using System.Security.Claims; + +namespace AutopilotMonitor.Functions.Helpers; + +/// +/// Helper class for extracting tenant information from authenticated requests +/// +public static class TenantHelper +{ + /// + /// 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. + /// + /// Normal Users: Can only see sessions with their own tenant ID (from JWT 'tid' claim) + /// + /// The HTTP request + /// The tenant ID from the JWT token + /// 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; + + if (!isAuthenticated) + { + 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)) + { + throw new UnauthorizedAccessException("Tenant ID (tid) claim not found in token"); + } + + // Return the tenant ID from the JWT token + return tenantIdClaim; + } + + /// + /// Gets the authenticated user's email or name for audit logging + /// + /// The HTTP request + /// 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) + { + return "Anonymous"; + } + + var user = httpContext.User; + + // Try to get email first, then preferred_username, then name + return user.FindFirst(ClaimTypes.Email)?.Value ?? + user.FindFirst("preferred_username")?.Value ?? + user.FindFirst(ClaimTypes.Name)?.Value ?? + user.FindFirst("name")?.Value ?? + "Unknown"; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs index 80937e5b..931b7068 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs @@ -31,8 +31,8 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware _configuration = configuration; _tokenHandler = new JsonWebTokenHandler(); - // Enable PII logging to see detailed error messages (for debugging only!) - Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true; + // 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"; diff --git a/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs index ce147a3d..4800179b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Linq; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -7,10 +8,27 @@ using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Security { /// - /// Validates client certificates for device authentication + /// Validates client certificates for device authentication with full chain validation /// + /// + /// Security measures: + /// - Full X.509 chain validation using X509Chain.Build() + /// - No revocation checking (Intune certificates have no CRL/OCSP endpoints) + /// - Validates entire chain contains Microsoft Intune CA + /// - Prevents self-signed certificate attacks + /// - Validates Enhanced Key Usage for Client Authentication + /// - In-memory cache for validated certificates (5 minute TTL) + /// public static class CertificateValidator { + private static readonly ConcurrentDictionary _validationCache = new(); + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + + private class CachedValidationResult + { + public CertificateValidationResult Result { get; set; } = null!; + public DateTime ExpiresAt { get; set; } + } /// /// Validates a client certificate from HTTP request header /// @@ -33,37 +51,95 @@ namespace AutopilotMonitor.Functions.Security // Parse certificate from Base64 var certBytes = Convert.FromBase64String(certificateBase64); var certificate = new X509Certificate2(certBytes); + var thumbprint = certificate.Thumbprint; - logger?.LogDebug($"Validating certificate: Subject={certificate.Subject}, Issuer={certificate.Issuer}, Thumbprint={certificate.Thumbprint}"); + logger?.LogDebug($"Validating certificate: Subject={certificate.Subject}, Issuer={certificate.Issuer}, Thumbprint={thumbprint}"); + + // Check cache first + if (_validationCache.TryGetValue(thumbprint, out var cached)) + { + if (cached.ExpiresAt > DateTime.UtcNow) + { + logger?.LogDebug($"Certificate validation cache HIT for thumbprint {thumbprint}"); + return cached.Result; + } + else + { + // Remove expired entry + _validationCache.TryRemove(thumbprint, out _); + logger?.LogDebug($"Certificate validation cache EXPIRED for thumbprint {thumbprint}"); + } + } + + logger?.LogDebug($"Certificate validation cache MISS for thumbprint {thumbprint}, performing full validation"); // 1. Check expiry var now = DateTime.UtcNow; if (now < certificate.NotBefore || now > certificate.NotAfter) { - return new CertificateValidationResult + var result = new CertificateValidationResult { IsValid = false, ErrorMessage = $"Certificate expired or not yet valid (NotBefore={certificate.NotBefore}, NotAfter={certificate.NotAfter})", - Thumbprint = certificate.Thumbprint + Thumbprint = thumbprint }; + + // Don't cache failures + return result; } - // 2. Check issuer (Microsoft Intune MDM Device CA or similar) - var issuerValid = certificate.Issuer.Contains("Microsoft Intune", StringComparison.OrdinalIgnoreCase) || - certificate.Issuer.Contains("MDM Device CA", StringComparison.OrdinalIgnoreCase) || - certificate.Issuer.Contains("Microsoft Device CA", StringComparison.OrdinalIgnoreCase); + // 2. Validate certificate chain (without revocation check - Intune certs have no CRL/OCSP) + using var chain = new X509Chain(); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain; + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag; + chain.ChainPolicy.VerificationTime = DateTime.UtcNow; + chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.FromSeconds(10); - if (!issuerValid) + var chainValid = chain.Build(certificate); + + if (!chainValid) { - return new CertificateValidationResult + var chainErrors = string.Join(", ", chain.ChainStatus.Select(s => s.StatusInformation)); + logger?.LogWarning($"Certificate chain validation failed: {chainErrors}"); + + var result = new CertificateValidationResult { IsValid = false, - ErrorMessage = $"Invalid certificate issuer: {certificate.Issuer}", - Thumbprint = certificate.Thumbprint + ErrorMessage = $"Certificate chain validation failed: {chainErrors}", + Thumbprint = thumbprint }; + + // Don't cache failures + return result; } - // 3. Check Enhanced Key Usage for Client Authentication (1.3.6.1.5.5.7.3.2) + // 3. Verify issuer chain contains Microsoft Intune CA + var hasValidIssuer = chain.ChainElements + .Cast() + .Any(element => + element.Certificate.Issuer.Contains("Microsoft Intune", StringComparison.OrdinalIgnoreCase) || + element.Certificate.Issuer.Contains("MDM Device CA", StringComparison.OrdinalIgnoreCase) || + element.Certificate.Subject.Contains("Microsoft Intune", StringComparison.OrdinalIgnoreCase) || + element.Certificate.Subject.Contains("MDM Device CA", StringComparison.OrdinalIgnoreCase)); + + if (!hasValidIssuer) + { + var chainInfo = string.Join(" -> ", chain.ChainElements.Cast().Select(e => e.Certificate.Subject)); + logger?.LogWarning($"Certificate chain does not contain Microsoft Intune CA: {chainInfo}"); + + var result = new CertificateValidationResult + { + IsValid = false, + ErrorMessage = $"Certificate not issued by Microsoft Intune CA", + Thumbprint = thumbprint + }; + + // Don't cache failures + return result; + } + + // 4. Check Enhanced Key Usage for Client Authentication (1.3.6.1.5.5.7.3.2) var hasClientAuth = certificate.Extensions .OfType() .Any(ext => ext.EnhancedKeyUsages @@ -72,23 +148,37 @@ namespace AutopilotMonitor.Functions.Security if (!hasClientAuth) { - return new CertificateValidationResult + var result = new CertificateValidationResult { IsValid = false, ErrorMessage = "Certificate does not have Client Authentication EKU", - Thumbprint = certificate.Thumbprint + Thumbprint = thumbprint }; + + // Don't cache failures + return result; } - // All checks passed - logger?.LogInformation($"Certificate validated successfully: Thumbprint={certificate.Thumbprint}"); - return new CertificateValidationResult + // All checks passed - create and cache success result + logger?.LogInformation($"Certificate validated successfully: Thumbprint={thumbprint}"); + var successResult = new CertificateValidationResult { IsValid = true, - Thumbprint = certificate.Thumbprint, + Thumbprint = thumbprint, Subject = certificate.Subject, Issuer = certificate.Issuer }; + + // Cache successful validation + _validationCache[thumbprint] = new CachedValidationResult + { + Result = successResult, + ExpiresAt = DateTime.UtcNow.Add(CacheDuration) + }; + + logger?.LogDebug($"Certificate validation cached for thumbprint {thumbprint}, expires at {DateTime.UtcNow.Add(CacheDuration):u}"); + + return successResult; } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs b/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs index 7ecb0997..59ba859e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography.X509Certificates; +using AutopilotMonitor.Functions.Security; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -39,7 +41,8 @@ public class HealthCheckService CheckTableStorageAsync(), CheckConfigurationAsync(), CheckAuthenticationAsync(), - CheckSignalRAsync() + CheckSignalRAsync(), + CheckCertificateValidationAsync() }; var completedChecks = await Task.WhenAll(checks); @@ -242,6 +245,100 @@ public class HealthCheckService return Task.FromResult(check); } + + /// + /// Checks certificate validation with test self-signed certificate + /// Demonstrates that self-signed certificates are properly rejected + /// + private Task CheckCertificateValidationAsync() + { + var check = new HealthCheck + { + Name = "Certificate Validation", + Description = "X.509 chain validation and security checks" + }; + + try + { + // Create a test self-signed certificate that mimics an attack + using var testCert = CreateTestSelfSignedCertificate(); + var testCertBase64 = Convert.ToBase64String(testCert.Export(X509ContentType.Cert)); + + // Validate the test certificate - should FAIL + var validationResult = CertificateValidator.ValidateCertificate(testCertBase64, _logger); + + if (!validationResult.IsValid) + { + // Expected: Self-signed certificate should be rejected + check.Status = "healthy"; + check.Message = "Certificate validation properly rejects self-signed certificates"; + check.Details = new Dictionary + { + { "ChainValidation", "Enabled (X509Chain.Build)" }, + { "RevocationCheck", "Online (OCSP/CRL)" }, + { "TestResult", "Self-signed cert rejected ✓" }, + { "RejectionReason", validationResult.ErrorMessage ?? "Unknown" }, + { "SecurityLevel", "High - Chain validation active" } + }; + } + else + { + // Unexpected: Self-signed certificate should NOT pass + check.Status = "unhealthy"; + check.Message = "WARNING: Certificate validation accepted self-signed certificate!"; + check.Details = new Dictionary + { + { "SecurityIssue", "Self-signed certificates are being accepted" }, + { "Recommendation", "Review CertificateValidator implementation" }, + { "TestCertThumbprint", validationResult.Thumbprint ?? "N/A" } + }; + _logger.LogError("Certificate validation security check failed - self-signed cert was accepted!"); + } + } + catch (Exception ex) + { + check.Status = "warning"; + check.Message = $"Certificate validation check error: {ex.Message}"; + check.Details = new Dictionary + { + { "Note", "Check may fail if certificate creation is not supported on this platform" } + }; + _logger.LogWarning(ex, "Certificate validation health check encountered an error"); + } + + return Task.FromResult(check); + } + + /// + /// Creates a test self-signed certificate that mimics an attack scenario + /// + private X509Certificate2 CreateTestSelfSignedCertificate() + { + // Create a self-signed certificate with fake Microsoft Intune issuer + // This simulates the attack described in the security audit + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var certRequest = new CertificateRequest( + "CN=FakeDevice, O=AttackerOrg", + rsa, + System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + + // Add EKU for Client Authentication (like a real MDM cert) + certRequest.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension( + new System.Security.Cryptography.OidCollection + { + new System.Security.Cryptography.Oid("1.3.6.1.5.5.7.3.2") // Client Authentication + }, + false)); + + // Create self-signed certificate with fake issuer name + var cert = certRequest.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(1)); + + return cert; + } } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs index 60043dc5..3230de2f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs @@ -150,6 +150,9 @@ namespace AutopilotMonitor.Functions.Services { "ManufacturerWhitelist", config.ManufacturerWhitelist }, { "ModelWhitelist", config.ModelWhitelist }, { "ValidateSerialNumber", config.ValidateSerialNumber }, + { "DataRetentionDays", config.DataRetentionDays }, + { "SessionTimeoutHours", config.SessionTimeoutHours }, + { "MaxNdjsonPayloadSizeMB", config.MaxNdjsonPayloadSizeMB }, { "CustomSettings", config.CustomSettings } }; @@ -168,6 +171,9 @@ namespace AutopilotMonitor.Functions.Services ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation", ModelWhitelist = entity.GetString("ModelWhitelist") ?? "*", ValidateSerialNumber = entity.GetBoolean("ValidateSerialNumber") ?? false, + DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90, + SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5, + MaxNdjsonPayloadSizeMB = entity.GetInt32("MaxNdjsonPayloadSizeMB") ?? 5, CustomSettings = entity.GetString("CustomSettings") }; } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs index 82aee77c..97459876 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs @@ -75,6 +75,15 @@ namespace AutopilotMonitor.Shared.Models /// public int SessionTimeoutHours { get; set; } = 5; + // ===== PAYLOAD SETTINGS ===== + + /// + /// Maximum decompressed NDJSON payload size in MB + /// Protects against memory exhaustion and denial-of-service attacks + /// Default: 5 MB + /// + public int MaxNdjsonPayloadSizeMB { get; set; } = 5; + // ===== FUTURE SETTINGS (Extensible) ===== /// @@ -122,7 +131,8 @@ namespace AutopilotMonitor.Shared.Models ModelWhitelist = "*", ValidateSerialNumber = false, DataRetentionDays = 90, - SessionTimeoutHours = 5 + SessionTimeoutHours = 5, + MaxNdjsonPayloadSizeMB = 5 }; } } diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx new file mode 100644 index 00000000..6be7f8d5 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ProtectedRoute } from "../../components/ProtectedRoute"; +import { API_BASE_URL } from "@/lib/config"; + +export default function AdminConfigurationPage() { + const router = useRouter(); + const [galacticAdminMode, setGalacticAdminMode] = useState(false); + const [triggeringMaintenance, setTriggeringMaintenance] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + // Load galactic admin mode from localStorage + useEffect(() => { + const galacticMode = localStorage.getItem("galacticAdminMode") === "true"; + setGalacticAdminMode(galacticMode); + + // Redirect if not in galactic admin mode + if (!galacticMode) { + router.push("/"); + } + }, [router]); + + const handleTriggerMaintenance = async () => { + try { + setTriggeringMaintenance(true); + setError(null); + setSuccessMessage(null); + + const response = await fetch(`${API_BASE_URL}/api/maintenance/trigger`, { + method: "POST", + }); + + if (!response.ok) { + throw new Error(`Failed to trigger maintenance: ${response.statusText}`); + } + + const result = await response.json(); + setSuccessMessage("Daily maintenance job completed successfully!"); + + // Auto-hide success message after 5 seconds + setTimeout(() => setSuccessMessage(null), 5000); + } catch (err) { + console.error("Error triggering maintenance:", err); + setError(err instanceof Error ? err.message : "Failed to trigger maintenance job"); + } finally { + setTriggeringMaintenance(false); + } + }; + + if (!galacticAdminMode) { + return null; + } + + return ( + +
+ {/* Header */} +
+
+
+
+ +
+
+

Admin Configuration

+

Galactic Admin Operations

+
+
+
+
+ + {/* Main Content */} +
+
+ {/* Success Message */} + {successMessage && ( +
+ + + + {successMessage} +
+ )} + + {/* Error Message */} + {error && ( +
+ + + + {error} +
+ )} + + {/* Manual Maintenance Trigger */} +
+
+
+ + + +
+

Manual Maintenance Trigger

+

Execute platform-wide maintenance operations

+
+
+
+
+
+
+

+ Manually trigger the daily maintenance job which includes: +

+
    +
  • + • + Mark stalled sessions as timed out +
  • +
  • + • + Aggregate yesterday's metrics into historical snapshots +
  • +
  • + • + Clean up old data based on retention policies +
  • +
+
+
+ + + +

+ Warning: This operation runs across all tenants and may take several minutes to complete. + Use this only for testing or when immediate cleanup is needed. +

+
+
+
+
+ +
+
+
+
+ + {/* Future Admin Functions Placeholder */} +
+
+ + + +

Additional Admin Functions

+
+

+ Additional cross-tenant administrative operations will be added here as needed. +

+
+
+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/health-check/page.tsx b/src/Web/autopilot-monitor-web/app/health-check/page.tsx index a9cf2183..f131eb87 100644 --- a/src/Web/autopilot-monitor-web/app/health-check/page.tsx +++ b/src/Web/autopilot-monitor-web/app/health-check/page.tsx @@ -73,6 +73,8 @@ export default function HealthCheckPage() { return 'text-green-600 bg-green-50 border-green-200'; case 'unhealthy': return 'text-red-600 bg-red-50 border-red-200'; + case 'warning': + return 'text-yellow-600 bg-yellow-50 border-yellow-200'; default: return 'text-gray-600 bg-gray-50 border-gray-200'; } @@ -84,6 +86,8 @@ export default function HealthCheckPage() { return '✅'; case 'unhealthy': return '❌'; + case 'warning': + return '⚠️'; default: return '❓'; } @@ -182,7 +186,11 @@ export default function HealthCheckPage() {
-

+

{check.message}

@@ -245,6 +253,10 @@ export default function HealthCheckPage() { • SignalR: Azure SignalR Service configuration and endpoint +
  • + • + Certificate Validation: X.509 chain validation and self-signed certificate rejection +
  • diff --git a/src/Web/autopilot-monitor-web/app/settings/page.tsx b/src/Web/autopilot-monitor-web/app/settings/page.tsx index 3d44a86d..0f341753 100644 --- a/src/Web/autopilot-monitor-web/app/settings/page.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/page.tsx @@ -38,15 +38,6 @@ export default function SettingsPage() { const [dataRetentionDays, setDataRetentionDays] = useState(90); const [sessionTimeoutHours, setSessionTimeoutHours] = useState(5); - // Galactic Admin state - const [galacticAdminMode, setGalacticAdminMode] = useState(false); - const [triggeringMaintenance, setTriggeringMaintenance] = useState(false); - - // Load galactic admin mode from localStorage - useEffect(() => { - const galacticMode = localStorage.getItem("galacticAdminMode") === "true"; - setGalacticAdminMode(galacticMode); - }, []); // Fetch configuration useEffect(() => { @@ -144,32 +135,6 @@ export default function SettingsPage() { setError(null); }; - const handleTriggerMaintenance = async () => { - try { - setTriggeringMaintenance(true); - setError(null); - setSuccessMessage(null); - - const response = await fetch(`${API_BASE_URL}/api/maintenance/trigger`, { - method: "POST", - }); - - if (!response.ok) { - throw new Error(`Failed to trigger maintenance: ${response.statusText}`); - } - - const result = await response.json(); - setSuccessMessage("Daily maintenance job completed successfully!"); - - // Auto-hide success message after 5 seconds - setTimeout(() => setSuccessMessage(null), 5000); - } catch (err) { - console.error("Error triggering maintenance:", err); - setError(err instanceof Error ? err.message : "Failed to trigger maintenance job"); - } finally { - setTriggeringMaintenance(false); - } - }; return ( @@ -419,79 +384,6 @@ export default function SettingsPage() { - {/* Galactic Admin Functions - Only visible in Galactic Mode */} - {galacticAdminMode && ( -
    -
    -
    - - - -
    -

    Galactic Admin Functions

    -

    Cross-tenant administrative operations

    -
    -
    -
    -
    -
    -
    -

    Manual Maintenance Trigger

    -

    - Manually trigger the daily maintenance job which includes: -

    -
      -
    • - • - Mark stalled sessions as timed out -
    • -
    • - • - Aggregate yesterday's metrics into historical snapshots -
    • -
    • - • - Clean up old data based on retention policies -
    • -
    -
    -
    - - - -

    - Warning: This operation runs across all tenants and may take several minutes to complete. - Use this only for testing or when immediate cleanup is needed. -

    -
    -
    -
    -
    - -
    -
    -
    -
    - )} {/* Configuration Info */} {config && ( diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx index 57ab30dc..0ffe6e38 100644 --- a/src/Web/autopilot-monitor-web/components/Navbar.tsx +++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx @@ -246,6 +246,20 @@ export default function Navbar() { + {/* Admin Configuration - Galactic Admin Only */} + {galacticAdminMode && ( +
    + setShowSettings(false)}> +
    + Admin Configuration + + + +
    + +
    + )} + {/* Platform Usage Metrics - Galactic Admin Only */} {galacticAdminMode && (
    diff --git a/src/Web/autopilot-monitor-web/contexts/TenantContext.tsx b/src/Web/autopilot-monitor-web/contexts/TenantContext.tsx index acdba9b4..c11e71b1 100644 --- a/src/Web/autopilot-monitor-web/contexts/TenantContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/TenantContext.tsx @@ -26,9 +26,10 @@ export function TenantProvider({ children }: { children: React.ReactNode }) { // Update tenant ID from authenticated user useEffect(() => { if (!isLoading && isAuthenticated && user) { - // Use tenant ID from authenticated user + // Use tenant ID from authenticated user's token + // This is the APPLICATION tenant ID, not the Azure AD tenant ID if (user.tenantId && user.tenantId !== tenantId) { - console.log(`[TenantContext] Setting tenant ID from auth: ${user.tenantId}`); + console.log(`[TenantContext] Setting application tenant ID from auth: ${user.tenantId}`); setTenantId(user.tenantId); } } else if (!isLoading && !isAuthenticated) { diff --git a/src/Web/autopilot-monitor-web/lib/msalConfig.ts b/src/Web/autopilot-monitor-web/lib/msalConfig.ts index e39fd8df..00c8a799 100644 --- a/src/Web/autopilot-monitor-web/lib/msalConfig.ts +++ b/src/Web/autopilot-monitor-web/lib/msalConfig.ts @@ -19,7 +19,7 @@ export const msalConfig: Configuration = { navigateToLoginRequestUrl: true, }, cache: { - cacheLocation: "localStorage", // "sessionStorage" or "localStorage" + cacheLocation: "sessionStorage", // Using sessionStorage for better XSS protection storeAuthStateInCookie: false, // Set to true for IE11 or Edge legacy }, system: {