663 lines
23 KiB
C#
663 lines
23 KiB
C#
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Router.Common.Identity;
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.Gateway.WebService.Middleware;
|
|
|
|
/// <summary>
|
|
/// Middleware that enforces the Gateway identity header policy:
|
|
/// 1. Strips all reserved identity headers from incoming requests (prevents spoofing)
|
|
/// 2. Computes effective identity from validated principal claims
|
|
/// 3. Writes downstream identity headers for microservice consumption
|
|
/// 4. Stores normalized identity context in HttpContext.Items
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This middleware replaces the legacy ClaimsPropagationMiddleware and TenantMiddleware
|
|
/// which used "set-if-missing" semantics that allowed client header spoofing.
|
|
/// </remarks>
|
|
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.
|
|
/// These are stripped from incoming requests and overwritten from validated claims.
|
|
/// </summary>
|
|
private static readonly string[] ReservedHeaders =
|
|
[
|
|
// StellaOps canonical headers
|
|
"X-StellaOps-Tenant",
|
|
"X-StellaOps-Project",
|
|
"X-StellaOps-Actor",
|
|
"X-StellaOps-Scopes",
|
|
"X-StellaOps-Client",
|
|
// Legacy Stella headers (compatibility)
|
|
"X-Stella-Tenant",
|
|
"X-Stella-Project",
|
|
"X-Stella-Actor",
|
|
"X-Stella-Scopes",
|
|
// Headers used by downstream services in header-based auth mode
|
|
"X-Scopes",
|
|
"X-Tenant-Id",
|
|
// Gateway-issued signed identity envelope headers
|
|
"X-StellaOps-Identity-Envelope",
|
|
"X-StellaOps-Identity-Envelope-Signature",
|
|
"X-StellaOps-Identity-Envelope-Algorithm",
|
|
// Raw claim headers (internal/legacy pass-through)
|
|
"sub",
|
|
"tid",
|
|
"scope",
|
|
"scp",
|
|
"cnf",
|
|
"cnf.jkt",
|
|
// Auth headers consumed by the gateway — strip before proxying
|
|
// so backends trust identity headers instead of re-validating JWT.
|
|
"Authorization",
|
|
"DPoP"
|
|
];
|
|
|
|
public IdentityHeaderPolicyMiddleware(
|
|
RequestDelegate next,
|
|
ILogger<IdentityHeaderPolicyMiddleware> logger,
|
|
IdentityHeaderPolicyOptions options)
|
|
{
|
|
_next = next;
|
|
_logger = logger;
|
|
_options = options;
|
|
}
|
|
|
|
public async Task InvokeAsync(HttpContext context)
|
|
{
|
|
// Skip processing for system paths (health, metrics, openapi, etc.)
|
|
if (GatewayRoutes.IsSystemPath(context.Request.Path))
|
|
{
|
|
await _next(context);
|
|
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, 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);
|
|
|
|
// Step 4: Write downstream identity headers
|
|
WriteDownstreamHeaders(context, identity);
|
|
|
|
await _next(context);
|
|
}
|
|
|
|
private void StripReservedHeaders(HttpContext context, bool preserveAuthHeaders)
|
|
{
|
|
foreach (var header in ReservedHeaders)
|
|
{
|
|
// Preserve Authorization/DPoP for routes that need JWT pass-through
|
|
if (preserveAuthHeaders && (header == "Authorization" || header == "DPoP"))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (context.Request.Headers.ContainsKey(header))
|
|
{
|
|
_logger.LogDebug(
|
|
"Stripped reserved identity header {Header} from request {TraceId}",
|
|
header,
|
|
context.TraceIdentifier);
|
|
context.Request.Headers.Remove(header);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
var isAuthenticated = principal.Identity?.IsAuthenticated == true;
|
|
|
|
if (!isAuthenticated)
|
|
{
|
|
return new IdentityContext
|
|
{
|
|
IsAnonymous = true,
|
|
Actor = "anonymous",
|
|
Scopes = _options.AnonymousScopes ?? []
|
|
};
|
|
}
|
|
|
|
// Extract subject (actor)
|
|
var actor = principal.FindFirstValue(StellaOpsClaimTypes.Subject);
|
|
|
|
// 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);
|
|
|
|
// Extract scopes - try 'scp' claims first (individual items), then 'scope' (space-separated)
|
|
var scopes = ExtractScopes(principal);
|
|
var roles = principal.FindAll(ClaimTypes.Role)
|
|
.Select(claim => claim.Value)
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(value => value, StringComparer.Ordinal)
|
|
.ToArray();
|
|
|
|
// Extract cnf (confirmation claim) for DPoP/sender constraint
|
|
var cnfJson = principal.FindFirstValue("cnf");
|
|
string? dpopThumbprint = null;
|
|
if (!string.IsNullOrWhiteSpace(cnfJson))
|
|
{
|
|
TryParseCnfThumbprint(cnfJson, out dpopThumbprint);
|
|
}
|
|
|
|
return new IdentityContext
|
|
{
|
|
IsAnonymous = false,
|
|
Actor = actor,
|
|
Tenant = tenant,
|
|
Project = project,
|
|
Scopes = scopes,
|
|
Roles = roles,
|
|
CnfJson = cnfJson,
|
|
DpopThumbprint = dpopThumbprint
|
|
};
|
|
}
|
|
|
|
private static HashSet<string> ExtractScopes(ClaimsPrincipal principal)
|
|
{
|
|
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// First try individual scope claims (scp)
|
|
var scpClaims = principal.FindAll(StellaOpsClaimTypes.ScopeItem);
|
|
foreach (var claim in scpClaims)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(claim.Value))
|
|
{
|
|
scopes.Add(claim.Value.Trim());
|
|
}
|
|
}
|
|
|
|
// If no scp claims, try space-separated scope claim
|
|
if (scopes.Count == 0)
|
|
{
|
|
var scopeClaims = principal.FindAll(StellaOpsClaimTypes.Scope);
|
|
foreach (var claim in scopeClaims)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(claim.Value))
|
|
{
|
|
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
foreach (var part in parts)
|
|
{
|
|
scopes.Add(part);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Expand coarse OIDC scopes to fine-grained service scopes.
|
|
// This bridges the gap between Authority-registered scopes (e.g. "scheduler:read")
|
|
// and the fine-grained scopes that downstream services expect (e.g. "scheduler.runs.read").
|
|
ExpandCoarseScopes(scopes);
|
|
|
|
return scopes;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Expands coarse OIDC scopes into fine-grained service scopes.
|
|
/// Pattern: "{service}:{action}" expands to "{service}.{resource}.{action}" for known resources.
|
|
/// </summary>
|
|
private static void ExpandCoarseScopes(HashSet<string> scopes)
|
|
{
|
|
// scheduler:read -> scheduler.schedules.read, scheduler.runs.read
|
|
// scheduler:operate -> scheduler.schedules.write, scheduler.runs.write, scheduler.runs.preview, scheduler.runs.manage
|
|
if (scopes.Contains("scheduler:read"))
|
|
{
|
|
scopes.Add("scheduler.schedules.read");
|
|
scopes.Add("scheduler.runs.read");
|
|
}
|
|
|
|
if (scopes.Contains("scheduler:operate"))
|
|
{
|
|
scopes.Add("scheduler.schedules.write");
|
|
scopes.Add("scheduler.runs.write");
|
|
scopes.Add("scheduler.runs.preview");
|
|
scopes.Add("scheduler.runs.manage");
|
|
}
|
|
}
|
|
|
|
private void StoreIdentityContext(HttpContext context, IdentityContext identity)
|
|
{
|
|
context.Items[GatewayContextKeys.IsAnonymous] = identity.IsAnonymous;
|
|
|
|
if (!string.IsNullOrEmpty(identity.Actor))
|
|
{
|
|
context.Items[GatewayContextKeys.Actor] = identity.Actor;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(identity.Tenant))
|
|
{
|
|
context.Items[GatewayContextKeys.TenantId] = identity.Tenant;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(identity.Project))
|
|
{
|
|
context.Items[GatewayContextKeys.ProjectId] = identity.Project;
|
|
}
|
|
|
|
if (identity.Scopes.Count > 0)
|
|
{
|
|
context.Items[GatewayContextKeys.Scopes] = identity.Scopes;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(identity.CnfJson))
|
|
{
|
|
context.Items[GatewayContextKeys.CnfJson] = identity.CnfJson;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(identity.DpopThumbprint))
|
|
{
|
|
context.Items[GatewayContextKeys.DpopThumbprint] = identity.DpopThumbprint;
|
|
}
|
|
}
|
|
|
|
private void WriteDownstreamHeaders(HttpContext context, IdentityContext identity)
|
|
{
|
|
var headers = context.Request.Headers;
|
|
|
|
// Actor header
|
|
if (!string.IsNullOrEmpty(identity.Actor))
|
|
{
|
|
headers["X-StellaOps-Actor"] = identity.Actor;
|
|
if (_options.EnableLegacyHeaders)
|
|
{
|
|
headers["X-Stella-Actor"] = identity.Actor;
|
|
}
|
|
}
|
|
|
|
// Tenant header
|
|
if (!string.IsNullOrEmpty(identity.Tenant))
|
|
{
|
|
headers["X-StellaOps-Tenant"] = identity.Tenant;
|
|
headers["X-Tenant-Id"] = identity.Tenant;
|
|
if (_options.EnableLegacyHeaders)
|
|
{
|
|
headers["X-Stella-Tenant"] = identity.Tenant;
|
|
}
|
|
}
|
|
|
|
// Project header (optional)
|
|
if (!string.IsNullOrEmpty(identity.Project))
|
|
{
|
|
headers["X-StellaOps-Project"] = identity.Project;
|
|
if (_options.EnableLegacyHeaders)
|
|
{
|
|
headers["X-Stella-Project"] = identity.Project;
|
|
}
|
|
}
|
|
|
|
// Scopes header (space-delimited, sorted for determinism)
|
|
if (identity.Scopes.Count > 0)
|
|
{
|
|
var sortedScopes = identity.Scopes.OrderBy(s => s, StringComparer.Ordinal);
|
|
var scopesValue = string.Join(" ", sortedScopes);
|
|
headers["X-StellaOps-Scopes"] = scopesValue;
|
|
headers["X-Scopes"] = scopesValue;
|
|
if (_options.EnableLegacyHeaders)
|
|
{
|
|
headers["X-Stella-Scopes"] = scopesValue;
|
|
}
|
|
}
|
|
else if (identity.IsAnonymous)
|
|
{
|
|
// Explicit empty scopes for anonymous to prevent ambiguity
|
|
headers["X-StellaOps-Scopes"] = string.Empty;
|
|
headers["X-Scopes"] = string.Empty;
|
|
if (_options.EnableLegacyHeaders)
|
|
{
|
|
headers["X-Stella-Scopes"] = string.Empty;
|
|
}
|
|
}
|
|
|
|
// DPoP thumbprint (if present)
|
|
if (!string.IsNullOrEmpty(identity.DpopThumbprint))
|
|
{
|
|
headers["cnf.jkt"] = identity.DpopThumbprint;
|
|
}
|
|
|
|
if (_options.EmitIdentityEnvelope &&
|
|
!string.IsNullOrWhiteSpace(_options.IdentityEnvelopeSigningKey))
|
|
{
|
|
var envelope = new GatewayIdentityEnvelope
|
|
{
|
|
Issuer = _options.IdentityEnvelopeIssuer,
|
|
Subject = identity.Actor ?? "anonymous",
|
|
Tenant = identity.Tenant,
|
|
Project = identity.Project,
|
|
Scopes = identity.Scopes.OrderBy(scope => scope, StringComparer.Ordinal).ToArray(),
|
|
Roles = identity.Roles,
|
|
SenderConfirmation = identity.DpopThumbprint,
|
|
CorrelationId = context.TraceIdentifier,
|
|
IssuedAtUtc = DateTimeOffset.UtcNow,
|
|
ExpiresAtUtc = DateTimeOffset.UtcNow.Add(_options.IdentityEnvelopeTtl)
|
|
};
|
|
|
|
var signature = GatewayIdentityEnvelopeCodec.Sign(envelope, _options.IdentityEnvelopeSigningKey!);
|
|
headers["X-StellaOps-Identity-Envelope"] = signature.Payload;
|
|
headers["X-StellaOps-Identity-Envelope-Signature"] = signature.Signature;
|
|
headers["X-StellaOps-Identity-Envelope-Algorithm"] = signature.Algorithm;
|
|
}
|
|
}
|
|
|
|
private static bool TryParseCnfThumbprint(string json, out string? jkt)
|
|
{
|
|
jkt = null;
|
|
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(json);
|
|
if (document.RootElement.TryGetProperty("jkt", out var jktElement) &&
|
|
jktElement.ValueKind == JsonValueKind.String)
|
|
{
|
|
jkt = jktElement.GetString();
|
|
}
|
|
|
|
return !string.IsNullOrWhiteSpace(jkt);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private sealed class IdentityContext
|
|
{
|
|
public bool IsAnonymous { get; init; }
|
|
public string? Actor { get; init; }
|
|
public string? Tenant { get; set; }
|
|
public string? Project { get; init; }
|
|
public HashSet<string> Scopes { get; init; } = [];
|
|
public IReadOnlyList<string> Roles { get; init; } = [];
|
|
public string? CnfJson { get; init; }
|
|
public string? DpopThumbprint { get; init; }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configuration options for the identity header policy middleware.
|
|
/// </summary>
|
|
public sealed class IdentityHeaderPolicyOptions
|
|
{
|
|
/// <summary>
|
|
/// Enable legacy X-Stella-* headers in addition to X-StellaOps-* headers.
|
|
/// Default: true (for migration compatibility).
|
|
/// </summary>
|
|
public bool EnableLegacyHeaders { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Scopes to assign to anonymous requests.
|
|
/// Default: empty (no scopes).
|
|
/// </summary>
|
|
public HashSet<string>? AnonymousScopes { get; set; }
|
|
|
|
/// <summary>
|
|
/// Allow client-provided scope headers in offline/pre-prod mode.
|
|
/// Default: false (forbidden for security).
|
|
/// </summary>
|
|
public bool AllowScopeHeaderOverride { get; set; } = false;
|
|
|
|
/// <summary>
|
|
/// When true, emit a signed identity envelope headers for downstream trust.
|
|
/// </summary>
|
|
public bool EmitIdentityEnvelope { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Shared signing key used to sign identity envelopes.
|
|
/// </summary>
|
|
public string? IdentityEnvelopeSigningKey { get; set; }
|
|
|
|
/// <summary>
|
|
/// Identity envelope issuer identifier.
|
|
/// </summary>
|
|
public string IdentityEnvelopeIssuer { get; set; } = "stellaops-gateway-router";
|
|
|
|
/// <summary>
|
|
/// Identity envelope validity window.
|
|
/// </summary>
|
|
public TimeSpan IdentityEnvelopeTtl { get; set; } = TimeSpan.FromMinutes(2);
|
|
|
|
/// <summary>
|
|
/// Route prefixes where Authorization and DPoP headers should be preserved
|
|
/// (passed through to the upstream service) instead of stripped.
|
|
/// Use this for upstream services that require JWT validation themselves
|
|
/// (e.g., Authority admin API at /console).
|
|
/// 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;
|
|
}
|