Stabilzie modules
This commit is contained in:
@@ -67,11 +67,18 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 0: Preserve client-sent tenant header before stripping.
|
||||
// When the Gateway runs in AllowAnonymous mode (no JWT validation),
|
||||
// the principal has no claims and we cannot determine tenant from the token.
|
||||
// In that case, we pass through the client-provided value and let the
|
||||
// upstream service validate it against the JWT's tenant claim.
|
||||
var clientTenant = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
|
||||
// Step 1: Strip all reserved identity headers from incoming request
|
||||
StripReservedHeaders(context);
|
||||
|
||||
// Step 2: Extract identity from validated principal
|
||||
var identity = ExtractIdentity(context);
|
||||
var identity = ExtractIdentity(context, clientTenant);
|
||||
|
||||
// Step 3: Store normalized identity in HttpContext.Items
|
||||
StoreIdentityContext(context, identity);
|
||||
@@ -97,17 +104,23 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
}
|
||||
}
|
||||
|
||||
private IdentityContext ExtractIdentity(HttpContext context)
|
||||
private IdentityContext ExtractIdentity(HttpContext context, string? clientTenant = null)
|
||||
{
|
||||
var principal = context.User;
|
||||
var isAuthenticated = principal.Identity?.IsAuthenticated == true;
|
||||
|
||||
if (!isAuthenticated)
|
||||
{
|
||||
// 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;
|
||||
|
||||
return new IdentityContext
|
||||
{
|
||||
IsAnonymous = true,
|
||||
Actor = "anonymous",
|
||||
Tenant = passThruTenant,
|
||||
Scopes = _options.AnonymousScopes ?? []
|
||||
};
|
||||
}
|
||||
@@ -115,9 +128,12 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
// Extract subject (actor)
|
||||
var actor = principal.FindFirstValue(StellaOpsClaimTypes.Subject);
|
||||
|
||||
// Extract tenant - try canonical claim first, then legacy 'tid'
|
||||
// Extract tenant - try canonical claim first, then legacy 'tid',
|
||||
// then client-provided header, then fall back to "default"
|
||||
var tenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
|
||||
?? principal.FindFirstValue("tid");
|
||||
?? principal.FindFirstValue("tid")
|
||||
?? (!string.IsNullOrWhiteSpace(clientTenant) ? clientTenant.Trim() : null)
|
||||
?? "default";
|
||||
|
||||
// Extract project (optional)
|
||||
var project = principal.FindFirstValue(StellaOpsClaimTypes.Project);
|
||||
|
||||
@@ -20,6 +20,10 @@ public sealed class RouteDispatchMiddleware
|
||||
"TE", "Trailers", "Transfer-Encoding", "Upgrade"
|
||||
};
|
||||
|
||||
// ReverseProxy paths that are legitimate browser navigation targets (e.g. OIDC flows)
|
||||
// and must NOT be redirected to the SPA fallback.
|
||||
private static readonly string[] BrowserProxyPaths = ["/connect", "/.well-known"];
|
||||
|
||||
public RouteDispatchMiddleware(
|
||||
RequestDelegate next,
|
||||
StellaOpsRouteResolver resolver,
|
||||
@@ -48,6 +52,22 @@ public sealed class RouteDispatchMiddleware
|
||||
return;
|
||||
}
|
||||
|
||||
// SPA fallback: when a ReverseProxy route is matched but the request is a
|
||||
// browser navigation (Accept: text/html, no file extension), serve the SPA
|
||||
// index.html instead of proxying to the backend. This prevents collisions
|
||||
// between Angular SPA routes and backend service proxy prefixes.
|
||||
// Excludes known backend browser-navigation paths (e.g. OIDC /connect).
|
||||
if (route.Type == StellaOpsRouteType.ReverseProxy && IsBrowserNavigation(context.Request))
|
||||
{
|
||||
var spaRoute = _resolver.FindSpaFallbackRoute();
|
||||
if (spaRoute is not null)
|
||||
{
|
||||
_logger.LogDebug("SPA fallback: serving index.html for browser navigation to {Path}", context.Request.Path);
|
||||
await HandleStaticFiles(context, spaRoute);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (route.Type)
|
||||
{
|
||||
case StellaOpsRouteType.StaticFiles:
|
||||
@@ -221,7 +241,8 @@ public sealed class RouteDispatchMiddleware
|
||||
{
|
||||
context.Response.StatusCode = (int)upstreamResponse.StatusCode;
|
||||
|
||||
// Copy response headers
|
||||
// Copy response headers (excluding hop-by-hop and content-length which
|
||||
// we'll set ourselves after reading the body to ensure accuracy)
|
||||
foreach (var header in upstreamResponse.Headers)
|
||||
{
|
||||
if (!HopByHopHeaders.Contains(header.Key))
|
||||
@@ -232,12 +253,22 @@ public sealed class RouteDispatchMiddleware
|
||||
|
||||
foreach (var header in upstreamResponse.Content.Headers)
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
if (!string.Equals(header.Key, "Content-Length", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
// Stream response body
|
||||
await using var responseStream = await upstreamResponse.Content.ReadAsStreamAsync(context.RequestAborted);
|
||||
await responseStream.CopyToAsync(context.Response.Body, context.RequestAborted);
|
||||
// Read the full response body so we can set an accurate Content-Length.
|
||||
// This is necessary because the upstream may use chunked transfer encoding
|
||||
// (which we strip as a hop-by-hop header), and without Content-Length or
|
||||
// Transfer-Encoding the downstream client cannot determine body length.
|
||||
var body = await upstreamResponse.Content.ReadAsByteArrayAsync(context.RequestAborted);
|
||||
if (body.Length > 0)
|
||||
{
|
||||
context.Response.ContentLength = body.Length;
|
||||
await context.Response.Body.WriteAsync(body, context.RequestAborted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,4 +374,28 @@ public sealed class RouteDispatchMiddleware
|
||||
await using var stream = fileInfo.CreateReadStream();
|
||||
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the request is a browser page navigation (as opposed to an XHR/fetch API call).
|
||||
/// Browser navigations send Accept: text/html and target paths without file extensions.
|
||||
/// Known backend browser-navigation paths (OIDC endpoints) are excluded.
|
||||
/// </summary>
|
||||
private static bool IsBrowserNavigation(HttpRequest request)
|
||||
{
|
||||
var path = request.Path.Value ?? string.Empty;
|
||||
|
||||
// Paths with file extensions are static asset requests, not SPA navigation
|
||||
if (System.IO.Path.HasExtension(path))
|
||||
return false;
|
||||
|
||||
// Exclude known backend paths that legitimately receive browser navigations
|
||||
foreach (var excluded in BrowserProxyPaths)
|
||||
{
|
||||
if (path.StartsWith(excluded, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
|
||||
var accept = request.Headers.Accept.ToString();
|
||||
return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ builder.Services.AddSingleton<IEnumerable<StellaOpsRoute>>(
|
||||
builder.Services.AddHttpClient("RouteDispatch")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
ServerCertificateCustomValidationCallback =
|
||||
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||
});
|
||||
|
||||
@@ -54,4 +54,24 @@ public sealed class StellaOpsRouteResolver
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the StaticFiles route configured with x-spa-fallback: true.
|
||||
/// Used to serve index.html for browser navigation requests that would
|
||||
/// otherwise be intercepted by ReverseProxy routes.
|
||||
/// </summary>
|
||||
public StellaOpsRoute? FindSpaFallbackRoute()
|
||||
{
|
||||
foreach (var (route, _) in _routes)
|
||||
{
|
||||
if (route.Type == StellaOpsRouteType.StaticFiles &&
|
||||
route.Headers.TryGetValue("x-spa-fallback", out var value) &&
|
||||
string.Equals(value, "true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,73 @@
|
||||
"CheckInterval": "5s"
|
||||
},
|
||||
"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/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" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/concelier", "TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/cvss", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/cvss" },
|
||||
{ "Type": "ReverseProxy", "Path": "/v1/evidence-packs", "TranslatesTo": "http://evidencelocker.stella-ops.local/v1/evidence-packs" },
|
||||
{ "Type": "ReverseProxy", "Path": "/v1/runs", "TranslatesTo": "http://orchestrator.stella-ops.local/v1/runs" },
|
||||
{ "Type": "ReverseProxy", "Path": "/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai" },
|
||||
{ "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/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" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://orchestrator.stella-ops.local/api/approvals" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/platform", "TranslatesTo": "http://platform.stella-ops.local/api/v1/platform" },
|
||||
{ "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/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/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/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" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory-ai" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/advisory", "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/concelier", "TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vulnerabilities", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/watchlist", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/watchlist" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/resolve", "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/ops/binaryindex", "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/verdicts", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/verdicts" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/lineage", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/export", "TranslatesTo": "http://exportcenter.stella-ops.local/api/v1/export" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/triage", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/governance", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/determinization", "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/opsmemory", "TranslatesTo": "http://opsmemory.stella-ops.local/api/v1/opsmemory" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/secrets", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/secrets" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/sources", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sources" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/workflows", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/witnesses", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/witnesses" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/gate", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/gate" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/risk-budget", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk-budget" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/fix-verification", "TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/compare", "TranslatesTo": "http://sbomservice.stella-ops.local/api/compare" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/change-traces", "TranslatesTo": "http://sbomservice.stella-ops.local/api/change-traces" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/exceptions", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/exceptions" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/verdicts", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/verdicts" },
|
||||
{ "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/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/connect" },
|
||||
{ "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" },
|
||||
@@ -88,7 +152,6 @@
|
||||
{ "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" },
|
||||
{ "Type": "ReverseProxy", "Path": "/graph", "TranslatesTo": "http://graph.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/cartographer", "TranslatesTo": "http://cartographer.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/reachgraph", "TranslatesTo": "http://reachgraph.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/doctor", "TranslatesTo": "http://doctor.stella-ops.local" },
|
||||
@@ -103,7 +166,6 @@
|
||||
{ "Type": "ReverseProxy", "Path": "/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/advisoryai", "TranslatesTo": "http://advisoryai.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/unknowns", "TranslatesTo": "http://unknowns.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/timeline", "TranslatesTo": "http://timeline.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/timelineindexer", "TranslatesTo": "http://timelineindexer.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/opsmemory", "TranslatesTo": "http://opsmemory.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/issuerdirectory", "TranslatesTo": "http://issuerdirectory.stella-ops.local" },
|
||||
|
||||
Reference in New Issue
Block a user