save changes
This commit is contained in:
@@ -39,13 +39,20 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
"X-Stella-Project",
|
||||
"X-Stella-Actor",
|
||||
"X-Stella-Scopes",
|
||||
// Headers used by downstream services in header-based auth mode
|
||||
"X-Scopes",
|
||||
"X-Tenant-Id",
|
||||
// Raw claim headers (internal/legacy pass-through)
|
||||
"sub",
|
||||
"tid",
|
||||
"scope",
|
||||
"scp",
|
||||
"cnf",
|
||||
"cnf.jkt"
|
||||
"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(
|
||||
@@ -91,8 +98,18 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
|
||||
private void StripReservedHeaders(HttpContext context)
|
||||
{
|
||||
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
|
||||
if (preserveAuthHeaders && (header == "Authorization" || header == "DPoP"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.ContainsKey(header))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
@@ -114,7 +131,7 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
// In AllowAnonymous mode the Gateway cannot validate identity claims.
|
||||
// Pass through the client-provided tenant so the upstream service
|
||||
// can validate it against the JWT's own tenant claim.
|
||||
var passThruTenant = !string.IsNullOrWhiteSpace(clientTenant) ? clientTenant.Trim() : null;
|
||||
var passThruTenant = !string.IsNullOrWhiteSpace(clientTenant) ? clientTenant.Trim() : "default";
|
||||
|
||||
return new IdentityContext
|
||||
{
|
||||
@@ -192,9 +209,37 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -248,6 +293,7 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
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;
|
||||
@@ -270,6 +316,7 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
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;
|
||||
@@ -279,6 +326,7 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
{
|
||||
// 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;
|
||||
@@ -347,4 +395,13 @@ public sealed class IdentityHeaderPolicyOptions
|
||||
/// Default: false (forbidden for security).
|
||||
/// </summary>
|
||||
public bool AllowScopeHeaderOverride { get; set; } = false;
|
||||
|
||||
/// <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; } = [];
|
||||
}
|
||||
|
||||
@@ -124,7 +124,11 @@ builder.Services.AddSingleton<IDpopProofValidator, DpopProofValidator>();
|
||||
builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
|
||||
{
|
||||
EnableLegacyHeaders = bootstrapOptions.Auth.EnableLegacyHeaders,
|
||||
AllowScopeHeaderOverride = bootstrapOptions.Auth.AllowScopeHeader
|
||||
AllowScopeHeaderOverride = bootstrapOptions.Auth.AllowScopeHeader,
|
||||
JwtPassthroughPrefixes = bootstrapOptions.Routes
|
||||
.Where(r => r.PreserveAuthHeaders)
|
||||
.Select(r => r.Path)
|
||||
.ToList()
|
||||
});
|
||||
|
||||
// Route table: resolver + error routes + HTTP client for reverse proxy
|
||||
@@ -222,6 +226,20 @@ static void ConfigureAuthentication(WebApplicationBuilder builder, GatewayOption
|
||||
}
|
||||
});
|
||||
|
||||
// Configure the OIDC metadata HTTP client to accept self-signed certificates
|
||||
// (Authority uses a dev cert in Docker)
|
||||
if (!authOptions.Authority.RequireHttpsMetadata)
|
||||
{
|
||||
builder.Services.ConfigureHttpClientDefaults(clientBuilder =>
|
||||
{
|
||||
clientBuilder.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback =
|
||||
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (authOptions.Authority.RequiredScopes.Count > 0)
|
||||
{
|
||||
builder.Services.AddAuthorization(config =>
|
||||
|
||||
@@ -43,9 +43,9 @@
|
||||
"MtlsEnabled": false,
|
||||
"AllowAnonymous": true,
|
||||
"Authority": {
|
||||
"Issuer": "",
|
||||
"RequireHttpsMetadata": true,
|
||||
"MetadataAddress": "",
|
||||
"Issuer": "https://authority.stella-ops.local",
|
||||
"RequireHttpsMetadata": false,
|
||||
"MetadataAddress": "https://authority.stella-ops.local/.well-known/openid-configuration",
|
||||
"Audiences": [],
|
||||
"RequiredScopes": []
|
||||
}
|
||||
@@ -66,7 +66,7 @@
|
||||
},
|
||||
"Routes": [
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/release-orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "https://vexhub.stella-ops.local/api/v1/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local/api/v1/vexlens" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/notify", "TranslatesTo": "http://notify.stella-ops.local/api/v1/notify" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/notifier", "TranslatesTo": "http://notifier.stella-ops.local/api/v1/notifier" },
|
||||
@@ -78,7 +78,7 @@
|
||||
{ "Type": "ReverseProxy", "Path": "/v1/audit-bundles", "TranslatesTo": "http://evidencelocker.stella-ops.local/v1/audit-bundles" },
|
||||
{ "Type": "ReverseProxy", "Path": "/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/risk", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/risk", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/analytics", "TranslatesTo": "http://platform.stella-ops.local/api/analytics" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/release-orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://orchestrator.stella-ops.local/api/releases" },
|
||||
@@ -87,15 +87,16 @@
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/scanner", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scanner" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/findings", "TranslatesTo": "http://findings.stella-ops.local/api/v1/findings" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/reachability", "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/attestor", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestor" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/attestations", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/sbom", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/signals", "TranslatesTo": "http://signals.stella-ops.local/api/v1/signals" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "https://vexhub.stella-ops.local/api/v1/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/authority", "TranslatesTo": "https://authority.stella-ops.local/api/v1/authority" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/trust", "TranslatesTo": "https://authority.stella-ops.local/api/v1/trust" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/authority", "TranslatesTo": "https://authority.stella-ops.local/api/v1/authority", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/trust", "TranslatesTo": "https://authority.stella-ops.local/api/v1/trust", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/evidence", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/evidence" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/proofs", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/proofs" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/timeline", "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline" },
|
||||
@@ -127,15 +128,15 @@
|
||||
{ "Type": "ReverseProxy", "Path": "/api/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local/api/sbomservice" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/vuln-explorer", "TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/vex", "TranslatesTo": "https://vexhub.stella-ops.local/api/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/admin", "TranslatesTo": "http://platform.stella-ops.local/api/admin" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" },
|
||||
{ "Type": "ReverseProxy", "Path": "/platform", "TranslatesTo": "http://platform.stella-ops.local/platform" },
|
||||
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "https://authority.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "https://authority.stella-ops.local/.well-known" },
|
||||
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "https://authority.stella-ops.local/jwks" },
|
||||
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "https://authority.stella-ops.local/authority" },
|
||||
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "https://authority.stella-ops.local/console" },
|
||||
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "https://authority.stella-ops.local", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "https://authority.stella-ops.local/.well-known", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "https://authority.stella-ops.local/jwks", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "https://authority.stella-ops.local/authority", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "https://authority.stella-ops.local/console", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/gateway", "TranslatesTo": "http://gateway.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/scanner", "TranslatesTo": "http://scanner.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/policyGateway", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
|
||||
@@ -148,7 +149,7 @@
|
||||
{ "Type": "ReverseProxy", "Path": "/signals", "TranslatesTo": "http://signals.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/excititor", "TranslatesTo": "http://excititor.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/findingsLedger", "TranslatesTo": "http://findings.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/vexhub", "TranslatesTo": "http://vexhub.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/vexhub", "TranslatesTo": "https://vexhub.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/taskrunner", "TranslatesTo": "http://taskrunner.stella-ops.local" },
|
||||
@@ -175,7 +176,6 @@
|
||||
{ "Type": "ReverseProxy", "Path": "/airgapController", "TranslatesTo": "http://airgap-controller.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/airgapTime", "TranslatesTo": "http://airgap-time.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/smremote", "TranslatesTo": "http://smremote.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/envsettings.json", "TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json" },
|
||||
{ "Type": "StaticFiles", "Path": "/", "TranslatesTo": "/app/wwwroot", "Headers": { "x-spa-fallback": "true" } },
|
||||
{ "Type": "NotFoundPage", "Path": "/_error/404", "TranslatesTo": "/app/wwwroot/index.html" },
|
||||
{ "Type": "ServerErrorPage", "Path": "/_error/500", "TranslatesTo": "/app/wwwroot/index.html" }
|
||||
|
||||
@@ -22,4 +22,11 @@ public sealed class StellaOpsRoute
|
||||
public string? TranslatesTo { get; set; }
|
||||
|
||||
public Dictionary<string, string> Headers { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// When true, the gateway preserves Authorization and DPoP headers instead
|
||||
/// of stripping them. Use for upstream services that perform their own JWT
|
||||
/// validation (e.g., Authority admin API).
|
||||
/// </summary>
|
||||
public bool PreserveAuthHeaders { get; set; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user