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