stela ops usage fixes roles propagation and timoeut, one account to support multi tenants, migrations consolidation, search to support documentation, doctor and open api vector db search

This commit is contained in:
master
2026-02-22 19:27:54 +02:00
parent a29f438f53
commit bd8fee6ed8
373 changed files with 832097 additions and 3369 deletions

View File

@@ -1,5 +1,6 @@
using StellaOps.Auth.Abstractions;
using StellaOps.Router.Common.Identity;
using System.Security.Claims;
using System.Text.Json;
@@ -42,6 +43,10 @@ public sealed class IdentityHeaderPolicyMiddleware
// 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",
@@ -74,18 +79,11 @@ 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, clientTenant);
var identity = ExtractIdentity(context);
// Step 3: Store normalized identity in HttpContext.Items
StoreIdentityContext(context, identity);
@@ -121,23 +119,17 @@ public sealed class IdentityHeaderPolicyMiddleware
}
}
private IdentityContext ExtractIdentity(HttpContext context, string? clientTenant = null)
private IdentityContext ExtractIdentity(HttpContext context)
{
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() : "default";
return new IdentityContext
{
IsAnonymous = true,
Actor = "anonymous",
Tenant = passThruTenant,
Scopes = _options.AnonymousScopes ?? []
};
}
@@ -146,10 +138,9 @@ public sealed class IdentityHeaderPolicyMiddleware
var actor = principal.FindFirstValue(StellaOpsClaimTypes.Subject);
// Extract tenant - try canonical claim first, then legacy 'tid',
// then client-provided header, then fall back to "default"
// then fall back to "default".
var tenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
?? principal.FindFirstValue("tid")
?? (!string.IsNullOrWhiteSpace(clientTenant) ? clientTenant.Trim() : null)
?? "default";
// Extract project (optional)
@@ -157,6 +148,12 @@ public sealed class IdentityHeaderPolicyMiddleware
// 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");
@@ -173,6 +170,7 @@ public sealed class IdentityHeaderPolicyMiddleware
Tenant = tenant,
Project = project,
Scopes = scopes,
Roles = roles,
CnfJson = cnfJson,
DpopThumbprint = dpopThumbprint
};
@@ -338,6 +336,29 @@ public sealed class IdentityHeaderPolicyMiddleware
{
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)
@@ -368,6 +389,7 @@ public sealed class IdentityHeaderPolicyMiddleware
public string? Tenant { get; init; }
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; }
}
@@ -396,6 +418,26 @@ public sealed class IdentityHeaderPolicyOptions
/// </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.

View File

@@ -10,9 +10,10 @@ public sealed class RequestRoutingMiddleware
public RequestRoutingMiddleware(
RequestDelegate next,
ILogger<RequestRoutingMiddleware> logger,
ILogger<TransportDispatchMiddleware> dispatchLogger)
ILogger<TransportDispatchMiddleware> dispatchLogger,
IHostEnvironment environment)
{
_dispatchMiddleware = new TransportDispatchMiddleware(next, dispatchLogger);
_dispatchMiddleware = new TransportDispatchMiddleware(next, dispatchLogger, environment);
}
public Task InvokeAsync(HttpContext context, ITransportClient transportClient, IGlobalRoutingState routingState)

View File

@@ -1,7 +1,9 @@
using System.Net.WebSockets;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders;
using StellaOps.Gateway.WebService.Configuration;
using StellaOps.Router.Gateway.Configuration;
using StellaOps.Router.Gateway;
using StellaOps.Gateway.WebService.Routing;
namespace StellaOps.Gateway.WebService.Middleware;
@@ -52,17 +54,21 @@ 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))
// SPA fallback: when a service route (ReverseProxy or Microservice) is matched
// but the request is a browser navigation, serve the SPA index.html instead of
// proxying/dispatching to backend service routes. This prevents collisions
// between UI deep links (for example "/policy") and backend route prefixes.
// Excludes known backend browser-navigation paths (for example OIDC /connect).
if ((route.Type == StellaOpsRouteType.ReverseProxy || route.Type == StellaOpsRouteType.Microservice)
&& 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);
_logger.LogDebug(
"SPA fallback: serving index.html for browser navigation to {Path} (matched route type: {RouteType})",
context.Request.Path,
route.Type);
await HandleStaticFiles(context, spaRoute);
return;
}
@@ -83,6 +89,7 @@ public sealed class RouteDispatchMiddleware
await HandleWebSocket(context, route);
break;
case StellaOpsRouteType.Microservice:
PrepareMicroserviceRoute(context, route);
await _next(context);
break;
default:
@@ -272,6 +279,185 @@ public sealed class RouteDispatchMiddleware
}
}
private static void PrepareMicroserviceRoute(HttpContext context, StellaOpsRoute route)
{
var translatedPath = ResolveTranslatedMicroservicePath(context.Request.Path.Value, route);
if (!string.Equals(translatedPath, context.Request.Path.Value, StringComparison.Ordinal))
{
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = translatedPath;
}
var targetMicroservice = ResolveRouteTargetMicroservice(route);
if (!string.IsNullOrWhiteSpace(targetMicroservice))
{
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = targetMicroservice;
}
if (!string.IsNullOrWhiteSpace(route.DefaultTimeout))
{
var routeTimeout = GatewayValueParser.ParseDuration(route.DefaultTimeout, TimeSpan.FromSeconds(30));
context.Items[RouterHttpContextKeys.RouteDefaultTimeout] = routeTimeout;
}
}
private static string ResolveTranslatedMicroservicePath(string? requestPathValue, StellaOpsRoute route)
{
var requestPath = string.IsNullOrWhiteSpace(requestPathValue) ? "/" : requestPathValue!;
if (string.IsNullOrWhiteSpace(route.TranslatesTo))
{
return requestPath;
}
var targetPrefix = ResolveTargetPathPrefix(route);
if (string.IsNullOrWhiteSpace(targetPrefix))
{
return requestPath;
}
var normalizedRoutePath = NormalizePath(route.Path);
var normalizedRequestPath = NormalizePath(requestPath);
var remainingPath = normalizedRequestPath;
if (!route.IsRegex &&
normalizedRequestPath.StartsWith(normalizedRoutePath, StringComparison.OrdinalIgnoreCase))
{
remainingPath = normalizedRequestPath[normalizedRoutePath.Length..];
if (!remainingPath.StartsWith('/'))
{
remainingPath = "/" + remainingPath;
}
}
return targetPrefix == "/"
? NormalizePath(remainingPath)
: NormalizePath($"{targetPrefix.TrimEnd('/')}{remainingPath}");
}
private static string ResolveTargetPathPrefix(StellaOpsRoute route)
{
var rawValue = route.TranslatesTo;
if (string.IsNullOrWhiteSpace(rawValue))
{
return string.Empty;
}
if (Uri.TryCreate(rawValue, UriKind.Absolute, out var absolute))
{
return NormalizePath(absolute.AbsolutePath);
}
if (Uri.TryCreate(rawValue, UriKind.Relative, out _))
{
return NormalizePath(rawValue);
}
return string.Empty;
}
private static string? ResolveRouteTargetMicroservice(StellaOpsRoute route)
{
var hostService = ExtractServiceKeyFromTranslatesTo(route.TranslatesTo);
var pathService = ExtractServiceKeyFromPath(route.Path);
if (IsGenericServiceAlias(hostService) && !IsGenericServiceAlias(pathService))
{
return pathService;
}
return hostService ?? pathService;
}
private static string? ExtractServiceKeyFromTranslatesTo(string? translatesTo)
{
if (string.IsNullOrWhiteSpace(translatesTo))
{
return null;
}
if (!Uri.TryCreate(translatesTo, UriKind.Absolute, out var absolute))
{
return null;
}
return NormalizeServiceKey(absolute.Host);
}
private static string? ExtractServiceKeyFromPath(string? path)
{
var normalizedPath = NormalizePath(path);
var segments = normalizedPath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length == 0)
{
return null;
}
if (segments.Length >= 3 &&
string.Equals(segments[0], "api", StringComparison.OrdinalIgnoreCase) &&
string.Equals(segments[1], "v1", StringComparison.OrdinalIgnoreCase))
{
return NormalizeServiceKey(segments[2]);
}
return NormalizeServiceKey(segments[0]);
}
private static string? NormalizeServiceKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var normalized = value.Trim().ToLowerInvariant();
var portSeparator = normalized.IndexOf(':');
if (portSeparator >= 0)
{
normalized = normalized[..portSeparator];
}
const string localDomain = ".stella-ops.local";
if (normalized.EndsWith(localDomain, StringComparison.Ordinal))
{
normalized = normalized[..^localDomain.Length];
}
return string.IsNullOrWhiteSpace(normalized)
? null
: normalized;
}
private static bool IsGenericServiceAlias(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
return value.Equals("api", StringComparison.OrdinalIgnoreCase) ||
value.Equals("web", StringComparison.OrdinalIgnoreCase) ||
value.Equals("service", StringComparison.OrdinalIgnoreCase);
}
private static string NormalizePath(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "/";
}
var normalized = value.Trim();
if (!normalized.StartsWith('/'))
{
normalized = "/" + normalized;
}
normalized = normalized.TrimEnd('/');
return string.IsNullOrEmpty(normalized) ? "/" : normalized;
}
private async Task HandleWebSocket(HttpContext context, StellaOpsRoute route)
{
if (!context.WebSockets.IsWebSocketRequest)
@@ -382,6 +568,9 @@ public sealed class RouteDispatchMiddleware
/// </summary>
private static bool IsBrowserNavigation(HttpRequest request)
{
if (!HttpMethods.IsGet(request.Method))
return false;
var path = request.Path.Value ?? string.Empty;
// Paths with file extensions are static asset requests, not SPA navigation
@@ -395,6 +584,16 @@ public sealed class RouteDispatchMiddleware
return false;
}
// API prefixes should continue to dispatch to backend handlers even when
// entered directly in a browser.
if (path.Equals("/api", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) ||
path.Equals("/v1", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/v1/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var accept = request.Headers.Accept.ToString();
return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase);
}