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

@@ -184,6 +184,12 @@ public sealed class GatewayAuthOptions
/// </summary>
public bool AllowScopeHeader { get; set; } = false;
/// <summary>
/// Enables per-request tenant override when explicitly configured.
/// Default: false.
/// </summary>
public bool EnableTenantOverride { get; set; } = false;
/// <summary>
/// Emit signed identity envelope headers for router-dispatched requests.
/// </summary>

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;
}

View File

@@ -125,7 +125,8 @@ builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
JwtPassthroughPrefixes = bootstrapOptions.Routes
.Where(r => r.PreserveAuthHeaders)
.Select(r => r.Path)
.ToList()
.ToList(),
EnableTenantOverride = bootstrapOptions.Auth.EnableTenantOverride
});
// Route table: resolver + error routes + HTTP client for reverse proxy

View File

@@ -191,13 +191,6 @@ public sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
operation["summary"] = $"{endpoint.Method} {gatewayPath}";
}
if (operation["description"] is null &&
operation["summary"] is JsonValue summaryValue &&
summaryValue.TryGetValue<string>(out var summaryText))
{
operation["description"] = summaryText;
}
// Add security requirements
var security = ClaimSecurityMapper.GenerateSecurityRequirement(
endpoint.AllowAnonymous,

View File

@@ -124,7 +124,7 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
#region Header Overwriting (Not Set-If-Missing)
[Fact]
public async Task InvokeAsync_OverwritesSpoofedTenantWithClaimValue()
public async Task InvokeAsync_RejectsSpoofedTenantHeaderWhenOverrideDisabled()
{
var middleware = CreateMiddleware();
var claims = new[]
@@ -133,15 +133,15 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
new Claim(StellaOpsClaimTypes.Subject, "real-subject")
};
var context = CreateHttpContext("/api/scan", claims);
context.Response.Body = new MemoryStream();
// Client attempts to spoof tenant
context.Request.Headers["X-StellaOps-Tenant"] = "spoofed-tenant";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Header should contain claim value, not spoofed value
Assert.Equal("real-tenant", context.Request.Headers["X-StellaOps-Tenant"].ToString());
Assert.False(_nextCalled);
Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode);
}
[Fact]
@@ -225,6 +225,7 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
Assert.True(_nextCalled);
Assert.Equal("tenant-abc", context.Request.Headers["X-StellaOps-Tenant"].ToString());
Assert.Equal("tenant-abc", context.Request.Headers["X-Tenant-Id"].ToString());
Assert.Equal("tenant-abc", context.Items[GatewayContextKeys.TenantId]);
}
@@ -243,6 +244,25 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
Assert.True(_nextCalled);
Assert.Equal("legacy-tenant-456", context.Request.Headers["X-StellaOps-Tenant"].ToString());
Assert.Equal("legacy-tenant-456", context.Request.Headers["X-Tenant-Id"].ToString());
}
[Fact]
public async Task InvokeAsync_AuthenticatedRequestWithoutTenantClaim_DoesNotWriteTenantHeaders()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.DoesNotContain("X-StellaOps-Tenant", context.Request.Headers.Keys);
Assert.DoesNotContain("X-Stella-Tenant", context.Request.Headers.Keys);
Assert.DoesNotContain("X-Tenant-Id", context.Request.Headers.Keys);
}
[Fact]
@@ -308,6 +328,109 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
#endregion
#region Tenant Override
[Fact]
public async Task InvokeAsync_OverrideEnabledAndAllowed_UsesRequestedTenant()
{
_options.EnableTenantOverride = true;
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.Tenant, "tenant-a"),
new Claim(StellaOpsClaimTypes.AllowedTenants, "tenant-a tenant-b")
};
var context = CreateHttpContext("/api/platform", claims);
context.Request.Headers["X-StellaOps-Tenant"] = "TENANT-B";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal("tenant-b", context.Request.Headers["X-StellaOps-Tenant"].ToString());
Assert.Equal("tenant-b", context.Request.Headers["X-Tenant-Id"].ToString());
Assert.Equal("tenant-b", context.Items[GatewayContextKeys.TenantId]);
}
[Fact]
public async Task InvokeAsync_OverrideEnabledButNotAllowed_ReturnsForbidden()
{
_options.EnableTenantOverride = true;
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.Tenant, "tenant-a"),
new Claim(StellaOpsClaimTypes.AllowedTenants, "tenant-a tenant-c")
};
var context = CreateHttpContext("/api/platform", claims);
context.Response.Body = new MemoryStream();
context.Request.Headers["X-StellaOps-Tenant"] = "tenant-b";
await middleware.InvokeAsync(context);
Assert.False(_nextCalled);
Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode);
}
#endregion
#region Auth Header Passthrough
[Fact]
public async Task InvokeAsync_PreservesAuthorizationHeadersForApprovedConfiguredPrefix()
{
_options.JwtPassthroughPrefixes = ["/connect"];
_options.ApprovedAuthPassthroughPrefixes = ["/connect", "/console"];
var middleware = CreateMiddleware();
var context = CreateHttpContext("/connect/token");
context.Request.Headers.Authorization = "Bearer token-value";
context.Request.Headers["DPoP"] = "proof-value";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal("Bearer token-value", context.Request.Headers.Authorization.ToString());
Assert.Equal("proof-value", context.Request.Headers["DPoP"].ToString());
}
[Fact]
public async Task InvokeAsync_StripsAuthorizationHeadersWhenConfiguredPrefixIsNotApproved()
{
_options.JwtPassthroughPrefixes = ["/api/v1/authority"];
_options.ApprovedAuthPassthroughPrefixes = ["/connect"];
var middleware = CreateMiddleware();
var context = CreateHttpContext("/api/v1/authority/clients");
context.Request.Headers.Authorization = "Bearer token-value";
context.Request.Headers["DPoP"] = "proof-value";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.False(context.Request.Headers.ContainsKey("Authorization"));
Assert.False(context.Request.Headers.ContainsKey("DPoP"));
}
[Fact]
public async Task InvokeAsync_StripsAuthorizationHeadersWhenPrefixIsNotConfigured()
{
_options.JwtPassthroughPrefixes = [];
_options.ApprovedAuthPassthroughPrefixes = ["/connect"];
var middleware = CreateMiddleware();
var context = CreateHttpContext("/connect/token");
context.Request.Headers.Authorization = "Bearer token-value";
context.Request.Headers["DPoP"] = "proof-value";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.False(context.Request.Headers.ContainsKey("Authorization"));
Assert.False(context.Request.Headers.ContainsKey("DPoP"));
}
#endregion
#region Legacy Header Compatibility
[Fact]

View File

@@ -464,7 +464,7 @@ public sealed class OpenApiDocumentGeneratorTests
}
[Fact]
public void GenerateDocument_WithoutExplicitDescription_UsesSummaryAsDescriptionFallback()
public void GenerateDocument_WithoutExplicitDescription_LeavesDescriptionUnset()
{
var endpoint = new EndpointDescriptor
{
@@ -485,7 +485,37 @@ public sealed class OpenApiDocumentGeneratorTests
var operation = document["paths"]!["/api/v1/timeline"]!["get"]!.AsObject();
operation["summary"]!.GetValue<string>().Should().Be("GET /api/v1/timeline");
operation["description"]!.GetValue<string>().Should().Be("GET /api/v1/timeline");
operation["description"].Should().BeNull();
}
[Fact]
public void GenerateDocument_WithSchemaDescription_PreservesEndpointDescription()
{
var endpoint = new EndpointDescriptor
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Method = "GET",
Path = "/api/v1/timeline",
SchemaInfo = new EndpointSchemaInfo
{
Summary = "Get timeline",
Description = "Return the timeline entries in reverse chronological order."
}
};
var routingState = new Mock<IGlobalRoutingState>();
routingState.Setup(state => state.GetAllConnections()).Returns([CreateConnection("timelineindexer", endpoint)]);
var generator = new OpenApiDocumentGenerator(
routingState.Object,
Options.Create(new OpenApiAggregationOptions()));
var document = JsonNode.Parse(generator.GenerateDocument())!.AsObject();
var operation = document["paths"]!["/api/v1/timeline"]!["get"]!.AsObject();
operation["summary"]!.GetValue<string>().Should().Be("Get timeline");
operation["description"]!.GetValue<string>().Should().Be("Return the timeline entries in reverse chronological order.");
}
private static ConnectionState CreateConnection(