wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -22,6 +22,8 @@ public sealed class IdentityHeaderPolicyMiddleware
private readonly RequestDelegate _next;
private readonly ILogger<IdentityHeaderPolicyMiddleware> _logger;
private readonly IdentityHeaderPolicyOptions _options;
private static readonly char[] TenantClaimDelimiters = [' ', ',', ';', '\t', '\r', '\n'];
private static readonly string[] TenantRequestHeaders = ["X-StellaOps-Tenant", "X-Stella-Tenant", "X-Tenant-Id"];
/// <summary>
/// Reserved identity headers that must never be trusted from external clients.
@@ -79,12 +81,41 @@ public sealed class IdentityHeaderPolicyMiddleware
return;
}
var requestedTenant = CaptureRequestedTenant(context.Request.Headers);
var clientSuppliedTenantHeader = HasClientSuppliedTenantHeader(context.Request.Headers);
// Step 1: Strip all reserved identity headers from incoming request
StripReservedHeaders(context);
StripReservedHeaders(context, ShouldPreserveAuthHeaders(context.Request.Path));
// Step 2: Extract identity from validated principal
var identity = ExtractIdentity(context);
if (clientSuppliedTenantHeader)
{
LogTenantHeaderTelemetry(
context,
identity,
requestedTenant);
}
if (!identity.IsAnonymous &&
!string.IsNullOrWhiteSpace(requestedTenant) &&
!string.IsNullOrWhiteSpace(identity.Tenant) &&
!string.Equals(requestedTenant, identity.Tenant, StringComparison.Ordinal))
{
if (!TryApplyTenantOverride(context, identity, requestedTenant))
{
await context.Response.WriteAsJsonAsync(
new
{
error = "tenant_override_forbidden",
message = "Requested tenant override is not permitted for this principal."
},
cancellationToken: context.RequestAborted).ConfigureAwait(false);
return;
}
}
// Step 3: Store normalized identity in HttpContext.Items
StoreIdentityContext(context, identity);
@@ -94,12 +125,8 @@ public sealed class IdentityHeaderPolicyMiddleware
await _next(context);
}
private void StripReservedHeaders(HttpContext context)
private void StripReservedHeaders(HttpContext context, bool preserveAuthHeaders)
{
var preserveAuthHeaders = _options.JwtPassthroughPrefixes.Count > 0
&& _options.JwtPassthroughPrefixes.Any(prefix =>
context.Request.Path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
foreach (var header in ReservedHeaders)
{
// Preserve Authorization/DPoP for routes that need JWT pass-through
@@ -119,6 +146,172 @@ public sealed class IdentityHeaderPolicyMiddleware
}
}
private bool ShouldPreserveAuthHeaders(PathString path)
{
if (_options.JwtPassthroughPrefixes.Count == 0)
{
return false;
}
var configuredMatch = _options.JwtPassthroughPrefixes.Any(prefix =>
path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
if (!configuredMatch)
{
return false;
}
if (_options.ApprovedAuthPassthroughPrefixes.Count == 0)
{
return false;
}
var approvedMatch = _options.ApprovedAuthPassthroughPrefixes.Any(prefix =>
path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
if (approvedMatch)
{
return true;
}
_logger.LogWarning(
"Gateway route {Path} requested Authorization/DPoP passthrough but prefix is not in approved allow-list. Headers will be stripped.",
path.Value);
return false;
}
private static bool HasClientSuppliedTenantHeader(IHeaderDictionary headers)
=> TenantRequestHeaders.Any(headers.ContainsKey);
private static string? CaptureRequestedTenant(IHeaderDictionary headers)
{
foreach (var header in TenantRequestHeaders)
{
if (!headers.TryGetValue(header, out var value))
{
continue;
}
var normalized = NormalizeTenant(value.ToString());
if (!string.IsNullOrWhiteSpace(normalized))
{
return normalized;
}
}
return null;
}
private void LogTenantHeaderTelemetry(HttpContext context, IdentityContext identity, string? requestedTenant)
{
var resolvedTenant = identity.Tenant;
var actor = identity.Actor ?? "unknown";
if (string.IsNullOrWhiteSpace(requestedTenant))
{
_logger.LogInformation(
"Gateway stripped client-supplied tenant headers with empty value. Route={Route} Actor={Actor} ResolvedTenant={ResolvedTenant}",
context.Request.Path.Value,
actor,
resolvedTenant);
return;
}
if (string.IsNullOrWhiteSpace(resolvedTenant))
{
_logger.LogWarning(
"Gateway stripped tenant override attempt but authenticated principal has no resolved tenant. Route={Route} Actor={Actor} RequestedTenant={RequestedTenant}",
context.Request.Path.Value,
actor,
requestedTenant);
return;
}
if (!string.Equals(requestedTenant, resolvedTenant, StringComparison.Ordinal))
{
_logger.LogWarning(
"Gateway stripped tenant override attempt. Route={Route} Actor={Actor} RequestedTenant={RequestedTenant} ResolvedTenant={ResolvedTenant}",
context.Request.Path.Value,
actor,
requestedTenant,
resolvedTenant);
return;
}
_logger.LogInformation(
"Gateway stripped client-supplied tenant header that matched resolved tenant. Route={Route} Actor={Actor} Tenant={Tenant}",
context.Request.Path.Value,
actor,
resolvedTenant);
}
private bool TryApplyTenantOverride(HttpContext context, IdentityContext identity, string requestedTenant)
{
if (!_options.EnableTenantOverride)
{
_logger.LogWarning(
"Tenant override rejected because feature is disabled. Route={Route} Actor={Actor} RequestedTenant={RequestedTenant} ResolvedTenant={ResolvedTenant}",
context.Request.Path.Value,
identity.Actor ?? "unknown",
requestedTenant,
identity.Tenant);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return false;
}
var allowedTenants = ResolveAllowedTenants(context.User);
if (!allowedTenants.Contains(requestedTenant))
{
_logger.LogWarning(
"Tenant override rejected because requested tenant is not in allow-list. Route={Route} Actor={Actor} RequestedTenant={RequestedTenant} AllowedTenants={AllowedTenants}",
context.Request.Path.Value,
identity.Actor ?? "unknown",
requestedTenant,
string.Join(",", allowedTenants.OrderBy(static tenant => tenant, StringComparer.Ordinal)));
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return false;
}
identity.Tenant = requestedTenant;
_logger.LogInformation(
"Tenant override accepted. Route={Route} Actor={Actor} SelectedTenant={SelectedTenant}",
context.Request.Path.Value,
identity.Actor ?? "unknown",
identity.Tenant);
return true;
}
private static HashSet<string> ResolveAllowedTenants(ClaimsPrincipal principal)
{
var tenants = new HashSet<string>(StringComparer.Ordinal);
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.AllowedTenants))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
foreach (var raw in claim.Value.Split(TenantClaimDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var normalized = NormalizeTenant(raw);
if (!string.IsNullOrWhiteSpace(normalized))
{
tenants.Add(normalized);
}
}
}
var selectedTenant = NormalizeTenant(principal.FindFirstValue(StellaOpsClaimTypes.Tenant) ?? principal.FindFirstValue("tid"));
if (!string.IsNullOrWhiteSpace(selectedTenant))
{
tenants.Add(selectedTenant);
}
return tenants;
}
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private IdentityContext ExtractIdentity(HttpContext context)
{
var principal = context.User;
@@ -137,11 +330,15 @@ public sealed class IdentityHeaderPolicyMiddleware
// Extract subject (actor)
var actor = principal.FindFirstValue(StellaOpsClaimTypes.Subject);
// Extract tenant - try canonical claim first, then legacy 'tid',
// then fall back to "default".
var tenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
?? principal.FindFirstValue("tid")
?? "default";
// Extract tenant from validated claims. Legacy 'tid' remains compatibility-only.
var tenant = NormalizeTenant(principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
?? principal.FindFirstValue("tid"));
if (string.IsNullOrWhiteSpace(tenant))
{
_logger.LogWarning(
"Authenticated request {TraceId} missing tenant claim; downstream tenant headers will be omitted.",
context.TraceIdentifier);
}
// Extract project (optional)
var project = principal.FindFirstValue(StellaOpsClaimTypes.Project);
@@ -386,7 +583,7 @@ public sealed class IdentityHeaderPolicyMiddleware
{
public bool IsAnonymous { get; init; }
public string? Actor { get; init; }
public string? Tenant { get; init; }
public string? Tenant { get; set; }
public string? Project { get; init; }
public HashSet<string> Scopes { get; init; } = [];
public IReadOnlyList<string> Roles { get; init; } = [];
@@ -446,4 +643,20 @@ public sealed class IdentityHeaderPolicyOptions
/// Default: empty (strip auth headers for all routes).
/// </summary>
public List<string> JwtPassthroughPrefixes { get; set; } = [];
/// <summary>
/// Approved route prefixes where auth passthrough is allowed when configured.
/// </summary>
public List<string> ApprovedAuthPassthroughPrefixes { get; set; } =
[
"/connect",
"/console",
"/api/admin"
];
/// <summary>
/// Enables per-request tenant override using tenant headers and allow-list claims.
/// Default: false.
/// </summary>
public bool EnableTenantOverride { get; set; } = false;
}