277 lines
9.3 KiB
C#
277 lines
9.3 KiB
C#
|
|
using Microsoft.Extensions.Options;
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace StellaOps.Policy.Engine.Tenancy;
|
|
|
|
/// <summary>
|
|
/// Middleware that extracts tenant context from request headers and validates tenant access.
|
|
/// Per RLS design at docs/modules/policy/prep/tenant-rls.md.
|
|
/// </summary>
|
|
public sealed partial class TenantContextMiddleware
|
|
{
|
|
private readonly RequestDelegate _next;
|
|
private readonly TenantContextOptions _options;
|
|
private readonly ILogger<TenantContextMiddleware> _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<TenantContextOptions> options,
|
|
ILogger<TenantContextMiddleware> 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<string, object?>
|
|
{
|
|
["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));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Error response for tenant validation failures.
|
|
/// </summary>
|
|
internal sealed record TenantErrorResponse(
|
|
string ErrorCode,
|
|
string Message,
|
|
string Path);
|