using System.Text.Json; namespace WorkClub.Api.Middleware; public class TenantValidationMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; public TenantValidationMiddleware(RequestDelegate next, ILogger logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context) { _logger.LogInformation("TenantValidationMiddleware: Processing request for {Path}", context.Request.Path); if (!context.User.Identity?.IsAuthenticated ?? true) { await _next(context); return; } // Exempt bootstrap and admin endpoints from tenant validation if (context.Request.Path.StartsWithSegments("/api/clubs/me") || context.Request.Path.StartsWithSegments("/api/admin")) { _logger.LogInformation("TenantValidationMiddleware: Exempting {Path} from tenant validation", context.Request.Path); await _next(context); return; } if (!context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantIdHeader) || string.IsNullOrWhiteSpace(tenantIdHeader)) { context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsJsonAsync(new { error = "X-Tenant-Id header is required" }); return; } var requestedTenantId = tenantIdHeader.ToString(); var clubsClaim = context.User.FindFirst("clubs")?.Value; if (string.IsNullOrEmpty(clubsClaim)) { // NEW: Skip check if user is a global admin var realmAccess = context.User.FindFirst("realm_access")?.Value; if (!string.IsNullOrEmpty(realmAccess) && IsAdminUser(realmAccess)) { await _next(context); return; } static bool IsAdminUser(string realmAccess) { try { using var doc = System.Text.Json.JsonDocument.Parse(realmAccess); if (doc.RootElement.TryGetProperty("roles", out var rolesElement) && rolesElement.ValueKind == System.Text.Json.JsonValueKind.Array) { foreach (var role in rolesElement.EnumerateArray()) { if (role.GetString()?.Equals("admin", StringComparison.OrdinalIgnoreCase) == true) return true; } } } catch { // If JSON parsing fails, fallback to string contains check return realmAccess.Contains("admin", StringComparison.OrdinalIgnoreCase); } return false; } context.Response.StatusCode = StatusCodes.Status403Forbidden; await context.Response.WriteAsJsonAsync(new { error = "User does not have clubs claim" }); return; } // Parse comma-separated club UUIDs var clubIds = clubsClaim.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(id => id.Trim()) .ToArray(); if (clubIds.Length == 0 || !clubIds.Contains(requestedTenantId)) { context.Response.StatusCode = StatusCodes.Status403Forbidden; await context.Response.WriteAsJsonAsync(new { error = $"User is not a member of tenant {requestedTenantId}" }); return; } // Store validated tenant ID in HttpContext.Items for downstream middleware/services context.Items["TenantId"] = requestedTenantId; _logger.LogInformation("TenantValidationMiddleware: Set TenantId={TenantId} in HttpContext.Items", requestedTenantId); await _next(context); } }