using Microsoft.Extensions.Options; using System.Security.Claims; using System.Text.Json; using System.Text.RegularExpressions; namespace StellaOps.Policy.Engine.Tenancy; /// /// Middleware that extracts tenant context from request headers and validates tenant access. /// Per RLS design at docs/modules/policy/prep/tenant-rls.md. /// public sealed partial class TenantContextMiddleware { private readonly RequestDelegate _next; private readonly TenantContextOptions _options; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; // Valid tenant/project ID pattern: alphanumeric, dashes, underscores [GeneratedRegex("^[a-zA-Z0-9_-]+$", RegexOptions.Compiled)] private static partial Regex ValidIdPattern(); private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, WriteIndented = false }; public TenantContextMiddleware( RequestDelegate next, IOptions options, ILogger logger, TimeProvider timeProvider) { _next = next ?? throw new ArgumentNullException(nameof(next)); _options = options?.Value ?? new TenantContextOptions(); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } public async Task InvokeAsync(HttpContext context, ITenantContextAccessor tenantContextAccessor) { // Skip tenant validation for excluded paths if (!_options.Enabled || IsExcludedPath(context.Request.Path)) { await _next(context); return; } var validationResult = ValidateTenantContext(context); if (!validationResult.IsValid) { await WriteTenantErrorResponse(context, validationResult); return; } // Set tenant context for the request tenantContextAccessor.TenantContext = validationResult.Context; try { using (_logger.BeginScope(new Dictionary { ["tenant_id"] = validationResult.Context?.TenantId, ["project_id"] = validationResult.Context?.ProjectId })) { await _next(context); } } finally { tenantContextAccessor.TenantContext = null; } } private bool IsExcludedPath(PathString path) { var pathValue = path.Value ?? string.Empty; return _options.ExcludedPaths.Any(excluded => pathValue.StartsWith(excluded, StringComparison.OrdinalIgnoreCase)); } private TenantValidationResult ValidateTenantContext(HttpContext context) { // Extract tenant: header first, then canonical claim, then legacy claim fallback. var tenantHeader = context.Request.Headers[TenantContextConstants.TenantHeader].FirstOrDefault(); // POL-TEN-01: Fall back to canonical stellaops:tenant claim if header is absent. if (string.IsNullOrWhiteSpace(tenantHeader)) { tenantHeader = context.User?.FindFirst(TenantContextConstants.CanonicalTenantClaim)?.Value; } // POL-TEN-01: Fall back to legacy "tid" claim for backwards compatibility. if (string.IsNullOrWhiteSpace(tenantHeader)) { tenantHeader = context.User?.FindFirst(TenantContextConstants.LegacyTenantClaim)?.Value; } if (string.IsNullOrWhiteSpace(tenantHeader)) { if (_options.RequireTenantHeader) { _logger.LogWarning( "Missing required tenant context (header {Header} or claim {Claim}) for {Path}", TenantContextConstants.TenantHeader, TenantContextConstants.CanonicalTenantClaim, context.Request.Path); return TenantValidationResult.Failure( TenantContextConstants.MissingTenantHeaderErrorCode, $"Tenant context is required. Provide the {TenantContextConstants.TenantHeader} header or a token with the {TenantContextConstants.CanonicalTenantClaim} claim."); } // Use default tenant ID when header is not required tenantHeader = TenantContextConstants.DefaultTenantId; } // Validate tenant ID format if (!IsValidTenantId(tenantHeader)) { _logger.LogWarning( "Invalid tenant ID format: {TenantId}", tenantHeader); return TenantValidationResult.Failure( TenantContextConstants.InvalidTenantIdErrorCode, "Invalid tenant ID format. Must be alphanumeric with dashes and underscores."); } // Extract project header (optional) var projectHeader = context.Request.Headers[TenantContextConstants.ProjectHeader].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(projectHeader) && !IsValidProjectId(projectHeader)) { _logger.LogWarning( "Invalid project ID format: {ProjectId}", projectHeader); return TenantValidationResult.Failure( TenantContextConstants.InvalidTenantIdErrorCode, "Invalid project ID format. Must be alphanumeric with dashes and underscores."); } // Determine write permission from scopes/claims var canWrite = DetermineWritePermission(context); // Extract actor ID var actorId = ExtractActorId(context); var tenantContext = TenantContext.ForTenant( tenantHeader, string.IsNullOrWhiteSpace(projectHeader) ? null : projectHeader, canWrite, actorId, _timeProvider); _logger.LogDebug( "Tenant context established: tenant={TenantId}, project={ProjectId}, canWrite={CanWrite}, actor={ActorId}", tenantContext.TenantId, tenantContext.ProjectId ?? "(none)", tenantContext.CanWrite, tenantContext.ActorId ?? "(anonymous)"); return TenantValidationResult.Success(tenantContext); } private bool IsValidTenantId(string tenantId) { if (string.IsNullOrWhiteSpace(tenantId)) { return false; } if (tenantId.Length > _options.MaxTenantIdLength) { return false; } return ValidIdPattern().IsMatch(tenantId); } private bool IsValidProjectId(string projectId) { if (string.IsNullOrWhiteSpace(projectId)) { return true; // Project ID is optional } if (projectId.Length > _options.MaxProjectIdLength) { return false; } return ValidIdPattern().IsMatch(projectId); } private static bool DetermineWritePermission(HttpContext context) { var user = context.User; if (user?.Identity?.IsAuthenticated != true) { return false; } // Check for write-related scopes var hasWriteScope = user.Claims.Any(c => c.Type == "scope" && (c.Value.Contains("policy:write", StringComparison.OrdinalIgnoreCase) || c.Value.Contains("policy:edit", StringComparison.OrdinalIgnoreCase) || c.Value.Contains("policy:activate", StringComparison.OrdinalIgnoreCase))); if (hasWriteScope) { return true; } // Check for admin role var hasAdminRole = user.IsInRole("admin") || user.IsInRole("policy-admin") || user.HasClaim("role", "admin") || user.HasClaim("role", "policy-admin"); return hasAdminRole; } private static string? ExtractActorId(HttpContext context) { var user = context.User; // Try standard claims var actorId = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user?.FindFirst(ClaimTypes.Upn)?.Value ?? user?.FindFirst("sub")?.Value ?? user?.FindFirst("client_id")?.Value; if (!string.IsNullOrWhiteSpace(actorId)) { return actorId; } // Fall back to header if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header)) { return header.ToString(); } return null; } private static async Task WriteTenantErrorResponse(HttpContext context, TenantValidationResult result) { context.Response.StatusCode = StatusCodes.Status400BadRequest; context.Response.ContentType = "application/json"; var errorResponse = new TenantErrorResponse( result.ErrorCode ?? "UNKNOWN_ERROR", result.ErrorMessage ?? "An unknown error occurred.", context.Request.Path.Value ?? "/"); await context.Response.WriteAsync( JsonSerializer.Serialize(errorResponse, JsonOptions)); } } /// /// Error response for tenant validation failures. /// internal sealed record TenantErrorResponse( string ErrorCode, string Message, string Path);