using Microsoft.EntityFrameworkCore; using OptimAI.BRE.RuleEngine.Api; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; namespace OptimAI.BRE.RuleEngine.Infrastructure; /// /// Resolves tenant from JWT claims (X-Tenant-ID header fallback). /// Every request must carry a valid tenant context. /// public sealed class TenantResolutionMiddleware : IMiddleware { public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { // Skip for health checks and swagger if (ctx.Request.Path.StartsWithSegments("/health") || ctx.Request.Path.StartsWithSegments("/swagger")) { await next(ctx); return; } var tenantId = ResolveTenantId(ctx); if (tenantId == null && !IsPublicEndpoint(ctx.Request.Path)) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = "Tenant context missing" }); return; } if (tenantId != null) ctx.Items["TenantId"] = tenantId; await next(ctx); } private static Guid? ResolveTenantId(HttpContext ctx) { // 1. From JWT claim var tenantClaim = ctx.User.FindFirst("tenant_id")?.Value; if (tenantClaim != null && Guid.TryParse(tenantClaim, out var tid)) return tid; // 2. From header (API key auth) var header = ctx.Request.Headers["X-Tenant-ID"].FirstOrDefault(); if (header != null && Guid.TryParse(header, out var hid)) return hid; return null; } private static bool IsPublicEndpoint(PathString path) => path.StartsWithSegments("/api/v1/auth"); } /// /// API Key authentication middleware. Validates X-API-Key header. /// Populates tenant context and user claims from API key. /// public sealed class ApiKeyMiddleware : IMiddleware { private readonly BREDbContext _db; public ApiKeyMiddleware(BREDbContext db) => _db = db; public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { var apiKey = ctx.Request.Headers["X-API-Key"].FirstOrDefault(); if (apiKey != null && !ctx.User.Identity?.IsAuthenticated == true) { var prefix = apiKey.Length >= 12 ? apiKey[..12] : apiKey; var keyHash = HashApiKey(apiKey); var key = await _db.ApiKeys .FirstOrDefaultAsync(k => k.ApiKeyPrefix == prefix && k.ApiKeyHash == keyHash && k.IsActive && (k.ExpiresAt == null || k.ExpiresAt > DateTime.UtcNow)); if (key != null) { await _db.ApiKeys .Where(k => k.Id == key.Id) .ExecuteUpdateAsync(s => s.SetProperty(k => k.LastUsedAt, DateTime.UtcNow)); var claims = new List { new(ClaimTypes.NameIdentifier, key.Id.ToString()), new("tenant_id", key.TenantId.ToString()), new("auth_type", "api_key"), new("api_key_id", key.Id.ToString()), }; foreach (var scope in key.Scopes) claims.Add(new Claim("permission", scope)); var identity = new ClaimsIdentity(claims, "ApiKey"); ctx.User = new ClaimsPrincipal(identity); ctx.Items["TenantId"] = key.TenantId; } } await next(ctx); } private static string HashApiKey(string key) { using var sha = System.Security.Cryptography.SHA256.Create(); var bytes = System.Text.Encoding.UTF8.GetBytes(key); return Convert.ToHexString(sha.ComputeHash(bytes)).ToLower(); } } /// /// HttpContext-scoped tenant and user context accessor. /// public sealed class TenantContextAccessor : ITenantContextAccessor { private readonly IHttpContextAccessor _httpContextAccessor; public TenantContextAccessor(IHttpContextAccessor httpContextAccessor) => _httpContextAccessor = httpContextAccessor; public Guid TenantId { get { var ctx = _httpContextAccessor.HttpContext; if (ctx?.Items["TenantId"] is Guid id) return id; var claim = ctx?.User.FindFirst("tenant_id")?.Value; return claim != null && Guid.TryParse(claim, out var tid) ? tid : Guid.Empty; } } public Guid UserId { get { var claim = _httpContextAccessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; return claim != null && Guid.TryParse(claim, out var id) ? id : Guid.Empty; } } public string? UserEmail => _httpContextAccessor.HttpContext?.User.FindFirst(ClaimTypes.Email)?.Value; }