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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user