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

@@ -36,6 +36,25 @@ public sealed class AuthorizationMiddleware
return;
}
if (endpoint.AllowAnonymous)
{
await _next(context);
return;
}
var requiresAuthentication = EndpointAuthorizationSemantics.ResolveRequiresAuthentication(endpoint);
var isAuthenticated = context.User.Identity?.IsAuthenticated == true;
if (requiresAuthentication && !isAuthenticated)
{
_logger.LogWarning(
"Authorization failed for {Method} {Path}: unauthenticated principal",
endpoint.Method,
endpoint.Path);
await WriteUnauthorizedAsync(context, endpoint);
return;
}
var effectiveClaims = _claimsStore.GetEffectiveClaims(
endpoint.ServiceName,
endpoint.Method,
@@ -71,6 +90,22 @@ public sealed class AuthorizationMiddleware
await _next(context);
}
private static Task WriteUnauthorizedAsync(HttpContext context, EndpointDescriptor endpoint)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json; charset=utf-8";
var payload = new AuthorizationFailureResponse(
Error: "unauthorized",
Message: "Authentication required",
RequiredClaimType: string.Empty,
RequiredClaimValue: null,
Service: endpoint.ServiceName,
Version: endpoint.Version);
return JsonSerializer.SerializeAsync(context.Response.Body, payload, JsonOptions, context.RequestAborted);
}
private static Task WriteForbiddenAsync(
HttpContext context,
EndpointDescriptor endpoint,

View File

@@ -3,13 +3,6 @@ using StellaOps.Router.Gateway.Authorization;
namespace StellaOps.Gateway.WebService.Authorization;
public interface IEffectiveClaimsStore
public interface IEffectiveClaimsStore : StellaOps.Router.Gateway.Authorization.IEffectiveClaimsStore
{
IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path);
void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints);
void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides);
void RemoveService(string serviceName);
}

View File

@@ -64,6 +64,12 @@ public sealed class GatewayMessagingTransportOptions
/// </summary>
public string RequestQueueTemplate { get; set; } = "router:requests:{service}";
/// <summary>
/// Reserved queue segment used for gateway control traffic.
/// Must not overlap with a real microservice name.
/// </summary>
public string GatewayControlQueueServiceName { get; set; } = "gateway-control";
/// <summary>
/// Queue name for gateway responses.
/// </summary>
@@ -139,6 +145,11 @@ public sealed class GatewayRoutingOptions
{
public string DefaultTimeout { get; set; } = "30s";
/// <summary>
/// Global timeout cap applied after endpoint and route timeout resolution.
/// </summary>
public string GlobalTimeoutCap { get; set; } = "120s";
public string MaxRequestBodySize { get; set; } = "100MB";
public bool StreamingEnabled { get; set; } = true;
@@ -173,6 +184,26 @@ public sealed class GatewayAuthOptions
/// </summary>
public bool AllowScopeHeader { get; set; } = false;
/// <summary>
/// Emit signed identity envelope headers for router-dispatched requests.
/// </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 TTL in seconds.
/// </summary>
public int IdentityEnvelopeTtlSeconds { get; set; } = 120;
public GatewayAuthorityOptions Authority { get; set; } = new();
}
@@ -184,6 +215,11 @@ public sealed class GatewayAuthorityOptions
public string? MetadataAddress { get; set; }
/// <summary>
/// Optional explicit base URL for Authority claims override endpoint discovery.
/// </summary>
public string? ClaimsOverridesUrl { get; set; }
public List<string> Audiences { get; set; } = new();
public List<string> RequiredScopes { get; set; } = new();

View File

@@ -33,6 +33,7 @@ public static class GatewayOptionsValidator
}
_ = GatewayValueParser.ParseDuration(options.Routing.DefaultTimeout, TimeSpan.FromSeconds(30));
_ = GatewayValueParser.ParseDuration(options.Routing.GlobalTimeoutCap, TimeSpan.FromSeconds(120));
_ = GatewayValueParser.ParseSizeBytes(options.Routing.MaxRequestBodySize, 0);
_ = GatewayValueParser.ParseDuration(options.Health.StaleThreshold, TimeSpan.FromSeconds(30));
@@ -115,6 +116,10 @@ public static class GatewayOptionsValidator
break;
case StellaOpsRouteType.Microservice:
if (!string.IsNullOrWhiteSpace(route.DefaultTimeout))
{
_ = GatewayValueParser.ParseDuration(route.DefaultTimeout, TimeSpan.FromSeconds(30));
}
break;
}
}

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

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
@@ -12,8 +13,6 @@ using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Gateway.WebService.Routing;
using StellaOps.Gateway.WebService.Security;
using StellaOps.Gateway.WebService.Services;
using StellaOps.Messaging.DependencyInjection;
using StellaOps.Messaging.Transport.Valkey;
using StellaOps.Router.AspNet;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Models;
@@ -25,11 +24,6 @@ using StellaOps.Router.Gateway.Middleware;
using StellaOps.Router.Gateway.OpenApi;
using StellaOps.Router.Gateway.RateLimit;
using StellaOps.Router.Gateway.Routing;
using StellaOps.Router.Transport.Messaging;
using StellaOps.Router.Transport.Messaging.Options;
using StellaOps.Router.Transport.Tcp;
using StellaOps.Router.Transport.Tls;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
@@ -55,6 +49,25 @@ builder.Services.AddRouterGatewayCore();
builder.Services.AddRouterRateLimiting(builder.Configuration);
builder.Services.AddSingleton<IEffectiveClaimsStore, EffectiveClaimsStore>();
builder.Services.AddSingleton<StellaOps.Router.Gateway.Authorization.IEffectiveClaimsStore>(sp =>
sp.GetRequiredService<IEffectiveClaimsStore>());
var authorityClaimsUrl = ResolveAuthorityClaimsUrl(bootstrapOptions.Auth.Authority);
StellaOps.Router.Gateway.Authorization.AuthorizationServiceCollectionExtensions.AddAuthorityIntegration(
builder.Services,
options =>
{
options.Enabled = !string.IsNullOrWhiteSpace(authorityClaimsUrl);
options.AuthorityUrl = authorityClaimsUrl ?? string.Empty;
options.RefreshInterval = TimeSpan.FromSeconds(30);
options.WaitForAuthorityOnStartup = false;
options.StartupTimeout = TimeSpan.FromSeconds(10);
options.UseAuthorityPushNotifications = false;
});
builder.Services.Replace(ServiceDescriptor.Singleton<StellaOps.Router.Gateway.Authorization.IEffectiveClaimsStore>(
sp => sp.GetRequiredService<IEffectiveClaimsStore>()));
builder.Services.AddSingleton<GatewayServiceStatus>();
builder.Services.AddSingleton<GatewayMetrics>();
@@ -62,55 +75,35 @@ builder.Services.AddSingleton<GatewayMetrics>();
var transportPluginLoader = new RouterTransportPluginLoader(
NullLoggerFactory.Instance.CreateLogger<RouterTransportPluginLoader>());
// Try to load from plugins directory, fallback to direct registration if not found
var pluginsPath = Path.Combine(AppContext.BaseDirectory, "plugins", "router", "transports");
if (Directory.Exists(pluginsPath))
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()
.Where(assembly =>
assembly.GetName().Name?.StartsWith("StellaOps.Router.Transport.", StringComparison.OrdinalIgnoreCase) == true))
{
transportPluginLoader.LoadFromDirectory(pluginsPath);
transportPluginLoader.LoadFromAssembly(assembly);
}
// Register TCP and TLS transports (from plugins or fallback to compile-time references)
var tcpPlugin = transportPluginLoader.GetPlugin("tcp");
var tlsPlugin = transportPluginLoader.GetPlugin("tls");
if (tcpPlugin is not null)
var pluginsPath = builder.Configuration["Gateway:TransportPlugins:Directory"];
if (string.IsNullOrWhiteSpace(pluginsPath))
{
tcpPlugin.Register(new RouterTransportRegistrationContext(
builder.Services, builder.Configuration, RouterTransportMode.Server)
{
ConfigurationSection = "Gateway:Transports:Tcp"
});
}
else
{
// Fallback to compile-time registration
builder.Services.AddTcpTransportServer();
pluginsPath = Path.Combine(AppContext.BaseDirectory, "plugins", "router", "transports");
}
if (tlsPlugin is not null)
var transportSearchPattern = builder.Configuration["Gateway:TransportPlugins:SearchPattern"];
if (string.IsNullOrWhiteSpace(transportSearchPattern))
{
tlsPlugin.Register(new RouterTransportRegistrationContext(
builder.Services, builder.Configuration, RouterTransportMode.Server)
{
ConfigurationSection = "Gateway:Transports:Tls"
});
}
else
{
// Fallback to compile-time registration
builder.Services.AddTlsTransportServer();
transportSearchPattern = "StellaOps.Router.Transport.*.dll";
}
// Messaging transport (Valkey)
if (bootstrapOptions.Transports.Messaging.Enabled)
{
builder.Services.AddMessagingTransport<ValkeyTransportPlugin>(builder.Configuration, "Gateway:Transports:Messaging");
builder.Services.AddMessagingTransportServer();
}
transportPluginLoader.LoadFromDirectory(pluginsPath, transportSearchPattern);
RegisterGatewayTransportIfEnabled("tcp", bootstrapOptions.Transports.Tcp.Enabled, "Gateway:Transports:Tcp");
RegisterGatewayTransportIfEnabled("tls", bootstrapOptions.Transports.Tls.Enabled, "Gateway:Transports:Tls");
RegisterGatewayTransportIfEnabled("messaging", bootstrapOptions.Transports.Messaging.Enabled, "Gateway:Transports:Messaging");
builder.Services.AddSingleton<GatewayTransportClient>();
builder.Services.AddSingleton<ITransportClient>(sp => sp.GetRequiredService<GatewayTransportClient>());
builder.Services.AddSingleton(new GatewayRouteCatalog(bootstrapOptions.Routes));
builder.Services.AddSingleton<IOpenApiDocumentGenerator, OpenApiDocumentGenerator>();
builder.Services.AddSingleton<IRouterOpenApiDocumentCache, RouterOpenApiDocumentCache>();
@@ -125,6 +118,10 @@ builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
{
EnableLegacyHeaders = bootstrapOptions.Auth.EnableLegacyHeaders,
AllowScopeHeaderOverride = bootstrapOptions.Auth.AllowScopeHeader,
EmitIdentityEnvelope = bootstrapOptions.Auth.EmitIdentityEnvelope,
IdentityEnvelopeSigningKey = bootstrapOptions.Auth.IdentityEnvelopeSigningKey,
IdentityEnvelopeIssuer = bootstrapOptions.Auth.IdentityEnvelopeIssuer,
IdentityEnvelopeTtl = TimeSpan.FromSeconds(Math.Max(1, bootstrapOptions.Auth.IdentityEnvelopeTtlSeconds)),
JwtPassthroughPrefixes = bootstrapOptions.Routes
.Where(r => r.PreserveAuthHeaders)
.Select(r => r.Path)
@@ -149,11 +146,11 @@ ConfigureAuthentication(builder, bootstrapOptions);
ConfigureGatewayOptionsMapping(builder, bootstrapOptions);
// Stella Router integration
var routerOptions = builder.Configuration.GetSection("Gateway:Router").Get<StellaRouterOptionsBase>();
builder.Services.TryAddStellaRouter(
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "gateway",
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
routerOptions: routerOptions);
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
@@ -164,9 +161,14 @@ app.LogStellaOpsLocalHostname("router");
// Force browser traffic onto HTTPS so auth (PKCE/DPoP/WebCrypto) always runs in a secure context.
app.Use(async (context, next) =>
{
var isWebSocketUpgrade =
context.WebSockets.IsWebSocketRequest ||
string.Equals(context.Request.Headers.Upgrade, "websocket", StringComparison.OrdinalIgnoreCase);
if (!context.Request.IsHttps &&
context.Request.Host.HasValue &&
!GatewayRoutes.IsSystemPath(context.Request.Path))
!GatewayRoutes.IsSystemPath(context.Request.Path) &&
!isWebSocketUpgrade)
{
var host = context.Request.Host.Host;
var redirect = $"https://{host}{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}";
@@ -185,7 +187,7 @@ app.UseMiddleware<SenderConstraintMiddleware>();
// It strips reserved identity headers and overwrites them from validated claims (security fix)
app.UseMiddleware<IdentityHeaderPolicyMiddleware>();
app.UseMiddleware<HealthCheckMiddleware>();
app.TryUseStellaRouter(routerOptions);
app.TryUseStellaRouter(routerEnabled);
// WebSocket support (before route dispatch)
app.UseWebSockets();
@@ -216,10 +218,34 @@ app.UseWhen(
app.UseMiddleware<ErrorPageFallbackMiddleware>();
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);
app.TryRefreshStellaRouterEndpoints(routerEnabled);
await app.RunAsync();
void RegisterGatewayTransportIfEnabled(string transportName, bool enabled, string configurationSection)
{
if (!enabled)
{
return;
}
var plugin = transportPluginLoader.GetPlugin(transportName);
if (plugin is null)
{
throw new InvalidOperationException(
$"Gateway transport plugin '{transportName}' is not available. " +
$"Provide a plugin assembly in '{pluginsPath}' or add the transport plugin dependency.");
}
plugin.Register(new RouterTransportRegistrationContext(
builder.Services,
builder.Configuration,
RouterTransportMode.Server)
{
ConfigurationSection = configurationSection
});
}
static void ConfigureAuthentication(WebApplicationBuilder builder, GatewayOptions options)
{
var authOptions = options.Auth;
@@ -312,6 +338,7 @@ static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, Gatewa
{
var routing = gateway.Value.Routing;
options.RoutingTimeoutMs = (int)GatewayValueParser.ParseDuration(routing.DefaultTimeout, TimeSpan.FromSeconds(30)).TotalMilliseconds;
options.GlobalTimeoutCapMs = (int)GatewayValueParser.ParseDuration(routing.GlobalTimeoutCap, TimeSpan.FromSeconds(120)).TotalMilliseconds;
options.PreferLocalRegion = routing.PreferLocalRegion;
options.AllowDegradedInstances = routing.AllowDegradedInstances;
options.StrictVersionMatching = routing.StrictVersionMatching;
@@ -346,51 +373,47 @@ static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, Gatewa
options.TokenUrl = openApi.TokenUrl;
});
builder.Services.AddOptions<TcpTransportOptions>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var tcp = gateway.Value.Transports.Tcp;
options.Port = tcp.Port;
options.ReceiveBufferSize = tcp.ReceiveBufferSize;
options.SendBufferSize = tcp.SendBufferSize;
options.MaxFrameSize = tcp.MaxFrameSize;
options.BindAddress = IPAddress.Parse(tcp.BindAddress);
});
builder.Services.AddOptions<TlsTransportOptions>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var tls = gateway.Value.Transports.Tls;
options.Port = tls.Port;
options.ReceiveBufferSize = tls.ReceiveBufferSize;
options.SendBufferSize = tls.SendBufferSize;
options.MaxFrameSize = tls.MaxFrameSize;
options.BindAddress = IPAddress.Parse(tls.BindAddress);
options.ServerCertificatePath = tls.CertificatePath;
options.ServerCertificateKeyPath = tls.CertificateKeyPath;
options.ServerCertificatePassword = tls.CertificatePassword;
options.RequireClientCertificate = tls.RequireClientCertificate;
options.AllowSelfSigned = tls.AllowSelfSigned;
});
builder.Services.AddOptions<MessagingTransportOptions>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var messaging = gateway.Value.Transports.Messaging;
options.RequestQueueTemplate = messaging.RequestQueueTemplate;
options.ResponseQueueName = messaging.ResponseQueueName;
options.ConsumerGroup = messaging.ConsumerGroup;
options.RequestTimeout = GatewayValueParser.ParseDuration(messaging.RequestTimeout, TimeSpan.FromSeconds(30));
options.LeaseDuration = GatewayValueParser.ParseDuration(messaging.LeaseDuration, TimeSpan.FromMinutes(5));
options.BatchSize = messaging.BatchSize;
options.HeartbeatInterval = GatewayValueParser.ParseDuration(messaging.HeartbeatInterval, TimeSpan.FromSeconds(10));
});
builder.Services.AddOptions<ValkeyTransportOptions>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var messaging = gateway.Value.Transports.Messaging;
options.ConnectionString = messaging.ConnectionString;
options.Database = messaging.Database;
});
}
static string? ResolveAuthorityClaimsUrl(GatewayAuthorityOptions authorityOptions)
{
if (!string.IsNullOrWhiteSpace(authorityOptions.ClaimsOverridesUrl))
{
return authorityOptions.ClaimsOverridesUrl.TrimEnd('/');
}
var candidate = authorityOptions.Issuer;
if (string.IsNullOrWhiteSpace(candidate))
{
candidate = authorityOptions.MetadataAddress;
}
if (string.IsNullOrWhiteSpace(candidate))
{
return null;
}
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
return candidate.TrimEnd('/');
}
// Authority runs HTTP on the internal compose network by default.
var builder = new UriBuilder(uri)
{
Path = string.Empty,
Query = string.Empty,
Fragment = string.Empty
};
if (string.Equals(builder.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
builder.Scheme = Uri.UriSchemeHttp;
builder.Port = builder.Port == 443 ? 80 : builder.Port;
}
return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/');
}

View File

@@ -16,8 +16,8 @@ namespace StellaOps.Gateway.WebService.Services;
public sealed class GatewayHostedService : IHostedService
{
private readonly TcpTransportServer _tcpServer;
private readonly TlsTransportServer _tlsServer;
private readonly TcpTransportServer? _tcpServer;
private readonly TlsTransportServer? _tlsServer;
private readonly MessagingTransportServer? _messagingServer;
private readonly IGlobalRoutingState _routingState;
private readonly GatewayTransportClient _transportClient;
@@ -32,14 +32,14 @@ public sealed class GatewayHostedService : IHostedService
private bool _messagingEnabled;
public GatewayHostedService(
TcpTransportServer tcpServer,
TlsTransportServer tlsServer,
IGlobalRoutingState routingState,
GatewayTransportClient transportClient,
IEffectiveClaimsStore claimsStore,
IOptions<GatewayOptions> options,
GatewayServiceStatus status,
ILogger<GatewayHostedService> logger,
TcpTransportServer? tcpServer = null,
TlsTransportServer? tlsServer = null,
IRouterOpenApiDocumentCache? openApiCache = null,
MessagingTransportServer? messagingServer = null)
{
@@ -63,10 +63,25 @@ public sealed class GatewayHostedService : IHostedService
public async Task StartAsync(CancellationToken cancellationToken)
{
var options = _options.Value;
_tcpEnabled = options.Transports.Tcp.Enabled;
_tlsEnabled = options.Transports.Tls.Enabled;
_tcpEnabled = options.Transports.Tcp.Enabled && _tcpServer is not null;
_tlsEnabled = options.Transports.Tls.Enabled && _tlsServer is not null;
_messagingEnabled = options.Transports.Messaging.Enabled && _messagingServer is not null;
if (options.Transports.Tcp.Enabled && _tcpServer is null)
{
_logger.LogWarning("TCP transport is enabled in configuration but no TCP transport server is registered.");
}
if (options.Transports.Tls.Enabled && _tlsServer is null)
{
_logger.LogWarning("TLS transport is enabled in configuration but no TLS transport server is registered.");
}
if (options.Transports.Messaging.Enabled && _messagingServer is null)
{
_logger.LogWarning("Messaging transport is enabled in configuration but no messaging transport server is registered.");
}
if (!_tcpEnabled && !_tlsEnabled && !_messagingEnabled)
{
_logger.LogWarning("No transports enabled; gateway will not accept microservice connections.");
@@ -77,7 +92,7 @@ public sealed class GatewayHostedService : IHostedService
if (_tcpEnabled)
{
_tcpServer.OnFrame += HandleTcpFrame;
_tcpServer!.OnFrame += HandleTcpFrame;
_tcpServer.OnDisconnection += HandleTcpDisconnection;
await _tcpServer.StartAsync(cancellationToken);
_logger.LogInformation("TCP transport started on port {Port}", options.Transports.Tcp.Port);
@@ -85,7 +100,7 @@ public sealed class GatewayHostedService : IHostedService
if (_tlsEnabled)
{
_tlsServer.OnFrame += HandleTlsFrame;
_tlsServer!.OnFrame += HandleTlsFrame;
_tlsServer.OnDisconnection += HandleTlsDisconnection;
await _tlsServer.StartAsync(cancellationToken);
_logger.LogInformation("TLS transport started on port {Port}", options.Transports.Tls.Port);
@@ -117,14 +132,14 @@ public sealed class GatewayHostedService : IHostedService
if (_tcpEnabled)
{
await _tcpServer.StopAsync(cancellationToken);
await _tcpServer!.StopAsync(cancellationToken);
_tcpServer.OnFrame -= HandleTcpFrame;
_tcpServer.OnDisconnection -= HandleTcpDisconnection;
}
if (_tlsEnabled)
{
await _tlsServer.StopAsync(cancellationToken);
await _tlsServer!.StopAsync(cancellationToken);
_tlsServer.OnFrame -= HandleTlsFrame;
_tlsServer.OnDisconnection -= HandleTlsDisconnection;
}
@@ -457,13 +472,13 @@ public sealed class GatewayHostedService : IHostedService
{
if (transportType == TransportType.Tcp)
{
_tcpServer.GetConnection(connectionId)?.Close();
_tcpServer?.GetConnection(connectionId)?.Close();
return;
}
if (transportType == TransportType.Certificate)
{
_tlsServer.GetConnection(connectionId)?.Close();
_tlsServer?.GetConnection(connectionId)?.Close();
}
// Messaging transport connections are managed by the queue system

View File

@@ -13,17 +13,17 @@ namespace StellaOps.Gateway.WebService.Services;
public sealed class GatewayTransportClient : ITransportClient
{
private readonly TcpTransportServer _tcpServer;
private readonly TlsTransportServer _tlsServer;
private readonly TcpTransportServer? _tcpServer;
private readonly TlsTransportServer? _tlsServer;
private readonly MessagingTransportServer? _messagingServer;
private readonly ILogger<GatewayTransportClient> _logger;
private readonly ConcurrentDictionary<string, TaskCompletionSource<Frame>> _pendingRequests = new();
private readonly ConcurrentDictionary<string, Channel<Frame>> _streamingResponses = new();
public GatewayTransportClient(
TcpTransportServer tcpServer,
TlsTransportServer tlsServer,
ILogger<GatewayTransportClient> logger,
TcpTransportServer? tcpServer = null,
TlsTransportServer? tlsServer = null,
MessagingTransportServer? messagingServer = null)
{
_tcpServer = tcpServer;
@@ -147,9 +147,17 @@ public sealed class GatewayTransportClient : ITransportClient
switch (connection.TransportType)
{
case TransportType.Tcp:
if (_tcpServer is null)
{
throw new InvalidOperationException("TCP transport is not enabled for gateway dispatch.");
}
await _tcpServer.SendFrameAsync(connection.ConnectionId, frame, cancellationToken);
break;
case TransportType.Certificate:
if (_tlsServer is null)
{
throw new InvalidOperationException("TLS transport is not enabled for gateway dispatch.");
}
await _tlsServer.SendFrameAsync(connection.ConnectionId, frame, cancellationToken);
break;
case TransportType.Messaging:

View File

@@ -21,4 +21,8 @@
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
<PropertyGroup Label="StellaOpsReleaseVersion">
<Version>1.0.0-alpha1</Version>
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
</PropertyGroup>
</Project>

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0347-T | DONE | Revalidated 2026-01-07; test coverage audit for Router Gateway WebService. |
| AUDIT-0347-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| RGH-01 | DONE | 2026-02-22: Added SPA fallback handling for browser deep links on microservice route matches; API prefixes remain backend-dispatched. |

View File

@@ -4,7 +4,13 @@ using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
using StellaOps.Router.Common.Models;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace StellaOps.Microservice.AspNetCore;
@@ -12,10 +18,18 @@ namespace StellaOps.Microservice.AspNetCore;
/// <summary>
/// Discovers ASP.NET Core endpoints and converts them to Router endpoint descriptors.
/// </summary>
public sealed partial class AspNetCoreEndpointDiscoveryProvider : IAspNetEndpointDiscoveryProvider
public sealed partial class AspNetCoreEndpointDiscoveryProvider :
IAspNetEndpointDiscoveryProvider,
IEndpointSchemaDefinitionProvider
{
private static readonly string[] MethodOrder =
["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"];
private static readonly JsonSerializerOptions SchemaJsonOptions = new()
{
WriteIndented = false
};
private static readonly NullabilityInfoContext NullabilityContext = new();
private const int MaxSchemaDepth = 8;
private readonly EndpointDataSource _endpointDataSource;
private readonly StellaRouterBridgeOptions _options;
@@ -23,6 +37,7 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider : IAspNetEndpoin
private readonly ILogger<AspNetCoreEndpointDiscoveryProvider> _logger;
private IReadOnlyList<AspNetEndpointDescriptor>? _cachedEndpoints;
private IReadOnlyDictionary<string, SchemaDefinition>? _cachedSchemas;
private readonly object _cacheLock = new();
public AspNetCoreEndpointDiscoveryProvider(
@@ -50,13 +65,18 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider : IAspNetEndpoin
{
lock (_cacheLock)
{
if (_cachedEndpoints is not null)
{
return _cachedEndpoints;
}
EnsureCache();
return _cachedEndpoints!;
}
}
_cachedEndpoints = DiscoverEndpointsCore();
return _cachedEndpoints;
/// <inheritdoc />
public IReadOnlyDictionary<string, SchemaDefinition> DiscoverSchemaDefinitions()
{
lock (_cacheLock)
{
EnsureCache();
return _cachedSchemas!;
}
}
@@ -66,10 +86,24 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider : IAspNetEndpoin
lock (_cacheLock)
{
_cachedEndpoints = null;
_cachedSchemas = null;
}
}
private IReadOnlyList<AspNetEndpointDescriptor> DiscoverEndpointsCore()
private void EnsureCache()
{
if (_cachedEndpoints is not null && _cachedSchemas is not null)
{
return;
}
var (endpoints, schemas) = DiscoverEndpointsCore();
_cachedEndpoints = endpoints;
_cachedSchemas = schemas;
}
private (IReadOnlyList<AspNetEndpointDescriptor> Endpoints, IReadOnlyDictionary<string, SchemaDefinition> Schemas)
DiscoverEndpointsCore()
{
var descriptors = new List<AspNetEndpointDescriptor>();
var seenEndpoints = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -77,7 +111,7 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider : IAspNetEndpoin
foreach (var endpoint in _endpointDataSource.Endpoints.OfType<RouteEndpoint>())
{
// Skip endpoints without HTTP method metadata
var httpMethodMetadata = endpoint.Metadata.GetMetadata<HttpMethodMetadata>();
var httpMethodMetadata = endpoint.Metadata.GetMetadata<IHttpMethodMetadata>();
if (httpMethodMetadata?.HttpMethods is not { Count: > 0 })
{
continue;
@@ -132,11 +166,14 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider : IAspNetEndpoin
}
// Sort for deterministic ordering
return descriptors
var ordered = descriptors
.OrderBy(e => e.Path, StringComparer.OrdinalIgnoreCase)
.ThenBy(e => GetMethodOrder(e.Method))
.ThenBy(e => e.OperationId ?? "")
.ToList();
var schemas = BuildSchemaMetadata(ordered);
return (ordered, schemas);
}
private AspNetEndpointDescriptor? BuildDescriptor(
@@ -144,8 +181,12 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider : IAspNetEndpoin
string method,
string normalizedPath)
{
// Map authorization
var authResult = _authMapper.Map(endpoint);
// Map authorization using policy-aware async resolution.
var authResult = _authMapper
.MapAsync(endpoint)
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
// Check authorization requirements based on configuration
if (!authResult.HasAuthorization && !authResult.AllowAnonymous)
@@ -182,6 +223,7 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider : IAspNetEndpoin
// Extract OpenAPI metadata
var (operationId, summary, description, tags) = ExtractOpenApiMetadata(endpoint);
var requiresAuthentication = ResolveRequiresAuthentication(authResult);
return new AspNetEndpointDescriptor
{
@@ -195,6 +237,7 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider : IAspNetEndpoin
AuthorizationPolicies = authResult.Policies,
Roles = authResult.Roles,
AllowAnonymous = authResult.AllowAnonymous,
RequiresAuthentication = requiresAuthentication,
AuthorizationSource = authResult.Source,
Parameters = parameters,
Responses = responses,
@@ -207,6 +250,26 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider : IAspNetEndpoin
};
}
private bool ResolveRequiresAuthentication(AuthorizationMappingResult authResult)
{
if (authResult.AllowAnonymous)
{
return false;
}
if (authResult.HasAuthorization)
{
return true;
}
return _options.OnMissingAuthorization switch
{
MissingAuthorizationBehavior.AllowAuthenticated => true,
MissingAuthorizationBehavior.WarnAndAllow => true,
_ => false
};
}
private string? NormalizeRoutePattern(RoutePattern pattern)
{
string raw;
@@ -423,4 +486,455 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider : IAspNetEndpoin
_ => "object"
};
}
private IReadOnlyDictionary<string, SchemaDefinition> BuildSchemaMetadata(IList<AspNetEndpointDescriptor> descriptors)
{
var schemas = new Dictionary<string, SchemaDefinition>(StringComparer.Ordinal);
for (var i = 0; i < descriptors.Count; i++)
{
var descriptor = descriptors[i];
var requestSchemaId = _options.ExtractSchemas
? AddSchemaDefinition(GetRequestBodyType(descriptor), schemas)
: null;
var responseSchemaId = _options.ExtractSchemas
? AddSchemaDefinition(GetSuccessResponseType(descriptor), schemas)
: null;
var responseStatusCode = GetSuccessResponseStatusCode(descriptor);
var hasOpenApiMetadata =
!string.IsNullOrWhiteSpace(descriptor.Summary) ||
!string.IsNullOrWhiteSpace(descriptor.Description) ||
descriptor.Tags.Count > 0;
if (requestSchemaId is null && responseSchemaId is null && !hasOpenApiMetadata)
{
continue;
}
descriptors[i] = descriptor with
{
SchemaInfo = new EndpointSchemaInfo
{
RequestSchemaId = requestSchemaId,
ResponseSchemaId = responseSchemaId,
ResponseStatusCode = responseStatusCode,
Summary = descriptor.Summary,
Description = descriptor.Description,
Tags = descriptor.Tags
}
};
}
return schemas;
}
private static Type? GetRequestBodyType(AspNetEndpointDescriptor descriptor)
{
var bodyType = descriptor.Parameters
.FirstOrDefault(parameter => parameter.Source == ParameterSource.Body)?
.Type;
if (bodyType is null || bodyType == typeof(object))
{
return null;
}
return UnwrapAsyncType(bodyType);
}
private static Type? GetSuccessResponseType(AspNetEndpointDescriptor descriptor)
{
var successResponses = descriptor.Responses
.OrderBy(response => response.StatusCode)
.Where(response =>
response.StatusCode is >= StatusCodes.Status200OK and < StatusCodes.Status300MultipleChoices &&
response.ResponseType is not null);
foreach (var response in successResponses)
{
var responseType = UnwrapAsyncType(response.ResponseType!);
if (responseType == typeof(void) || responseType == typeof(object))
{
continue;
}
if (TryUnwrapTypedHttpResult(responseType, out var payloadType))
{
return payloadType;
}
if (typeof(IResult).IsAssignableFrom(responseType))
{
continue;
}
return responseType;
}
return null;
}
private static int? GetSuccessResponseStatusCode(AspNetEndpointDescriptor descriptor)
{
return descriptor.Responses
.OrderBy(response => response.StatusCode)
.FirstOrDefault(response =>
response.StatusCode is >= StatusCodes.Status200OK and < StatusCodes.Status300MultipleChoices &&
response.ResponseType is not null)
?.StatusCode;
}
private static bool TryUnwrapTypedHttpResult(Type responseType, out Type payloadType)
{
payloadType = typeof(object);
if (!responseType.IsGenericType || !typeof(IResult).IsAssignableFrom(responseType))
{
return false;
}
if (responseType.Namespace is null ||
!responseType.Namespace.StartsWith("Microsoft.AspNetCore.Http.HttpResults", StringComparison.Ordinal))
{
return false;
}
var genericArguments = responseType.GetGenericArguments();
if (genericArguments.Length != 1)
{
return false;
}
payloadType = UnwrapAsyncType(genericArguments[0]);
return payloadType != typeof(void);
}
private static Type UnwrapAsyncType(Type type)
{
if (type.IsGenericType &&
(type.GetGenericTypeDefinition() == typeof(Task<>) ||
type.GetGenericTypeDefinition() == typeof(ValueTask<>)))
{
return type.GetGenericArguments()[0];
}
return type;
}
private static string? AddSchemaDefinition(
Type? type,
IDictionary<string, SchemaDefinition> schemas)
{
if (type is null)
{
return null;
}
var schemaId = GetSchemaId(type);
if (schemas.ContainsKey(schemaId))
{
return schemaId;
}
var schemaNode = BuildSchema(type, new HashSet<Type>(), depth: 0);
schemaNode["$schema"] = "https://json-schema.org/draft/2020-12/schema";
var schemaJson = schemaNode.ToJsonString(SchemaJsonOptions);
schemas[schemaId] = new SchemaDefinition
{
SchemaId = schemaId,
SchemaJson = schemaJson,
ETag = ComputeSchemaEtag(schemaJson)
};
return schemaId;
}
private static JsonObject BuildSchema(
Type type,
HashSet<Type> visited,
int depth)
{
var underlying = Nullable.GetUnderlyingType(type) ?? type;
var nullable = Nullable.GetUnderlyingType(type) is not null;
if (underlying == typeof(string))
{
return BuildPrimitiveSchema("string", nullable);
}
if (underlying == typeof(bool))
{
return BuildPrimitiveSchema("boolean", nullable);
}
if (underlying == typeof(byte) ||
underlying == typeof(short) ||
underlying == typeof(int) ||
underlying == typeof(long) ||
underlying == typeof(sbyte) ||
underlying == typeof(ushort) ||
underlying == typeof(uint) ||
underlying == typeof(ulong))
{
return BuildPrimitiveSchema("integer", nullable);
}
if (underlying == typeof(float) ||
underlying == typeof(double) ||
underlying == typeof(decimal))
{
return BuildPrimitiveSchema("number", nullable);
}
if (underlying == typeof(Guid))
{
return BuildPrimitiveSchema("string", nullable, "uuid");
}
if (underlying == typeof(DateTime) || underlying == typeof(DateTimeOffset))
{
return BuildPrimitiveSchema("string", nullable, "date-time");
}
if (underlying == typeof(DateOnly))
{
return BuildPrimitiveSchema("string", nullable, "date");
}
if (underlying == typeof(TimeOnly) || underlying == typeof(TimeSpan))
{
return BuildPrimitiveSchema("string", nullable, "duration");
}
if (underlying == typeof(Uri))
{
return BuildPrimitiveSchema("string", nullable, "uri");
}
if (underlying.IsEnum)
{
var enumNode = BuildPrimitiveSchema("string", nullable);
var values = new JsonArray();
foreach (var value in Enum.GetNames(underlying).OrderBy(v => v, StringComparer.Ordinal))
{
values.Add(value);
}
enumNode["enum"] = values;
return enumNode;
}
if (TryGetDictionaryValueType(underlying, out var dictionaryValueType))
{
var schema = new JsonObject
{
["type"] = "object",
["additionalProperties"] = BuildSchema(dictionaryValueType, visited, depth + 1)
};
return MakeNullable(schema, nullable);
}
if (TryGetEnumerableElementType(underlying, out var elementType))
{
var schema = new JsonObject
{
["type"] = "array",
["items"] = BuildSchema(elementType, visited, depth + 1)
};
return MakeNullable(schema, nullable);
}
if (depth >= MaxSchemaDepth || !visited.Add(underlying))
{
return MakeNullable(new JsonObject { ["type"] = "object" }, nullable);
}
var properties = new JsonObject();
var required = new JsonArray();
var publicProperties = underlying
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(property =>
property.GetMethod is not null &&
property.GetMethod.IsPublic &&
property.GetIndexParameters().Length == 0)
.OrderBy(property => property.Name, StringComparer.Ordinal);
foreach (var property in publicProperties)
{
var propertyName = ToCamelCase(property.Name);
properties[propertyName] = BuildSchema(property.PropertyType, visited, depth + 1);
if (IsRequiredProperty(property))
{
required.Add(propertyName);
}
}
visited.Remove(underlying);
var objectSchema = new JsonObject
{
["type"] = "object",
["properties"] = properties
};
if (required.Count > 0)
{
objectSchema["required"] = required;
}
return MakeNullable(objectSchema, nullable);
}
private static JsonObject BuildPrimitiveSchema(string type, bool nullable, string? format = null)
{
var schema = new JsonObject
{
["type"] = type
};
if (!string.IsNullOrWhiteSpace(format))
{
schema["format"] = format;
}
return MakeNullable(schema, nullable);
}
private static JsonObject MakeNullable(JsonObject schema, bool nullable)
{
if (!nullable)
{
return schema;
}
if (schema["type"] is JsonValue value &&
value.TryGetValue<string>(out var schemaType))
{
var types = new JsonArray();
types.Add(schemaType);
types.Add("null");
schema["type"] = types;
}
return schema;
}
private static bool TryGetDictionaryValueType(Type type, out Type valueType)
{
if (type.IsGenericType &&
type.GetGenericTypeDefinition() == typeof(Dictionary<,>) &&
type.GetGenericArguments()[0] == typeof(string))
{
valueType = type.GetGenericArguments()[1];
return true;
}
var dictionaryInterface = type
.GetInterfaces()
.FirstOrDefault(interfaceType =>
interfaceType.IsGenericType &&
interfaceType.GetGenericTypeDefinition() == typeof(IDictionary<,>) &&
interfaceType.GetGenericArguments()[0] == typeof(string));
if (dictionaryInterface is not null)
{
valueType = dictionaryInterface.GetGenericArguments()[1];
return true;
}
valueType = typeof(object);
return false;
}
private static bool TryGetEnumerableElementType(Type type, out Type elementType)
{
if (type == typeof(string))
{
elementType = typeof(object);
return false;
}
if (type.IsArray)
{
elementType = type.GetElementType()!;
return true;
}
var enumerableInterface = type
.GetInterfaces()
.FirstOrDefault(interfaceType =>
interfaceType.IsGenericType &&
interfaceType.GetGenericTypeDefinition() == typeof(IEnumerable<>));
if (enumerableInterface is not null)
{
elementType = enumerableInterface.GetGenericArguments()[0];
return true;
}
elementType = typeof(object);
return false;
}
private static bool IsRequiredProperty(PropertyInfo property)
{
var propertyType = property.PropertyType;
if (propertyType.IsValueType)
{
return Nullable.GetUnderlyingType(propertyType) is null;
}
var nullability = NullabilityContext.Create(property);
return nullability.ReadState == NullabilityState.NotNull;
}
private static string GetSchemaId(Type type)
{
var name = type.FullName ?? type.Name;
var builder = new StringBuilder(name.Length);
var previousUnderscore = false;
foreach (var ch in name)
{
if (char.IsLetterOrDigit(ch))
{
builder.Append(ch);
previousUnderscore = false;
}
else if (!previousUnderscore)
{
builder.Append('_');
previousUnderscore = true;
}
}
return builder.ToString().Trim('_');
}
private static string ComputeSchemaEtag(string schemaJson)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(schemaJson));
return Convert.ToHexString(hash)[..16];
}
private static string ToCamelCase(string name)
{
if (string.IsNullOrEmpty(name))
{
return name;
}
if (name.Length == 1)
{
return name.ToLowerInvariant();
}
return char.ToLowerInvariant(name[0]) + name[1..];
}
}

View File

@@ -64,6 +64,11 @@ public sealed record AspNetEndpointDescriptor
/// </summary>
public bool AllowAnonymous { get; init; }
/// <summary>
/// Whether an authenticated principal is required.
/// </summary>
public bool RequiresAuthentication { get; init; }
/// <summary>
/// Source of the authorization metadata.
/// </summary>
@@ -136,8 +141,24 @@ public sealed record AspNetEndpointDescriptor
DefaultTimeout = DefaultTimeout,
SupportsStreaming = SupportsStreaming,
RequiringClaims = RequiringClaims,
AllowAnonymous = AllowAnonymous,
RequiresAuthentication = RequiresAuthentication,
AuthorizationPolicies = AuthorizationPolicies,
Roles = Roles,
AuthorizationSource = MapAuthorizationSource(AuthorizationSource),
SchemaInfo = SchemaInfo
};
private static EndpointAuthorizationSource MapAuthorizationSource(AuthorizationSource source)
{
return source switch
{
AuthorizationSource.AspNetMetadata => EndpointAuthorizationSource.AspNetMetadata,
AuthorizationSource.YamlOverride => EndpointAuthorizationSource.YamlOverride,
AuthorizationSource.Hybrid => EndpointAuthorizationSource.Hybrid,
_ => EndpointAuthorizationSource.None
};
}
}
/// <summary>
@@ -277,5 +298,9 @@ public sealed record AuthorizationMappingResult
/// Whether any authorization metadata was found.
/// </summary>
public bool HasAuthorization =>
AllowAnonymous || Policies.Count > 0 || Roles.Count > 0 || Claims.Count > 0;
AllowAnonymous ||
Policies.Count > 0 ||
Roles.Count > 0 ||
Claims.Count > 0 ||
Source != AuthorizationSource.None;
}

View File

@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Identity;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
@@ -29,6 +30,8 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
private const string ScopesHeader = "X-StellaOps-Scopes";
private const string RolesHeader = "X-StellaOps-Roles";
private const string SessionHeader = "X-StellaOps-Session";
private const string IdentityEnvelopeHeader = "X-StellaOps-Identity-Envelope";
private const string IdentityEnvelopeSignatureHeader = "X-StellaOps-Identity-Envelope-Signature";
// Headers that should not be forwarded to the response
private static readonly HashSet<string> ExcludedResponseHeaders = new(StringComparer.OrdinalIgnoreCase)
@@ -169,7 +172,11 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
}
// Populate identity from StellaOps headers
PopulateIdentity(httpContext, request.Headers);
var identityResult = PopulateIdentity(httpContext, request.Headers);
if (identityResult == IdentityPopulationResult.Rejected)
{
throw new UnauthorizedAccessException("Gateway identity envelope verification failed.");
}
// Set up response body capture
httpContext.Response.Body = new MemoryStream();
@@ -191,7 +198,131 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
return (new PathString(path), new QueryString(query));
}
private void PopulateIdentity(HttpContext httpContext, IReadOnlyDictionary<string, string> headers)
private IdentityPopulationResult PopulateIdentity(HttpContext httpContext, IReadOnlyDictionary<string, string> headers)
{
string? envelopeFailureReason = null;
if (_options.AuthorizationTrustMode != GatewayAuthorizationTrustMode.ServiceEnforced &&
TryPopulateIdentityFromEnvelope(httpContext, headers, out envelopeFailureReason))
{
return IdentityPopulationResult.Accepted;
}
if (_options.AuthorizationTrustMode == GatewayAuthorizationTrustMode.GatewayEnforced)
{
_logger.LogWarning(
"Rejecting request {TraceId}: gateway-enforced trust requires valid identity envelope ({Reason})",
httpContext.TraceIdentifier,
envelopeFailureReason ?? "missing or invalid envelope");
return IdentityPopulationResult.Rejected;
}
PopulateIdentityFromHeaders(httpContext, headers);
return IdentityPopulationResult.Accepted;
}
private bool TryPopulateIdentityFromEnvelope(
HttpContext httpContext,
IReadOnlyDictionary<string, string> headers,
out string? failureReason)
{
failureReason = null;
if (!headers.TryGetValue(IdentityEnvelopeHeader, out var encodedEnvelope) ||
string.IsNullOrWhiteSpace(encodedEnvelope))
{
failureReason = "envelope header missing";
return false;
}
if (!headers.TryGetValue(IdentityEnvelopeSignatureHeader, out var encodedSignature) ||
string.IsNullOrWhiteSpace(encodedSignature))
{
failureReason = "envelope signature header missing";
return false;
}
if (string.IsNullOrWhiteSpace(_options.IdentityEnvelopeSigningKey))
{
failureReason = "envelope signing key not configured";
return false;
}
if (!GatewayIdentityEnvelopeCodec.TryVerify(
encodedEnvelope,
encodedSignature,
_options.IdentityEnvelopeSigningKey,
out var envelope) ||
envelope is null)
{
failureReason = "envelope signature validation failed";
return false;
}
var now = DateTimeOffset.UtcNow;
var skew = _options.IdentityEnvelopeClockSkew;
if (envelope.IssuedAtUtc - skew > now)
{
failureReason = "envelope issued-at timestamp is in the future";
return false;
}
if (envelope.ExpiresAtUtc + skew < now)
{
failureReason = "envelope expired";
return false;
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, envelope.Subject),
new("sub", envelope.Subject)
};
if (!string.IsNullOrWhiteSpace(envelope.Tenant))
{
claims.Add(new Claim("tenant", envelope.Tenant));
}
if (!string.IsNullOrWhiteSpace(envelope.Project))
{
claims.Add(new Claim("project", envelope.Project));
}
if (!string.IsNullOrWhiteSpace(envelope.CorrelationId))
{
claims.Add(new Claim("correlation_id", envelope.CorrelationId));
}
foreach (var scope in envelope.Scopes.Where(s => !string.IsNullOrWhiteSpace(s)))
{
claims.Add(new Claim("scope", scope));
}
foreach (var role in envelope.Roles.Where(r => !string.IsNullOrWhiteSpace(r)))
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
if (!string.IsNullOrWhiteSpace(envelope.SenderConfirmation))
{
claims.Add(new Claim("cnf.jkt", envelope.SenderConfirmation));
}
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity(
claims,
authenticationType: "StellaRouterEnvelope",
nameType: ClaimTypes.NameIdentifier,
roleType: ClaimTypes.Role));
return true;
}
private static void PopulateIdentityFromHeaders(
HttpContext httpContext,
IReadOnlyDictionary<string, string> headers)
{
var claims = new List<Claim>();
@@ -537,4 +668,10 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
{
public RouteValueDictionary RouteValues { get; set; } = new();
}
private enum IdentityPopulationResult
{
Accepted,
Rejected
}
}

View File

@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authorization.Infrastructure;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
using System.Collections;
using System.Security.Claims;
namespace StellaOps.Microservice.AspNetCore;
@@ -14,6 +15,7 @@ namespace StellaOps.Microservice.AspNetCore;
/// </summary>
public sealed class DefaultAuthorizationClaimMapper : IAuthorizationClaimMapper
{
private const string ScopeClaimType = "scope";
private readonly IAuthorizationPolicyProvider _policyProvider;
private readonly ILogger<DefaultAuthorizationClaimMapper> _logger;
@@ -66,7 +68,7 @@ public sealed class DefaultAuthorizationClaimMapper : IAuthorizationClaimMapper
if (!roles.Contains(role, StringComparer.OrdinalIgnoreCase))
{
roles.Add(role);
claims.Add(new ClaimRequirement
AddClaimIfMissing(claims, new ClaimRequirement
{
Type = ClaimTypes.Role,
Value = role
@@ -88,10 +90,7 @@ public sealed class DefaultAuthorizationClaimMapper : IAuthorizationClaimMapper
.ConfigureAwait(false);
foreach (var claim in policyClaims)
{
if (!claims.Any(c => c.Type == claim.Type && c.Value == claim.Value))
{
claims.Add(claim);
}
AddClaimIfMissing(claims, claim);
}
}
}
@@ -106,7 +105,20 @@ public sealed class DefaultAuthorizationClaimMapper : IAuthorizationClaimMapper
}
}
var source = authorizeDataItems.Any()
var authorizationPolicy = endpoint.Metadata.GetMetadata<AuthorizationPolicy>();
if (authorizationPolicy is not null)
{
var metadataPolicyClaims = ExtractPolicyClaims(
authorizationPolicy,
policyName: null,
logUnmappedRequirements: false);
foreach (var claim in metadataPolicyClaims)
{
AddClaimIfMissing(claims, claim);
}
}
var source = authorizeDataItems.Any() || authorizationPolicy is not null
? AuthorizationSource.AspNetMetadata
: AuthorizationSource.None;
@@ -160,7 +172,7 @@ public sealed class DefaultAuthorizationClaimMapper : IAuthorizationClaimMapper
if (!roles.Contains(role, StringComparer.OrdinalIgnoreCase))
{
roles.Add(role);
claims.Add(new ClaimRequirement
AddClaimIfMissing(claims, new ClaimRequirement
{
Type = ClaimTypes.Role,
Value = role
@@ -183,7 +195,20 @@ public sealed class DefaultAuthorizationClaimMapper : IAuthorizationClaimMapper
}
}
var source = authorizeDataItems.Any()
var authorizationPolicy = endpoint.Metadata.GetMetadata<AuthorizationPolicy>();
if (authorizationPolicy is not null)
{
var metadataPolicyClaims = ExtractPolicyClaims(
authorizationPolicy,
policyName: null,
logUnmappedRequirements: false);
foreach (var claim in metadataPolicyClaims)
{
AddClaimIfMissing(claims, claim);
}
}
var source = authorizeDataItems.Any() || authorizationPolicy is not null
? AuthorizationSource.AspNetMetadata
: AuthorizationSource.None;
@@ -212,63 +237,10 @@ public sealed class DefaultAuthorizationClaimMapper : IAuthorizationClaimMapper
return claims;
}
foreach (var requirement in policy.Requirements)
var policyClaims = ExtractPolicyClaims(policy, policyName, logUnmappedRequirements: true);
foreach (var claim in policyClaims)
{
switch (requirement)
{
case ClaimsAuthorizationRequirement claimsReq:
if (claimsReq.AllowedValues is { } allowedValues && allowedValues.Any())
{
foreach (var value in allowedValues)
{
claims.Add(new ClaimRequirement
{
Type = claimsReq.ClaimType,
Value = value
});
}
}
else
{
// Claim type required but any value is acceptable
claims.Add(new ClaimRequirement
{
Type = claimsReq.ClaimType,
Value = null
});
}
break;
case RolesAuthorizationRequirement rolesReq:
foreach (var role in rolesReq.AllowedRoles)
{
claims.Add(new ClaimRequirement
{
Type = ClaimTypes.Role,
Value = role
});
}
break;
case NameAuthorizationRequirement nameReq:
if (!string.IsNullOrEmpty(nameReq.RequiredName))
{
claims.Add(new ClaimRequirement
{
Type = ClaimTypes.Name,
Value = nameReq.RequiredName
});
}
break;
default:
// For custom requirements, log a warning
_logger.LogDebug(
"Custom authorization requirement type '{RequirementType}' in policy '{Policy}' cannot be mapped to claims",
requirement.GetType().Name,
policyName);
break;
}
AddClaimIfMissing(claims, claim);
}
}
catch (Exception ex)
@@ -281,4 +253,160 @@ public sealed class DefaultAuthorizationClaimMapper : IAuthorizationClaimMapper
return claims;
}
private IReadOnlyList<ClaimRequirement> ExtractPolicyClaims(
AuthorizationPolicy policy,
string? policyName,
bool logUnmappedRequirements)
{
var claims = new List<ClaimRequirement>();
foreach (var requirement in policy.Requirements)
{
switch (requirement)
{
case DenyAnonymousAuthorizationRequirement:
// Auth-only requirement with no concrete claim.
continue;
case ClaimsAuthorizationRequirement claimsReq:
if (claimsReq.AllowedValues is { } allowedValues && allowedValues.Any())
{
foreach (var value in allowedValues)
{
AddClaimIfMissing(claims, new ClaimRequirement
{
Type = claimsReq.ClaimType,
Value = value
});
}
}
else
{
// Claim type required but any value is acceptable.
AddClaimIfMissing(claims, new ClaimRequirement
{
Type = claimsReq.ClaimType,
Value = null
});
}
break;
case RolesAuthorizationRequirement rolesReq:
foreach (var role in rolesReq.AllowedRoles)
{
AddClaimIfMissing(claims, new ClaimRequirement
{
Type = ClaimTypes.Role,
Value = role
});
}
break;
case NameAuthorizationRequirement nameReq:
if (!string.IsNullOrEmpty(nameReq.RequiredName))
{
AddClaimIfMissing(claims, new ClaimRequirement
{
Type = ClaimTypes.Name,
Value = nameReq.RequiredName
});
}
break;
default:
if (TryExtractScopeClaims(requirement, out var scopeClaims))
{
foreach (var scopeClaim in scopeClaims)
{
AddClaimIfMissing(claims, scopeClaim);
}
continue;
}
if (logUnmappedRequirements)
{
_logger.LogDebug(
"Custom authorization requirement type '{RequirementType}' in policy '{Policy}' cannot be mapped to claims",
requirement.GetType().Name,
policyName ?? "(metadata)");
}
break;
}
}
return claims;
}
private static bool TryExtractScopeClaims(
IAuthorizationRequirement requirement,
out IReadOnlyList<ClaimRequirement> claims)
{
var requirementType = requirement.GetType();
var property = requirementType.GetProperty("RequiredScopes")
?? requirementType.GetProperty("Scopes")
?? requirementType.GetProperty("AllowedScopes");
if (property?.GetValue(requirement) is not IEnumerable rawValues)
{
claims = [];
return false;
}
var extracted = new List<ClaimRequirement>();
foreach (var rawValue in rawValues)
{
if (rawValue is not string value || string.IsNullOrWhiteSpace(value))
{
continue;
}
extracted.Add(new ClaimRequirement
{
Type = ScopeClaimType,
Value = value.Trim()
});
}
claims = extracted
.DistinctBy(claim => (claim.Type, claim.Value), StringTupleComparer.OrdinalIgnoreCase)
.ToArray();
return claims.Count > 0;
}
private static void AddClaimIfMissing(ICollection<ClaimRequirement> claims, ClaimRequirement claim)
{
if (claims.Any(existing =>
string.Equals(existing.Type, claim.Type, StringComparison.OrdinalIgnoreCase) &&
string.Equals(existing.Value, claim.Value, StringComparison.Ordinal)))
{
return;
}
claims.Add(claim);
}
private sealed class StringTupleComparer : IEqualityComparer<(string Type, string? Value)>
{
public static readonly StringTupleComparer OrdinalIgnoreCase = new();
public bool Equals((string Type, string? Value) x, (string Type, string? Value) y)
{
return string.Equals(x.Type, y.Type, StringComparison.OrdinalIgnoreCase) &&
string.Equals(x.Value, y.Value, StringComparison.Ordinal);
}
public int GetHashCode((string Type, string? Value) obj)
{
return HashCode.Combine(
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Type),
obj.Value is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.Value));
}
}
}

View File

@@ -49,6 +49,10 @@ public static class StellaRouterBridgeExtensions
// Register options as singleton
services.AddSingleton(options);
// Ensure both metadata mapping and middleware pipeline requirements are satisfied
// even when the host service does not explicitly call AddAuthorization().
services.AddAuthorization();
// Register authorization claim mapper
services.TryAddSingleton<IAuthorizationClaimMapper, DefaultAuthorizationClaimMapper>();

View File

@@ -88,6 +88,24 @@ public sealed class StellaRouterBridgeOptions
/// </summary>
public bool EnableStreaming { get; set; }
/// <summary>
/// Trust mode for gateway-issued identity semantics.
/// Default: Hybrid.
/// </summary>
public GatewayAuthorizationTrustMode AuthorizationTrustMode { get; set; }
= GatewayAuthorizationTrustMode.Hybrid;
/// <summary>
/// Shared signing key used to verify gateway identity envelopes.
/// Required when AuthorizationTrustMode is GatewayEnforced.
/// </summary>
public string? IdentityEnvelopeSigningKey { get; set; }
/// <summary>
/// Allowed clock skew when validating gateway identity envelope timestamps.
/// </summary>
public TimeSpan IdentityEnvelopeClockSkew { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// List of path prefixes to exclude from bridging.
/// Default: health and metrics endpoints.
@@ -95,9 +113,13 @@ public sealed class StellaRouterBridgeOptions
public IList<string> ExcludedPathPrefixes { get; set; } = new List<string>
{
"/health",
"/healthz",
"/readyz",
"/livez",
"/metrics",
"/swagger",
"/openapi"
"/openapi",
"/.well-known"
};
/// <summary>
@@ -180,6 +202,29 @@ public enum UnsupportedConstraintBehavior
SilentStrip
}
/// <summary>
/// Service-side trust mode for gateway authorization and identity envelopes.
/// </summary>
public enum GatewayAuthorizationTrustMode
{
/// <summary>
/// Service performs authorization checks independently.
/// Gateway headers are best-effort context only.
/// </summary>
ServiceEnforced,
/// <summary>
/// Prefer gateway identity envelope when valid, otherwise fall back to headers.
/// </summary>
Hybrid,
/// <summary>
/// Require a valid gateway-signed identity envelope.
/// Missing/invalid envelopes fail closed.
/// </summary>
GatewayEnforced
}
/// <summary>
/// Source of authorization metadata for an endpoint.
/// </summary>

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| RVM-06 | DONE | Improved success-response schema inference to prefer typed produces metadata over generic `IResult` entries and retained OpenAPI summary/description/tags extraction. |
| AUDIT-0388-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Microservice.AspNetCore. |
| AUDIT-0388-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Microservice.AspNetCore. |
| AUDIT-0388-A | TODO | Revalidated 2026-01-07 (open findings). |

View File

@@ -0,0 +1,14 @@
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
/// <summary>
/// Optional provider for endpoint JSON schema definitions discovered at runtime.
/// </summary>
public interface IEndpointSchemaDefinitionProvider
{
/// <summary>
/// Gets schema definitions keyed by schema identifier.
/// </summary>
IReadOnlyDictionary<string, SchemaDefinition> DiscoverSchemaDefinitions();
}

View File

@@ -9,18 +9,24 @@ namespace StellaOps.Microservice;
public sealed class MicroserviceHostedService : IHostedService
{
private readonly IRouterConnectionManager _connectionManager;
private readonly IHostApplicationLifetime _applicationLifetime;
private readonly ILogger<MicroserviceHostedService> _logger;
private readonly SchemaProviderDiscoveryDiagnostics? _schemaDiagnostics;
private readonly CancellationTokenSource _startupCts = new();
private Task? _startupTask;
private volatile bool _isStarted;
/// <summary>
/// Initializes a new instance of the <see cref="MicroserviceHostedService"/> class.
/// </summary>
public MicroserviceHostedService(
IRouterConnectionManager connectionManager,
IHostApplicationLifetime applicationLifetime,
ILogger<MicroserviceHostedService> logger,
SchemaProviderDiscoveryDiagnostics? schemaDiagnostics = null)
{
_connectionManager = connectionManager;
_applicationLifetime = applicationLifetime;
_logger = logger;
_schemaDiagnostics = schemaDiagnostics;
}
@@ -39,15 +45,101 @@ public sealed class MicroserviceHostedService : IHostedService
issue.Message);
}
}
await _connectionManager.StartAsync(cancellationToken);
_logger.LogInformation("Stella microservice started");
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
_startupCts.Token);
_startupTask = Task.Run(async () =>
{
try
{
await StartWhenApplicationReadyAsync(linkedCts.Token);
}
finally
{
linkedCts.Dispose();
}
}, CancellationToken.None);
await Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping Stella microservice");
await _connectionManager.StopAsync(cancellationToken);
await _startupCts.CancelAsync();
if (_startupTask is not null)
{
try
{
await _startupTask.WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
// Expected during shutdown.
}
}
if (_isStarted)
{
await _connectionManager.StopAsync(cancellationToken);
}
_logger.LogInformation("Stella microservice stopped");
}
private async Task StartWhenApplicationReadyAsync(CancellationToken cancellationToken)
{
try
{
await WaitForApplicationStartedAsync(cancellationToken);
await _connectionManager.StartAsync(cancellationToken);
_isStarted = true;
_logger.LogInformation("Stella microservice started");
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogDebug("Stella microservice startup cancelled");
}
catch (Exception ex)
{
_logger.LogError(ex, "Stella microservice failed to start");
_applicationLifetime.StopApplication();
}
}
private Task WaitForApplicationStartedAsync(CancellationToken cancellationToken)
{
if (_applicationLifetime.ApplicationStarted.IsCancellationRequested)
{
return Task.CompletedTask;
}
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var startedRegistration = _applicationLifetime.ApplicationStarted.Register(
static state => ((TaskCompletionSource)state!).TrySetResult(),
tcs);
var cancelRegistration = cancellationToken.Register(
static state =>
{
var tuple = ((TaskCompletionSource, CancellationToken))state!;
tuple.Item1.TrySetCanceled(tuple.Item2);
},
(tcs, cancellationToken));
_ = tcs.Task.ContinueWith(
_ =>
{
startedRegistration.Dispose();
cancelRegistration.Dispose();
},
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
return tcs.Task;
}
}

View File

@@ -103,9 +103,10 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
_microserviceTransport.OnRequestReceived += HandleRequestReceivedAsync;
}
// Get schema definitions from generated provider
_schemas = _generatedProvider?.GetSchemaDefinitions()
?? new Dictionary<string, SchemaDefinition>();
// Get schema definitions from generated provider and runtime discovery provider.
var generatedSchemas = _generatedProvider?.GetSchemaDefinitions();
var discoveredSchemas = (_endpointDiscovery as IEndpointSchemaDefinitionProvider)?.DiscoverSchemaDefinitions();
_schemas = MergeSchemaDefinitions(generatedSchemas, discoveredSchemas);
_logger.LogInformation("Discovered {SchemaCount} schemas", _schemas.Count);
// Build OpenAPI info from options
@@ -133,7 +134,12 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
Region = _options.Region
};
await _microserviceTransport.ConnectAsync(instance, _endpoints, cancellationToken);
await _microserviceTransport.ConnectAsync(
instance,
_endpoints,
_schemas,
_openApiInfo,
cancellationToken);
}
else
{
@@ -347,6 +353,33 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
}
}
private static IReadOnlyDictionary<string, SchemaDefinition> MergeSchemaDefinitions(
IReadOnlyDictionary<string, SchemaDefinition>? generatedSchemas,
IReadOnlyDictionary<string, SchemaDefinition>? discoveredSchemas)
{
var merged = new Dictionary<string, SchemaDefinition>(StringComparer.Ordinal);
if (generatedSchemas is not null)
{
foreach (var pair in generatedSchemas.OrderBy(p => p.Key, StringComparer.Ordinal))
{
merged[pair.Key] = pair.Value;
}
}
if (discoveredSchemas is not null)
{
foreach (var pair in discoveredSchemas.OrderBy(p => p.Key, StringComparer.Ordinal))
{
// Runtime-discovered schemas include ASP.NET metadata and should take precedence
// when the same schema id also exists in source-generated output.
merged[pair.Key] = pair.Value;
}
}
return merged;
}
/// <inheritdoc />
public void Dispose()
{

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| RVM-06 | DONE | Merged runtime-discovered endpoint schemas with generated schemas (runtime precedence) and passed schema metadata to transport connect for HELLO propagation. |
| AUDIT-0387-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Microservice. |
| AUDIT-0387-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Microservice. |
| AUDIT-0387-A | DONE | Applied 2026-01-13; superseded by AUDIT-0598-A. |

View File

@@ -59,6 +59,9 @@ public static class StellaRouterExtensions
bridgeOptions.YamlConfigPath = options.YamlConfigPath;
bridgeOptions.OnMissingAuthorization = options.OnMissingAuthorization;
bridgeOptions.DefaultTimeout = options.DefaultTimeout;
bridgeOptions.AuthorizationTrustMode = options.AuthorizationTrustMode;
bridgeOptions.IdentityEnvelopeSigningKey = options.IdentityEnvelopeSigningKey;
bridgeOptions.IdentityEnvelopeClockSkew = TimeSpan.FromSeconds(options.IdentityEnvelopeClockSkewSeconds);
bridgeOptions.ExcludedPathPrefixes.Clear();
foreach (var prefix in options.ExcludedPathPrefixes)

View File

@@ -1,6 +1,9 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Plugins;
using System.Globalization;
namespace StellaOps.Router.AspNet;
@@ -29,15 +32,20 @@ public static class StellaRouterIntegrationHelper
return false;
}
var normalizedVersion = NormalizeServiceVersion(version);
services.AddStellaRouter(opts =>
{
opts.ServiceName = serviceName;
opts.Version = version;
opts.Version = normalizedVersion;
opts.Region = routerOptions.Region;
opts.EnableAspNetBridge = true;
opts.EnableStellaEndpoints = false;
opts.DefaultTimeout = TimeSpan.FromSeconds(routerOptions.DefaultTimeoutSeconds);
opts.HeartbeatInterval = TimeSpan.FromSeconds(routerOptions.HeartbeatIntervalSeconds);
opts.AuthorizationTrustMode = routerOptions.AuthorizationTrustMode;
opts.IdentityEnvelopeSigningKey = routerOptions.IdentityEnvelopeSigningKey;
opts.IdentityEnvelopeClockSkewSeconds = routerOptions.IdentityEnvelopeClockSkewSeconds;
foreach (var gateway in routerOptions.Gateways)
{
@@ -55,6 +63,148 @@ public static class StellaRouterIntegrationHelper
return true;
}
/// <summary>
/// Conditionally adds Stella Router and registers the required microservice transport client
/// based on configuration in the specified Router options section.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">Application configuration root.</param>
/// <param name="serviceName">The service name for Router registration.</param>
/// <param name="version">The service version.</param>
/// <param name="routerOptionsSection">Configuration section path (for example: "TimelineIndexer:Router").</param>
/// <param name="messagingOptionsSection">
/// Optional messaging configuration section path.
/// Defaults to "{routerOptionsSection}:Messaging" when omitted.
/// </param>
/// <returns>True when Router is enabled and configured; otherwise false.</returns>
public static bool AddRouterMicroservice(
this IServiceCollection services,
IConfiguration configuration,
string serviceName,
string version,
string routerOptionsSection,
string? messagingOptionsSection = null)
{
ArgumentNullException.ThrowIfNull(configuration);
if (string.IsNullOrWhiteSpace(routerOptionsSection))
{
throw new ArgumentException("Router options section is required.", nameof(routerOptionsSection));
}
var configuredRouterSection = configuration.GetSection(routerOptionsSection);
var hasConfiguredRouterSection = HasSectionValues(configuredRouterSection);
var resolvedRouterOptionsSection = routerOptionsSection;
var routerOptions = configuredRouterSection.Get<StellaRouterOptionsBase>();
if (!hasConfiguredRouterSection)
{
var fallbackRouterSection = configuration.GetSection("Router");
if (HasSectionValues(fallbackRouterSection))
{
routerOptions = fallbackRouterSection.Get<StellaRouterOptionsBase>();
resolvedRouterOptionsSection = "Router";
}
}
var resolvedMessagingSection = string.IsNullOrWhiteSpace(messagingOptionsSection)
? $"{resolvedRouterOptionsSection}:Messaging"
: messagingOptionsSection;
return services.AddRouterMicroservice(
configuration,
serviceName,
version,
routerOptions,
resolvedMessagingSection,
resolvedRouterOptionsSection);
}
/// <summary>
/// Conditionally adds Stella Router and registers the required microservice transport client
/// based on configured gateway transport types.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">Application configuration root.</param>
/// <param name="serviceName">The service name for Router registration.</param>
/// <param name="version">The service version.</param>
/// <param name="routerOptions">Bound router options.</param>
/// <param name="messagingOptionsSection">Messaging configuration section path.</param>
/// <returns>True when Router is enabled and configured; otherwise false.</returns>
public static bool AddRouterMicroservice(
this IServiceCollection services,
IConfiguration configuration,
string serviceName,
string version,
StellaRouterOptionsBase? routerOptions,
string? messagingOptionsSection = null,
string? routerOptionsSection = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
if (routerOptions?.Enabled != true)
{
return false;
}
var resolvedRouterOptionsSection = ResolveRouterOptionsSection(routerOptionsSection, messagingOptionsSection);
EnsureGatewayConfigured(routerOptions, configuration, resolvedRouterOptionsSection);
if (routerOptions.Gateways.Count == 0)
{
throw new InvalidOperationException("Router is enabled but no gateway endpoints are configured.");
}
services.TryAddStellaRouter(
serviceName,
version,
routerOptions);
var configuredTransports = routerOptions.Gateways
.GroupBy(gateway => gateway.TransportType)
.Select(group => (TransportType: group.Key, Gateway: group.First()))
.OrderBy(item => item.TransportType)
.ToArray();
var resolvedMessagingSection = string.IsNullOrWhiteSpace(messagingOptionsSection)
? $"{resolvedRouterOptionsSection}:Messaging"
: messagingOptionsSection;
var transportConfiguration = BuildResolvedTransportConfiguration(
configuration,
resolvedRouterOptionsSection,
resolvedMessagingSection,
configuredTransports);
var pluginLoader = CreateTransportPluginLoader(
transportConfiguration,
resolvedRouterOptionsSection);
foreach (var transport in configuredTransports)
{
var transportName = ToTransportPluginName(transport.TransportType);
var plugin = pluginLoader.GetPlugin(transportName);
if (plugin is null)
{
var pluginDirectory = ResolveTransportPluginDirectory(transportConfiguration, resolvedRouterOptionsSection);
throw new InvalidOperationException(
$"Transport plugin '{transportName}' is not available for Router microservice startup. " +
$"Provide a plugin assembly in '{pluginDirectory}' or add the transport plugin dependency to the service.");
}
plugin.Register(new RouterTransportRegistrationContext(
services,
transportConfiguration,
RouterTransportMode.Client)
{
ConfigurationSection = BuildResolvedTransportSection(transportName)
});
}
return true;
}
/// <summary>
/// Conditionally enables Stella Router middleware if Router is enabled in options.
/// Call after UseAuthorization().
@@ -74,6 +224,24 @@ public static class StellaRouterIntegrationHelper
return app;
}
/// <summary>
/// Conditionally enables Stella Router middleware when the supplied flag is true.
/// </summary>
/// <param name="app">The application builder.</param>
/// <param name="routerEnabled">Whether Router integration is enabled.</param>
/// <returns>The application builder for chaining.</returns>
public static IApplicationBuilder TryUseStellaRouter(
this IApplicationBuilder app,
bool routerEnabled)
{
if (routerEnabled)
{
app.UseStellaRouter();
}
return app;
}
/// <summary>
/// Conditionally refreshes Stella Router endpoint cache if Router is enabled.
/// Call after all endpoints are mapped.
@@ -92,4 +260,384 @@ public static class StellaRouterIntegrationHelper
return app;
}
/// <summary>
/// Conditionally refreshes Stella Router endpoint cache when the supplied flag is true.
/// </summary>
/// <param name="app">The application builder.</param>
/// <param name="routerEnabled">Whether Router integration is enabled.</param>
/// <returns>The application builder for chaining.</returns>
public static IApplicationBuilder TryRefreshStellaRouterEndpoints(
this IApplicationBuilder app,
bool routerEnabled)
{
if (routerEnabled)
{
app.RefreshStellaRouterEndpoints();
}
return app;
}
private static string ResolveRouterOptionsSection(string? routerOptionsSection, string? messagingOptionsSection)
{
if (!string.IsNullOrWhiteSpace(routerOptionsSection))
{
return routerOptionsSection;
}
if (!string.IsNullOrWhiteSpace(messagingOptionsSection) &&
messagingOptionsSection.EndsWith(":Messaging", StringComparison.OrdinalIgnoreCase))
{
return messagingOptionsSection[..^":Messaging".Length];
}
return "Router";
}
private static bool HasSectionValues(IConfigurationSection section)
{
return !string.IsNullOrWhiteSpace(section.Value) || section.GetChildren().Any();
}
private static void EnsureGatewayConfigured(
StellaRouterOptionsBase routerOptions,
IConfiguration configuration,
string routerOptionsSection)
{
if (routerOptions.Gateways.Count > 0)
{
return;
}
var host = configuration[$"{routerOptionsSection}:Defaults:GatewayHost"] ??
configuration["Router:Defaults:GatewayHost"] ??
"router.stella-ops.local";
var port = 9100;
var configuredPort = configuration[$"{routerOptionsSection}:Defaults:GatewayPort"] ??
configuration["Router:Defaults:GatewayPort"];
if (!string.IsNullOrWhiteSpace(configuredPort) &&
int.TryParse(configuredPort, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedPort) &&
parsedPort > 0)
{
port = parsedPort;
}
var transportValue = configuration[$"{routerOptionsSection}:Defaults:Transport"] ??
configuration["Router:Defaults:Transport"] ??
"Messaging";
var transportType = ParseTransportType(transportValue, fallback: TransportType.Messaging);
routerOptions.Gateways.Add(new StellaRouterGatewayOptionsBase
{
Host = host,
Port = port,
TransportType = transportType
});
}
private static IConfiguration BuildResolvedTransportConfiguration(
IConfiguration configuration,
string routerOptionsSection,
string messagingOptionsSection,
IEnumerable<(TransportType TransportType, StellaRouterGatewayOptionsBase Gateway)> configuredTransports)
{
var transportOverrides = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var transport in configuredTransports)
{
var transportName = ToTransportPluginName(transport.TransportType);
var resolvedSection = BuildResolvedTransportSection(transportName);
var sourceSection = ResolveSourceTransportSection(
transport.TransportType,
routerOptionsSection,
messagingOptionsSection);
CopySectionValues(configuration.GetSection(sourceSection), transportOverrides, resolvedSection);
ApplyGatewayDefaults(transportOverrides, resolvedSection, transport.TransportType, transport.Gateway);
NormalizeMessagingValues(
transportOverrides,
configuration,
resolvedSection,
transport.TransportType);
}
return new ConfigurationBuilder()
.AddConfiguration(configuration)
.AddInMemoryCollection(transportOverrides)
.Build();
}
private static void CopySectionValues(
IConfigurationSection section,
IDictionary<string, string?> destination,
string destinationSection)
{
foreach (var pair in section.AsEnumerable(makePathsRelative: true))
{
if (string.IsNullOrEmpty(pair.Key) || pair.Value is null)
{
continue;
}
destination[$"{destinationSection}:{pair.Key}"] = pair.Value;
}
}
private static void ApplyGatewayDefaults(
IDictionary<string, string?> destination,
string destinationSection,
TransportType transportType,
StellaRouterGatewayOptionsBase gateway)
{
switch (transportType)
{
case TransportType.Tcp:
SetIfMissing(destination, $"{destinationSection}:Host", gateway.Host);
SetIfMissing(destination, $"{destinationSection}:Port", gateway.Port.ToString(CultureInfo.InvariantCulture));
break;
case TransportType.Certificate:
SetIfMissing(destination, $"{destinationSection}:Host", gateway.Host);
SetIfMissing(destination, $"{destinationSection}:Port", gateway.Port.ToString(CultureInfo.InvariantCulture));
SetIfMissing(destination, $"{destinationSection}:ExpectedServerHostname", gateway.Host);
if (!string.IsNullOrWhiteSpace(gateway.CertificatePath))
{
SetIfMissing(destination, $"{destinationSection}:ClientCertificatePath", gateway.CertificatePath);
}
break;
case TransportType.Udp:
SetIfMissing(destination, $"{destinationSection}:Host", gateway.Host);
SetIfMissing(destination, $"{destinationSection}:Port", gateway.Port.ToString(CultureInfo.InvariantCulture));
break;
case TransportType.RabbitMq:
SetIfMissing(destination, $"{destinationSection}:HostName", gateway.Host);
SetIfMissing(destination, $"{destinationSection}:Port", gateway.Port.ToString(CultureInfo.InvariantCulture));
break;
}
}
private static void NormalizeMessagingValues(
IDictionary<string, string?> destination,
IConfiguration configuration,
string destinationSection,
TransportType transportType)
{
if (transportType != TransportType.Messaging)
{
return;
}
SetIfMissing(destination, $"{destinationSection}:transport", "valkey");
SetIfMissing(destination, $"{destinationSection}:PluginDirectory", "/app/plugins/messaging");
SetIfMissing(destination, $"{destinationSection}:SearchPattern", "StellaOps.Messaging.Transport.*.dll");
SetIfMissing(destination, $"{destinationSection}:RequestQueueTemplate", "router:requests:{service}");
SetIfMissing(destination, $"{destinationSection}:ResponseQueueName", "router:responses");
if (destination.TryGetValue($"{destinationSection}:ConnectionString", out var connectionString))
{
SetIfMissing(destination, $"{destinationSection}:valkey:ConnectionString", connectionString);
}
if (destination.TryGetValue($"{destinationSection}:Database", out var database))
{
SetIfMissing(destination, $"{destinationSection}:valkey:Database", database);
}
SetIfMissing(
destination,
$"{destinationSection}:valkey:ConnectionString",
configuration.GetConnectionString("Redis"));
SetIfMissing(destination, $"{destinationSection}:valkey:ConnectionString", "cache.stella-ops.local:6379");
SetIfMissing(destination, $"{destinationSection}:valkey:Database", "0");
NormalizeDuration(destination, $"{destinationSection}:RequestTimeout", TimeSpan.FromSeconds(30));
NormalizeDuration(destination, $"{destinationSection}:LeaseDuration", TimeSpan.FromMinutes(5));
NormalizeDuration(destination, $"{destinationSection}:HeartbeatInterval", TimeSpan.FromSeconds(10));
}
private static void NormalizeDuration(
IDictionary<string, string?> destination,
string key,
TimeSpan fallback)
{
if (!destination.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw))
{
return;
}
destination[key] = ParseDuration(raw, fallback).ToString("c", CultureInfo.InvariantCulture);
}
private static void SetIfMissing(
IDictionary<string, string?> destination,
string key,
string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
if (!destination.ContainsKey(key) || string.IsNullOrWhiteSpace(destination[key]))
{
destination[key] = value;
}
}
private static RouterTransportPluginLoader CreateTransportPluginLoader(
IConfiguration configuration,
string routerOptionsSection)
{
var loader = new RouterTransportPluginLoader();
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()
.Where(a => a.GetName().Name?.StartsWith(
"StellaOps.Router.Transport.",
StringComparison.OrdinalIgnoreCase) == true))
{
loader.LoadFromAssembly(assembly);
}
var pluginDirectory = ResolveTransportPluginDirectory(configuration, routerOptionsSection);
var searchPattern = configuration[$"{routerOptionsSection}:TransportPlugins:SearchPattern"];
if (string.IsNullOrWhiteSpace(searchPattern))
{
searchPattern = "StellaOps.Router.Transport.*.dll";
}
loader.LoadFromDirectory(pluginDirectory, searchPattern);
return loader;
}
private static string ResolveTransportPluginDirectory(IConfiguration configuration, string routerOptionsSection)
{
var pluginDirectory = configuration[$"{routerOptionsSection}:TransportPlugins:Directory"];
if (!string.IsNullOrWhiteSpace(pluginDirectory))
{
return pluginDirectory;
}
return Path.Combine(AppContext.BaseDirectory, "plugins", "router", "transports");
}
private static string ResolveSourceTransportSection(
TransportType transportType,
string routerOptionsSection,
string messagingOptionsSection)
{
return transportType == TransportType.Messaging
? messagingOptionsSection
: $"{routerOptionsSection}:Transports:{ToTransportConfigurationName(transportType)}";
}
private static string BuildResolvedTransportSection(string transportName) =>
$"Router:ResolvedTransports:{transportName}";
private static string ToTransportPluginName(TransportType transportType)
{
return transportType switch
{
TransportType.InMemory => "inmemory",
TransportType.Udp => "udp",
TransportType.Tcp => "tcp",
TransportType.Certificate => "tls",
TransportType.RabbitMq => "rabbitmq",
TransportType.Messaging => "messaging",
_ => throw new NotSupportedException($"Unsupported transport type: {transportType}")
};
}
private static string ToTransportConfigurationName(TransportType transportType)
{
return transportType switch
{
TransportType.InMemory => "InMemory",
TransportType.Udp => "Udp",
TransportType.Tcp => "Tcp",
TransportType.Certificate => "Tls",
TransportType.RabbitMq => "RabbitMq",
TransportType.Messaging => "Messaging",
_ => throw new NotSupportedException($"Unsupported transport type: {transportType}")
};
}
private static TransportType ParseTransportType(string? value, TransportType fallback)
{
if (string.IsNullOrWhiteSpace(value))
{
return fallback;
}
if (Enum.TryParse<TransportType>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
return value.Trim().ToLowerInvariant() switch
{
"tls" => TransportType.Certificate,
"certificate" => TransportType.Certificate,
"rabbit" or "rabbitmq" => TransportType.RabbitMq,
"messaging" => TransportType.Messaging,
"tcp" => TransportType.Tcp,
"udp" => TransportType.Udp,
"inmemory" => TransportType.InMemory,
_ => fallback
};
}
private static TimeSpan ParseDuration(string? rawValue, TimeSpan fallback)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return fallback;
}
var trimmed = rawValue.Trim();
if (TimeSpan.TryParse(trimmed, CultureInfo.InvariantCulture, out var parsed) && parsed > TimeSpan.Zero)
{
return parsed;
}
if (trimmed.Length < 2)
{
return fallback;
}
var suffix = char.ToLowerInvariant(trimmed[^1]);
var numberPart = trimmed[..^1];
if (!double.TryParse(numberPart, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) || value <= 0)
{
return fallback;
}
return suffix switch
{
's' => TimeSpan.FromSeconds(value),
'm' => TimeSpan.FromMinutes(value),
'h' => TimeSpan.FromHours(value),
'd' => TimeSpan.FromDays(value),
_ => fallback
};
}
private static string NormalizeServiceVersion(string? version)
{
if (string.IsNullOrWhiteSpace(version))
{
return "1.0.0";
}
var trimmed = version.Trim();
if (!Version.TryParse(trimmed, out var parsed))
{
return trimmed;
}
var patch = parsed.Build >= 0 ? parsed.Build : 0;
return $"{parsed.Major}.{parsed.Minor}.{patch}";
}
}

View File

@@ -67,10 +67,29 @@ public sealed class StellaRouterOptions
/// <summary>
/// Behavior when endpoint has no authorization metadata.
/// Default: RequireExplicit (fail if no auth metadata).
/// Default: WarnAndAllow (log warning and allow endpoint).
/// </summary>
public MissingAuthorizationBehavior OnMissingAuthorization { get; set; }
= MissingAuthorizationBehavior.RequireExplicit;
= MissingAuthorizationBehavior.WarnAndAllow;
/// <summary>
/// Service trust mode for gateway-enforced authorization semantics.
/// Default: Hybrid.
/// </summary>
public GatewayAuthorizationTrustMode AuthorizationTrustMode { get; set; }
= GatewayAuthorizationTrustMode.Hybrid;
/// <summary>
/// Shared signing key used to verify gateway identity envelopes.
/// Required when AuthorizationTrustMode is GatewayEnforced.
/// </summary>
public string? IdentityEnvelopeSigningKey { get; set; }
/// <summary>
/// Allowed clock skew in seconds when validating gateway identity envelopes.
/// Default: 30 seconds.
/// </summary>
public int IdentityEnvelopeClockSkewSeconds { get; set; } = 30;
/// <summary>
/// Default timeout for endpoints.
@@ -103,9 +122,14 @@ public sealed class StellaRouterOptions
public IList<string> ExcludedPathPrefixes { get; set; } = new List<string>
{
"/health",
"/healthz",
"/ready",
"/readyz",
"/livez",
"/metrics",
"/swagger",
"/openapi"
"/openapi",
"/.well-known"
};
/// <summary>

View File

@@ -1,4 +1,5 @@
using StellaOps.Router.Common.Enums;
using StellaOps.Microservice.AspNetCore;
namespace StellaOps.Router.AspNet;
@@ -42,6 +43,25 @@ public class StellaRouterOptionsBase
/// </summary>
public int HeartbeatIntervalSeconds { get; set; } = 10;
/// <summary>
/// Service trust mode for gateway-enforced authorization semantics.
/// Default: Hybrid.
/// </summary>
public GatewayAuthorizationTrustMode AuthorizationTrustMode { get; set; }
= GatewayAuthorizationTrustMode.Hybrid;
/// <summary>
/// Shared signing key used to verify gateway identity envelopes.
/// Required when AuthorizationTrustMode is GatewayEnforced.
/// </summary>
public string? IdentityEnvelopeSigningKey { get; set; }
/// <summary>
/// Allowed clock skew in seconds when validating gateway identity envelopes.
/// Default: 30 seconds.
/// </summary>
public int IdentityEnvelopeClockSkewSeconds { get; set; } = 30;
/// <summary>
/// Gateway endpoints to connect to for endpoint registration.
/// </summary>

View File

@@ -19,6 +19,24 @@ public interface IMicroserviceTransport
IReadOnlyList<EndpointDescriptor> endpoints,
CancellationToken cancellationToken);
/// <summary>
/// Connects to the router and registers the microservice, including schemas and OpenAPI metadata.
/// </summary>
/// <param name="instance">The instance descriptor.</param>
/// <param name="endpoints">The endpoints to register.</param>
/// <param name="schemas">Optional schema definitions keyed by schema id.</param>
/// <param name="openApiInfo">Optional OpenAPI service metadata.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task ConnectAsync(
InstanceDescriptor instance,
IReadOnlyList<EndpointDescriptor> endpoints,
IReadOnlyDictionary<string, SchemaDefinition>? schemas,
ServiceOpenApiInfo? openApiInfo,
CancellationToken cancellationToken)
{
return ConnectAsync(instance, endpoints, cancellationToken);
}
/// <summary>
/// Disconnects from the router.
/// </summary>

View File

@@ -0,0 +1,182 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Router.Common.Identity;
/// <summary>
/// Signed gateway-issued identity envelope used for trusted user impersonation
/// when requests are dispatched through the Router gateway.
/// </summary>
public sealed record GatewayIdentityEnvelope
{
/// <summary>
/// Envelope schema version.
/// </summary>
public int Version { get; init; } = 1;
/// <summary>
/// Gateway issuer identifier.
/// </summary>
public string Issuer { get; init; } = "stellaops-gateway";
/// <summary>
/// Subject (authenticated actor).
/// </summary>
public string Subject { get; init; } = "anonymous";
/// <summary>
/// Tenant identifier, when available.
/// </summary>
public string? Tenant { get; init; }
/// <summary>
/// Project identifier, when available.
/// </summary>
public string? Project { get; init; }
/// <summary>
/// Effective scopes enforced by the gateway.
/// </summary>
public IReadOnlyList<string> Scopes { get; init; } = [];
/// <summary>
/// Effective roles enforced by the gateway.
/// </summary>
public IReadOnlyList<string> Roles { get; init; } = [];
/// <summary>
/// Sender confirmation thumbprint (for example DPoP JKT) when available.
/// </summary>
public string? SenderConfirmation { get; init; }
/// <summary>
/// Correlation identifier for audit traceability.
/// </summary>
public string CorrelationId { get; init; } = string.Empty;
/// <summary>
/// Envelope issuance timestamp (UTC).
/// </summary>
public DateTimeOffset IssuedAtUtc { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Envelope expiry timestamp (UTC).
/// </summary>
public DateTimeOffset ExpiresAtUtc { get; init; } = DateTimeOffset.UtcNow.AddMinutes(2);
}
/// <summary>
/// Result of envelope signing operation.
/// </summary>
public readonly record struct GatewayIdentityEnvelopeSignature(
string Payload,
string Signature,
string Algorithm);
/// <summary>
/// Codec for creating and verifying signed identity envelopes.
/// </summary>
public static class GatewayIdentityEnvelopeCodec
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
/// <summary>
/// Algorithm marker used for generated signatures.
/// </summary>
public const string AlgorithmHs256 = "HS256";
/// <summary>
/// Creates a signed identity envelope using HMAC-SHA256.
/// </summary>
public static GatewayIdentityEnvelopeSignature Sign(
GatewayIdentityEnvelope envelope,
string signingKey)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentException.ThrowIfNullOrWhiteSpace(signingKey);
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(envelope, SerializerOptions);
var signatureBytes = ComputeHmac(payloadBytes, signingKey);
return new GatewayIdentityEnvelopeSignature(
Payload: ToBase64Url(payloadBytes),
Signature: ToBase64Url(signatureBytes),
Algorithm: AlgorithmHs256);
}
/// <summary>
/// Tries to verify and decode a signed identity envelope.
/// </summary>
public static bool TryVerify(
string payload,
string signature,
string signingKey,
out GatewayIdentityEnvelope? envelope)
{
envelope = null;
if (string.IsNullOrWhiteSpace(payload) ||
string.IsNullOrWhiteSpace(signature) ||
string.IsNullOrWhiteSpace(signingKey))
{
return false;
}
try
{
var payloadBytes = FromBase64Url(payload);
var signatureBytes = FromBase64Url(signature);
var expectedSignature = ComputeHmac(payloadBytes, signingKey);
if (!CryptographicOperations.FixedTimeEquals(signatureBytes, expectedSignature))
{
return false;
}
envelope = JsonSerializer.Deserialize<GatewayIdentityEnvelope>(payloadBytes, SerializerOptions);
return envelope is not null;
}
catch (FormatException)
{
return false;
}
catch (JsonException)
{
return false;
}
}
private static byte[] ComputeHmac(byte[] payload, string signingKey)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingKey));
return hmac.ComputeHash(payload);
}
private static string ToBase64Url(ReadOnlySpan<byte> bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] FromBase64Url(string value)
{
var padded = value
.Replace('-', '+')
.Replace('_', '/');
var paddingLength = 4 - (padded.Length % 4);
if (paddingLength is > 0 and < 4)
{
padded = padded.PadRight(padded.Length + paddingLength, '=');
}
return Convert.FromBase64String(padded);
}
}

View File

@@ -0,0 +1,59 @@
using StellaOps.Router.Common.Enums;
namespace StellaOps.Router.Common.Models;
/// <summary>
/// Resolves effective authorization semantics for endpoint descriptors.
/// </summary>
public static class EndpointAuthorizationSemantics
{
/// <summary>
/// Resolves whether an endpoint should require an authenticated principal.
/// </summary>
public static bool ResolveRequiresAuthentication(EndpointDescriptor endpoint)
{
ArgumentNullException.ThrowIfNull(endpoint);
return ResolveRequiresAuthentication(
endpoint.AllowAnonymous,
endpoint.RequiresAuthentication,
endpoint.AuthorizationSource,
endpoint.RequiringClaims,
endpoint.AuthorizationPolicies,
endpoint.Roles);
}
/// <summary>
/// Resolves whether an endpoint should require an authenticated principal.
/// </summary>
public static bool ResolveRequiresAuthentication(
bool allowAnonymous,
bool requiresAuthentication,
EndpointAuthorizationSource source,
IReadOnlyList<ClaimRequirement>? requiringClaims = null,
IReadOnlyList<string>? authorizationPolicies = null,
IReadOnlyList<string>? roles = null)
{
if (allowAnonymous)
{
return false;
}
if (requiresAuthentication)
{
return true;
}
if (requiringClaims is { Count: > 0 } ||
authorizationPolicies is { Count: > 0 } ||
roles is { Count: > 0 } ||
source != EndpointAuthorizationSource.None)
{
return true;
}
// Legacy HELLO payloads can omit RequiresAuthentication while still
// operating in authenticated-only mode. Fail closed for compatibility.
return true;
}
}

View File

@@ -0,0 +1,32 @@
namespace StellaOps.Router.Common.Models;
/// <summary>
/// Source of authorization metadata for an endpoint descriptor.
/// </summary>
public enum EndpointAuthorizationSource
{
/// <summary>
/// No authorization metadata found.
/// </summary>
None,
/// <summary>
/// Authorization derived from ASP.NET metadata.
/// </summary>
AspNetMetadata,
/// <summary>
/// Authorization defined by YAML endpoint overrides.
/// </summary>
YamlOverride,
/// <summary>
/// Authorization merged from ASP.NET metadata and YAML overrides.
/// </summary>
Hybrid,
/// <summary>
/// Authorization overridden by Authority.
/// </summary>
AuthorityOverride
}

View File

@@ -35,6 +35,31 @@ public sealed record EndpointDescriptor
/// </summary>
public IReadOnlyList<ClaimRequirement> RequiringClaims { get; init; } = [];
/// <summary>
/// Gets a value indicating whether this endpoint explicitly allows anonymous access.
/// </summary>
public bool AllowAnonymous { get; init; }
/// <summary>
/// Gets a value indicating whether this endpoint requires an authenticated principal.
/// </summary>
public bool RequiresAuthentication { get; init; }
/// <summary>
/// Gets named authorization policies discovered for this endpoint.
/// </summary>
public IReadOnlyList<string> AuthorizationPolicies { get; init; } = [];
/// <summary>
/// Gets role names required by endpoint metadata.
/// </summary>
public IReadOnlyList<string> Roles { get; init; } = [];
/// <summary>
/// Gets the source of the authorization metadata.
/// </summary>
public EndpointAuthorizationSource AuthorizationSource { get; init; } = EndpointAuthorizationSource.None;
/// <summary>
/// Gets a value indicating whether this endpoint supports streaming.
/// </summary>

View File

@@ -17,6 +17,11 @@ public sealed record EndpointSchemaInfo
/// </summary>
public string? ResponseSchemaId { get; init; }
/// <summary>
/// Gets the preferred success HTTP status code for the response schema.
/// </summary>
public int? ResponseStatusCode { get; init; }
/// <summary>
/// Gets the OpenAPI operation summary.
/// </summary>

View File

@@ -41,6 +41,11 @@ public sealed record RoutingContext
/// </summary>
public string? RequestedVersion { get; init; }
/// <summary>
/// Gets the gateway route default timeout selected for this request path.
/// </summary>
public TimeSpan? RouteDefaultTimeout { get; init; }
/// <summary>
/// Gets the cancellation token for the request.
/// </summary>

View File

@@ -1,6 +1,9 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Models;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Router.Gateway.Authorization;
@@ -13,6 +16,8 @@ internal sealed class AuthorityClaimsRefreshService : BackgroundService
private readonly IEffectiveClaimsStore _claimsStore;
private readonly AuthorityConnectionOptions _options;
private readonly ILogger<AuthorityClaimsRefreshService> _logger;
private string? _lastOverridesFingerprint;
private long _refreshGeneration;
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityClaimsRefreshService"/> class.
@@ -113,7 +118,7 @@ internal sealed class AuthorityClaimsRefreshService : BackgroundService
try
{
var overrides = await _claimsProvider.GetOverridesAsync(cancellationToken);
_claimsStore.UpdateFromAuthority(overrides);
ApplyOverrides(overrides, source: "poll");
}
catch (Exception ex)
{
@@ -123,8 +128,7 @@ internal sealed class AuthorityClaimsRefreshService : BackgroundService
private void OnOverridesChanged(object? sender, ClaimsOverrideChangedEventArgs e)
{
_logger.LogInformation("Received claims override update from Authority");
_claimsStore.UpdateFromAuthority(e.Overrides);
ApplyOverrides(e.Overrides, source: "push");
}
/// <inheritdoc />
@@ -137,4 +141,66 @@ internal sealed class AuthorityClaimsRefreshService : BackgroundService
base.Dispose();
}
private void ApplyOverrides(
IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides,
string source)
{
var fingerprint = ComputeOverridesFingerprint(overrides);
var changed = !string.Equals(_lastOverridesFingerprint, fingerprint, StringComparison.Ordinal);
if (!changed)
{
_logger.LogDebug(
"Authority claims refresh unchanged (version={Version}, source={Source}, endpointCount={EndpointCount}, fingerprint={Fingerprint})",
_refreshGeneration,
source,
overrides.Count,
fingerprint);
return;
}
_claimsStore.UpdateFromAuthority(overrides);
_lastOverridesFingerprint = fingerprint;
_refreshGeneration++;
_logger.LogInformation(
"Authority claims refresh applied (version={Version}, source={Source}, endpointCount={EndpointCount}, fingerprint={Fingerprint})",
_refreshGeneration,
source,
overrides.Count,
fingerprint);
}
private static string ComputeOverridesFingerprint(
IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
{
var builder = new StringBuilder();
foreach (var (key, claims) in overrides
.OrderBy(entry => entry.Key.ServiceName, StringComparer.Ordinal)
.ThenBy(entry => entry.Key.Method, StringComparer.Ordinal)
.ThenBy(entry => entry.Key.Path, StringComparer.Ordinal))
{
builder.Append(key.ServiceName)
.Append('|')
.Append(key.Method)
.Append('|')
.Append(key.Path)
.Append('|');
foreach (var claim in claims
.OrderBy(entry => entry.Type, StringComparer.Ordinal)
.ThenBy(entry => entry.Value, StringComparer.Ordinal))
{
builder.Append(claim.Type)
.Append('=')
.Append(claim.Value)
.Append(';');
}
builder.AppendLine();
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
return Convert.ToHexString(hash)[..16];
}
}

View File

@@ -40,6 +40,32 @@ public sealed class AuthorizationMiddleware
return;
}
if (endpoint.AllowAnonymous)
{
await _next(context);
return;
}
var requiresAuthentication = EndpointAuthorizationSemantics.ResolveRequiresAuthentication(endpoint);
var isAuthenticated = context.User.Identity?.IsAuthenticated == true;
if (requiresAuthentication && !isAuthenticated)
{
_logger.LogWarning(
"Authorization failed for {Method} {Path}: unauthenticated principal",
endpoint.Method,
endpoint.Path);
await RouterErrorWriter.WriteAsync(
context,
statusCode: StatusCodes.Status401Unauthorized,
error: "Unauthorized",
message: "Authentication required",
service: endpoint.ServiceName,
version: endpoint.Version,
cancellationToken: context.RequestAborted);
return;
}
// Get effective claims for this endpoint
var effectiveClaims = _claimsStore.GetEffectiveClaims(
endpoint.ServiceName,

View File

@@ -40,10 +40,16 @@ public sealed class RoutingOptions
public bool StrictVersionMatching { get; set; } = true;
/// <summary>
/// Gets or sets the timeout for routing decisions in milliseconds.
/// Gets or sets the gateway route default timeout in milliseconds.
/// </summary>
public int RoutingTimeoutMs { get; set; } = 30000;
/// <summary>
/// Gets or sets the global timeout cap in milliseconds.
/// Effective request timeout cannot exceed this value.
/// </summary>
public int GlobalTimeoutCapMs { get; set; } = 120000;
/// <summary>
/// Gets or sets whether to prefer local region instances over neighbor regions.
/// </summary>

View File

@@ -23,6 +23,12 @@ public sealed class StellaOpsRoute
public Dictionary<string, string> Headers { get; set; } = new();
/// <summary>
/// Optional route-level default timeout (for example "15s", "2m").
/// Applied when endpoint metadata does not provide a timeout.
/// </summary>
public string? DefaultTimeout { get; set; }
/// <summary>
/// When true, the gateway preserves Authorization and DPoP headers instead
/// of stripping them. Use for upstream services that perform their own JWT

View File

@@ -1,4 +1,6 @@
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Gateway.Middleware;
@@ -23,9 +25,27 @@ public sealed class EndpointResolutionMiddleware
public async Task Invoke(HttpContext context, IGlobalRoutingState routingState)
{
var method = context.Request.Method;
var path = context.Request.Path.ToString();
var translatedPath = context.Items.TryGetValue(RouterHttpContextKeys.TranslatedRequestPath, out var translated)
? translated as string
: null;
var path = string.IsNullOrWhiteSpace(translatedPath)
? context.Request.Path.ToString()
: translatedPath;
var targetMicroserviceHint = context.Items.TryGetValue(RouterHttpContextKeys.RouteTargetMicroservice, out var targetService)
? targetService as string
: null;
EndpointDescriptor? endpoint;
if (!string.IsNullOrWhiteSpace(targetMicroserviceHint))
{
endpoint = ResolveEndpointForTargetService(routingState, method, path, targetMicroserviceHint!);
}
else
{
endpoint = routingState.ResolveEndpoint(method, path);
}
var endpoint = routingState.ResolveEndpoint(method, path);
if (endpoint is null)
{
await RouterErrorWriter.WriteAsync(
@@ -41,4 +61,128 @@ public sealed class EndpointResolutionMiddleware
context.Items[RouterHttpContextKeys.TargetEndpointPathTemplate] = endpoint.Path;
await _next(context);
}
private static EndpointDescriptor? ResolveEndpointForTargetService(
IGlobalRoutingState routingState,
string method,
string path,
string targetServiceHint)
{
var normalizedHint = NormalizeServiceKey(targetServiceHint);
if (string.IsNullOrWhiteSpace(normalizedHint))
{
return null;
}
EndpointDescriptor? bestEndpoint = null;
var bestScore = int.MinValue;
foreach (var connection in routingState.GetAllConnections())
{
var serviceScore = GetServiceMatchScore(connection.Instance.ServiceName, normalizedHint);
if (serviceScore < 0)
{
continue;
}
foreach (var endpoint in connection.Endpoints.Values)
{
if (!string.Equals(endpoint.Method, method, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var matcher = new PathMatcher(endpoint.Path);
if (!matcher.IsMatch(path))
{
continue;
}
var endpointScore = (serviceScore * 1000) + endpoint.Path.Length;
if (endpointScore <= bestScore)
{
continue;
}
bestEndpoint = endpoint;
bestScore = endpointScore;
}
}
return bestEndpoint;
}
private static int GetServiceMatchScore(string? serviceName, string normalizedHint)
{
var normalizedService = NormalizeServiceKey(serviceName);
if (string.IsNullOrWhiteSpace(normalizedService))
{
return -1;
}
if (string.Equals(serviceName, normalizedHint, StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalizedService, normalizedHint, StringComparison.OrdinalIgnoreCase))
{
return 5;
}
if (serviceName is not null &&
(serviceName.StartsWith(normalizedHint + "-", StringComparison.OrdinalIgnoreCase) ||
serviceName.StartsWith(normalizedHint + "_", StringComparison.OrdinalIgnoreCase)))
{
return 4;
}
if (normalizedService.StartsWith(normalizedHint, StringComparison.OrdinalIgnoreCase) ||
normalizedHint.StartsWith(normalizedService, StringComparison.OrdinalIgnoreCase))
{
return 3;
}
if (normalizedService.Contains(normalizedHint, StringComparison.OrdinalIgnoreCase) ||
normalizedHint.Contains(normalizedService, StringComparison.OrdinalIgnoreCase))
{
return 2;
}
return -1;
}
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];
}
normalized = StripSuffix(normalized, "-web");
normalized = StripSuffix(normalized, "-api");
normalized = StripSuffix(normalized, "-service");
normalized = StripSuffix(normalized, "-gateway");
return string.IsNullOrWhiteSpace(normalized)
? null
: normalized;
}
private static string StripSuffix(string value, string suffix)
{
return value.EndsWith(suffix, StringComparison.Ordinal)
? value[..^suffix.Length]
: value;
}
}

View File

@@ -60,6 +60,10 @@ public sealed class RoutingDecisionMiddleware
AvailableConnections = availableConnections,
GatewayRegion = gatewayConfig.Value.Region,
RequestedVersion = ExtractVersionFromRequest(context, routingOptions.Value),
RouteDefaultTimeout = context.Items.TryGetValue(RouterHttpContextKeys.RouteDefaultTimeout, out var routeTimeoutObj) &&
routeTimeoutObj is TimeSpan routeTimeout
? routeTimeout
: null,
CancellationToken = context.RequestAborted
};

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Hosting;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
@@ -14,6 +15,7 @@ public sealed class TransportDispatchMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TransportDispatchMiddleware> _logger;
private readonly IHostEnvironment _environment;
/// <summary>
/// Tracks cancelled request IDs to ignore late responses.
@@ -24,10 +26,11 @@ public sealed class TransportDispatchMiddleware
/// <summary>
/// Initializes a new instance of the <see cref="TransportDispatchMiddleware"/> class.
/// </summary>
public TransportDispatchMiddleware(RequestDelegate next, ILogger<TransportDispatchMiddleware> logger)
public TransportDispatchMiddleware(RequestDelegate next, ILogger<TransportDispatchMiddleware> logger, IHostEnvironment environment)
{
_next = next;
_logger = logger;
_environment = environment;
// Start background cleanup task for expired cancelled request entries
_ = Task.Run(CleanupExpiredCancelledRequestsAsync);
@@ -104,12 +107,19 @@ public sealed class TransportDispatchMiddleware
}
// Build request frame
var translatedPath = context.Items.TryGetValue(RouterHttpContextKeys.TranslatedRequestPath, out var translated)
? translated as string
: null;
var dispatchPath = string.IsNullOrWhiteSpace(translatedPath)
? context.Request.Path.ToString()
: translatedPath;
var requestFrame = new RequestFrame
{
RequestId = requestId,
CorrelationId = context.TraceIdentifier,
Method = context.Request.Method,
Path = context.Request.Path.ToString() + context.Request.QueryString.ToString(),
Path = dispatchPath + context.Request.QueryString.ToString(),
Headers = headers,
Payload = bodyBytes,
TimeoutSeconds = (int)decision.EffectiveTimeout.TotalSeconds,
@@ -231,7 +241,7 @@ public sealed class TransportDispatchMiddleware
context,
statusCode: StatusCodes.Status502BadGateway,
error: "Upstream error",
message: ex.Message,
message: _environment.IsDevelopment() ? ex.Message : null,
cancellationToken: context.RequestAborted);
return;
}
@@ -326,12 +336,19 @@ public sealed class TransportDispatchMiddleware
var requestIdGuid = Guid.TryParse(requestId, out var parsed) ? parsed : Guid.NewGuid();
// Build request header frame (without body - will stream)
var translatedPath = context.Items.TryGetValue(RouterHttpContextKeys.TranslatedRequestPath, out var translated)
? translated as string
: null;
var dispatchPath = string.IsNullOrWhiteSpace(translatedPath)
? context.Request.Path.ToString()
: translatedPath;
var requestFrame = new RequestFrame
{
RequestId = requestId,
CorrelationId = context.TraceIdentifier,
Method = context.Request.Method,
Path = context.Request.Path.ToString() + context.Request.QueryString.ToString(),
Path = dispatchPath + context.Request.QueryString.ToString(),
Headers = headers,
Payload = Array.Empty<byte>(), // Empty - body will be streamed
TimeoutSeconds = (int)decision.EffectiveTimeout.TotalSeconds,
@@ -467,7 +484,7 @@ public sealed class TransportDispatchMiddleware
context,
statusCode: StatusCodes.Status502BadGateway,
error: "Upstream streaming error",
message: ex.Message,
message: _environment.IsDevelopment() ? ex.Message : null,
cancellationToken: context.RequestAborted);
}
}

View File

@@ -9,6 +9,8 @@ namespace StellaOps.Router.Gateway.OpenApi;
/// </summary>
internal static class ClaimSecurityMapper
{
private static readonly string[] ScopeClaimTypes = ["scope", "scp"];
/// <summary>
/// Generates security schemes from claim requirements.
/// </summary>
@@ -27,20 +29,16 @@ internal static class ClaimSecurityMapper
["type"] = "http",
["scheme"] = "bearer",
["bearerFormat"] = "JWT",
["description"] = "JWT Bearer token authentication"
["description"] = "JWT bearer token authentication enforced by Stella gateway."
};
// Collect all unique scopes from claims
var scopes = new Dictionary<string, string>();
// Collect all unique OAuth2 scopes from scope/scp claim requirements.
var scopes = new SortedSet<string>(StringComparer.Ordinal);
foreach (var endpoint in endpoints)
{
foreach (var claim in endpoint.RequiringClaims)
foreach (var scope in ExtractOAuthScopes(endpoint))
{
var scope = claim.Type;
if (!scopes.ContainsKey(scope))
{
scopes[scope] = $"Access scope: {scope}";
}
scopes.Add(scope);
}
}
@@ -48,9 +46,9 @@ internal static class ClaimSecurityMapper
if (scopes.Count > 0)
{
var scopesObject = new JsonObject();
foreach (var (scope, description) in scopes)
foreach (var scope in scopes)
{
scopesObject[scope] = description;
scopesObject[scope] = $"Access scope: {scope}";
}
schemes["OAuth2"] = new JsonObject
@@ -76,10 +74,29 @@ internal static class ClaimSecurityMapper
/// <param name="endpoint">The endpoint descriptor.</param>
/// <returns>Security requirement JSON array.</returns>
public static JsonArray GenerateSecurityRequirement(EndpointDescriptor endpoint)
{
return GenerateSecurityRequirement(
endpoint.AllowAnonymous,
endpoint.RequiresAuthentication,
endpoint.RequiringClaims);
}
/// <summary>
/// Generates security requirement from effective authorization semantics.
/// </summary>
public static JsonArray GenerateSecurityRequirement(
bool allowAnonymous,
bool requiresAuthentication,
IReadOnlyList<ClaimRequirement> requiringClaims)
{
var requirements = new JsonArray();
if (endpoint.RequiringClaims.Count == 0)
if (allowAnonymous)
{
return requirements;
}
if (!requiresAuthentication && requiringClaims.Count == 0)
{
return requirements;
}
@@ -91,9 +108,9 @@ internal static class ClaimSecurityMapper
// Add OAuth2 scopes
var scopes = new JsonArray();
foreach (var claim in endpoint.RequiringClaims)
foreach (var scope in ExtractOAuthScopes(requiringClaims))
{
scopes.Add(claim.Type);
scopes.Add(scope);
}
if (scopes.Count > 0)
@@ -104,4 +121,86 @@ internal static class ClaimSecurityMapper
requirements.Add(requirement);
return requirements;
}
/// <summary>
/// Builds operation-level extension describing gateway-enforced authorization semantics.
/// </summary>
public static JsonObject BuildGatewayAuthorizationExtension(
EndpointDescriptor endpoint,
IReadOnlyList<ClaimRequirement>? effectiveClaimRequirements = null,
bool authorityOverrideApplied = false,
bool? requiresAuthenticationOverride = null)
{
var claimRequirements = effectiveClaimRequirements ?? endpoint.RequiringClaims;
var requiresAuthentication = requiresAuthenticationOverride ??
EndpointAuthorizationSemantics.ResolveRequiresAuthentication(endpoint);
var extension = new JsonObject
{
["allowAnonymous"] = endpoint.AllowAnonymous,
["requiresAuthentication"] = requiresAuthentication,
["source"] = endpoint.AuthorizationSource.ToString(),
["effectiveClaimSource"] = authorityOverrideApplied ? "AuthorityOverride" : "ServiceMetadata"
};
if (endpoint.AuthorizationPolicies.Count > 0)
{
var policies = new JsonArray();
foreach (var policy in endpoint.AuthorizationPolicies.OrderBy(value => value, StringComparer.Ordinal))
{
policies.Add(policy);
}
extension["policies"] = policies;
}
if (endpoint.Roles.Count > 0)
{
var roles = new JsonArray();
foreach (var role in endpoint.Roles.OrderBy(value => value, StringComparer.Ordinal))
{
roles.Add(role);
}
extension["roles"] = roles;
}
if (claimRequirements.Count > 0)
{
var claims = new JsonArray();
foreach (var claim in claimRequirements
.OrderBy(value => value.Type, StringComparer.Ordinal)
.ThenBy(value => value.Value, StringComparer.Ordinal))
{
claims.Add(new JsonObject
{
["type"] = claim.Type,
["value"] = claim.Value
});
}
extension["claimRequirements"] = claims;
}
return extension;
}
private static IReadOnlyList<string> ExtractOAuthScopes(EndpointDescriptor endpoint)
{
return ExtractOAuthScopes(endpoint.RequiringClaims);
}
private static IReadOnlyList<string> ExtractOAuthScopes(
IReadOnlyList<ClaimRequirement> requiringClaims)
{
var scopes = requiringClaims
.Where(claim =>
ScopeClaimTypes.Contains(claim.Type, StringComparer.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(claim.Value))
.Select(claim => claim.Value!)
.Distinct(StringComparer.Ordinal)
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray();
return scopes;
}
}

View File

@@ -0,0 +1,24 @@
using StellaOps.Router.Gateway.Configuration;
namespace StellaOps.Router.Gateway.OpenApi;
/// <summary>
/// Provides the configured gateway route table for OpenAPI path projection.
/// </summary>
public sealed class GatewayRouteCatalog
{
/// <summary>
/// Initializes a new instance of the <see cref="GatewayRouteCatalog"/> class.
/// </summary>
/// <param name="routes">Configured gateway routes.</param>
public GatewayRouteCatalog(IEnumerable<StellaOpsRoute> routes)
{
ArgumentNullException.ThrowIfNull(routes);
Routes = routes.ToArray();
}
/// <summary>
/// Gets the configured gateway routes.
/// </summary>
public IReadOnlyList<StellaOpsRoute> Routes { get; }
}

View File

@@ -2,6 +2,9 @@
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Authorization;
using StellaOps.Router.Gateway.Configuration;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -14,17 +17,41 @@ public sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
{
private readonly IGlobalRoutingState _routingState;
private readonly OpenApiAggregationOptions _options;
private readonly RoutingOptions _routingOptions;
private readonly IReadOnlyList<RoutePathMapping> _routePathMappings;
private readonly IEffectiveClaimsStore? _effectiveClaimsStore;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
private static readonly HashSet<string> GenericServiceKeys = new(StringComparer.Ordinal)
{
"api",
"connect",
"jwks",
"wellknown",
"console",
"envsettingsjson"
};
private sealed record RoutePathMapping(
string ExternalPathPrefix,
string InternalPathPrefix,
string? TargetServiceKey,
bool AllowServicePrefixMatch);
public OpenApiDocumentGenerator(
IGlobalRoutingState routingState,
IOptions<OpenApiAggregationOptions> options)
IOptions<OpenApiAggregationOptions> options,
GatewayRouteCatalog? routeCatalog = null,
IOptions<RoutingOptions>? routingOptions = null,
IEffectiveClaimsStore? effectiveClaimsStore = null)
{
_routingState = routingState;
_options = options.Value;
_routingOptions = routingOptions?.Value ?? new RoutingOptions();
_routePathMappings = BuildRoutePathMappings(routeCatalog);
_effectiveClaimsStore = effectiveClaimsStore;
}
/// <inheritdoc />
@@ -86,29 +113,33 @@ public sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
var paths = new JsonObject();
// Group endpoints by path
var pathGroups = new Dictionary<string, List<(ConnectionState Conn, EndpointDescriptor Endpoint)>>();
var pathGroups = new Dictionary<string, Dictionary<string, (ConnectionState Conn, EndpointDescriptor Endpoint)>>(StringComparer.OrdinalIgnoreCase);
foreach (var conn in connections)
{
foreach (var endpoint in conn.Endpoints.Values)
{
if (!pathGroups.TryGetValue(endpoint.Path, out var list))
foreach (var projectedPath in ProjectGatewayPaths(conn.Instance.ServiceName, endpoint.Path))
{
list = [];
pathGroups[endpoint.Path] = list;
if (!pathGroups.TryGetValue(projectedPath, out var operationsByMethod))
{
operationsByMethod = new Dictionary<string, (ConnectionState, EndpointDescriptor)>(StringComparer.OrdinalIgnoreCase);
pathGroups[projectedPath] = operationsByMethod;
}
operationsByMethod[endpoint.Method] = (conn, endpoint);
}
list.Add((conn, endpoint));
}
}
// Generate path items
foreach (var (path, endpoints) in pathGroups.OrderBy(p => p.Key))
foreach (var (path, endpoints) in pathGroups.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
{
var pathItem = new JsonObject();
foreach (var (conn, endpoint) in endpoints)
foreach (var (_, (conn, endpoint)) in endpoints.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
{
var operation = GenerateOperation(conn, endpoint);
var operation = GenerateOperation(conn, endpoint, path);
var method = endpoint.Method.ToLowerInvariant();
pathItem[method] = operation;
}
@@ -119,11 +150,17 @@ public sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
return paths;
}
private JsonObject GenerateOperation(ConnectionState conn, EndpointDescriptor endpoint)
private JsonObject GenerateOperation(ConnectionState conn, EndpointDescriptor endpoint, string gatewayPath)
{
var effectiveClaimRequirements = ResolveEffectiveClaimRequirements(endpoint);
var authorityOverrideApplied = !AreClaimSetsEquivalent(
endpoint.RequiringClaims,
effectiveClaimRequirements);
var requiresAuthentication = EndpointAuthorizationSemantics.ResolveRequiresAuthentication(endpoint);
var operation = new JsonObject
{
["operationId"] = $"{conn.Instance.ServiceName}_{endpoint.Path.Replace("/", "_").Trim('_')}_{endpoint.Method}",
["operationId"] = $"{conn.Instance.ServiceName}_{gatewayPath.Replace("/", "_").Trim('_')}_{endpoint.Method}",
["tags"] = new JsonArray { conn.Instance.ServiceName }
};
@@ -149,13 +186,39 @@ public sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
}
}
if (operation["summary"] is null)
{
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);
var security = ClaimSecurityMapper.GenerateSecurityRequirement(
endpoint.AllowAnonymous,
requiresAuthentication,
effectiveClaimRequirements);
if (security.Count > 0)
{
operation["security"] = security;
}
operation["x-stellaops-gateway-auth"] = ClaimSecurityMapper.BuildGatewayAuthorizationExtension(
endpoint,
effectiveClaimRequirements,
authorityOverrideApplied,
requiresAuthentication);
var timeoutExtension = BuildTimeoutExtension(endpoint);
operation["x-stellaops-timeout"] = timeoutExtension;
operation["x-stellaops-timeout-seconds"] =
timeoutExtension["effectiveSeconds"]?.GetValue<int>() ??
Math.Max(1, (int)Math.Ceiling(endpoint.DefaultTimeout.TotalSeconds));
// Add request body if schema exists
if (endpoint.SchemaInfo?.RequestSchemaId is not null)
{
@@ -200,13 +263,30 @@ public sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
};
}
responses["200"] = successResponse;
var successStatusCode = "200";
if (endpoint.SchemaInfo?.ResponseSchemaId is not null &&
endpoint.SchemaInfo.ResponseStatusCode is > 0)
{
successStatusCode = endpoint.SchemaInfo.ResponseStatusCode.Value.ToString();
}
responses[successStatusCode] = successResponse;
// Error responses
responses["400"] = new JsonObject { ["description"] = "Bad Request" };
responses["401"] = new JsonObject { ["description"] = "Unauthorized" };
if (!endpoint.AllowAnonymous)
{
responses["401"] = new JsonObject { ["description"] = "Unauthorized" };
}
if (effectiveClaimRequirements.Count > 0 || endpoint.Roles.Count > 0)
{
responses["403"] = new JsonObject { ["description"] = "Forbidden" };
}
responses["404"] = new JsonObject { ["description"] = "Not Found" };
responses["422"] = new JsonObject { ["description"] = "Validation Error" };
responses["504"] = new JsonObject { ["description"] = "Gateway timeout" };
responses["500"] = new JsonObject { ["description"] = "Internal Server Error" };
operation["responses"] = responses;
@@ -214,6 +294,316 @@ public sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
return operation;
}
private IReadOnlyList<string> ProjectGatewayPaths(string serviceName, string endpointPath)
{
var normalizedEndpointPath = NormalizePath(endpointPath);
var normalizedServiceKey = NormalizeServiceKey(serviceName);
var projectedPaths = new List<string>();
foreach (var mapping in _routePathMappings)
{
if (!ShouldProjectForService(mapping, normalizedServiceKey))
{
continue;
}
var externalPrefix = mapping.ExternalPathPrefix;
var internalPrefix = mapping.InternalPathPrefix;
if (normalizedEndpointPath.Equals(internalPrefix, StringComparison.OrdinalIgnoreCase))
{
projectedPaths.Add(externalPrefix);
continue;
}
if (internalPrefix.Length > 1 &&
normalizedEndpointPath.StartsWith(internalPrefix + "/", StringComparison.OrdinalIgnoreCase))
{
var suffix = normalizedEndpointPath[internalPrefix.Length..];
projectedPaths.Add(NormalizePath(externalPrefix + suffix));
continue;
}
if (internalPrefix == "/" && normalizedEndpointPath.StartsWith("/", StringComparison.Ordinal))
{
projectedPaths.Add(NormalizePath(externalPrefix + normalizedEndpointPath));
}
}
if (projectedPaths.Count == 0)
{
if (_routePathMappings.Count == 0)
{
projectedPaths.Add(normalizedEndpointPath);
}
else
{
return [];
}
}
return projectedPaths
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static IReadOnlyList<RoutePathMapping> BuildRoutePathMappings(
GatewayRouteCatalog? routeCatalog)
{
if (routeCatalog is null || routeCatalog.Routes.Count == 0)
{
return [];
}
return routeCatalog.Routes
.Where(route => route.Type == StellaOpsRouteType.Microservice)
.Select(route =>
{
var externalPathPrefix = NormalizePath(route.Path);
var internalPathPrefix = ResolveInternalPathPrefix(route);
var targetServiceKey = ResolveTargetServiceKey(route, internalPathPrefix);
return new RoutePathMapping(
externalPathPrefix,
internalPathPrefix,
targetServiceKey,
AllowServicePrefixMatch: internalPathPrefix != "/" &&
!string.IsNullOrWhiteSpace(targetServiceKey));
})
.Distinct()
.OrderBy(mapping => mapping.ExternalPathPrefix, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static bool ShouldProjectForService(RoutePathMapping mapping, string? normalizedServiceKey)
{
if (string.IsNullOrWhiteSpace(mapping.TargetServiceKey))
{
return true;
}
if (string.IsNullOrWhiteSpace(normalizedServiceKey))
{
return false;
}
if (string.Equals(mapping.TargetServiceKey, normalizedServiceKey, StringComparison.Ordinal))
{
return true;
}
return mapping.AllowServicePrefixMatch &&
normalizedServiceKey.StartsWith(mapping.TargetServiceKey, StringComparison.Ordinal);
}
private static string ResolveInternalPathPrefix(StellaOpsRoute route)
{
if (string.IsNullOrWhiteSpace(route.TranslatesTo))
{
return NormalizePath(route.Path);
}
if (Uri.TryCreate(route.TranslatesTo, UriKind.Absolute, out var uri))
{
return NormalizePath(uri.AbsolutePath);
}
if (Uri.TryCreate(route.TranslatesTo, UriKind.Relative, out _))
{
return NormalizePath(route.TranslatesTo);
}
return NormalizePath(route.Path);
}
private static string? ResolveTargetServiceKey(StellaOpsRoute route, string internalPathPrefix)
{
var pathKey = ExtractServiceKeyFromPath(route.Path);
var hostKey = ExtractServiceKeyFromHost(route.TranslatesTo);
if (internalPathPrefix == "/" && !IsGenericServiceKey(pathKey))
{
return pathKey;
}
if (!IsGenericServiceKey(hostKey))
{
return hostKey;
}
if (!IsGenericServiceKey(pathKey))
{
return pathKey;
}
return null;
}
private static string? ExtractServiceKeyFromHost(string? translatesTo)
{
if (string.IsNullOrWhiteSpace(translatesTo) ||
!Uri.TryCreate(translatesTo, UriKind.Absolute, out var uri))
{
return null;
}
var host = uri.Host;
if (string.IsNullOrWhiteSpace(host))
{
return null;
}
var firstLabel = host.Split('.', StringSplitOptions.RemoveEmptyEntries)[0];
return NormalizeServiceKey(firstLabel);
}
private static string? ExtractServiceKeyFromPath(string? routePath)
{
var normalizedPath = NormalizePath(routePath);
var segments = normalizedPath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length == 0)
{
return null;
}
if (segments[0].Equals("api", StringComparison.OrdinalIgnoreCase))
{
var serviceSegmentIndex = 1;
if (segments.Length > 1 && IsApiVersionSegment(segments[1]))
{
serviceSegmentIndex = 2;
}
if (segments.Length > serviceSegmentIndex)
{
return NormalizeServiceKey(segments[serviceSegmentIndex]);
}
}
return NormalizeServiceKey(segments[0]);
}
private static bool IsApiVersionSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment) || segment.Length < 2)
{
return false;
}
if (char.ToLowerInvariant(segment[0]) != 'v')
{
return false;
}
for (var i = 1; i < segment.Length; i++)
{
if (!char.IsDigit(segment[i]))
{
return false;
}
}
return true;
}
private static bool IsGenericServiceKey(string? key)
{
return string.IsNullOrWhiteSpace(key) || GenericServiceKeys.Contains(key);
}
private static string? NormalizeServiceKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var builder = new StringBuilder(value.Length);
foreach (var character in value)
{
if (char.IsLetterOrDigit(character))
{
builder.Append(char.ToLowerInvariant(character));
}
}
return builder.Length == 0 ? null : builder.ToString();
}
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 JsonObject BuildTimeoutExtension(EndpointDescriptor endpoint)
{
var endpointTimeout = NormalizeTimeout(endpoint.DefaultTimeout);
var gatewayRouteDefaultTimeout = NormalizeTimeout(TimeSpan.FromMilliseconds(_routingOptions.RoutingTimeoutMs));
var gatewayGlobalCap = NormalizeTimeout(TimeSpan.FromMilliseconds(_routingOptions.GlobalTimeoutCapMs));
var candidate = endpointTimeout ?? gatewayRouteDefaultTimeout ?? TimeSpan.FromSeconds(30);
var source = endpointTimeout is not null ? "endpoint" : "gatewayRouteDefault";
if (gatewayGlobalCap is not null && candidate > gatewayGlobalCap.Value)
{
candidate = gatewayGlobalCap.Value;
source += "Capped";
}
var extension = new JsonObject
{
["effectiveSeconds"] = (int)Math.Ceiling(candidate.TotalSeconds),
["source"] = source,
["precedence"] = new JsonArray(
"endpointOverride",
"serviceDefault",
"gatewayRouteDefault",
"gatewayGlobalCap")
};
if (endpointTimeout is not null)
{
extension["endpointSeconds"] = (int)Math.Ceiling(endpointTimeout.Value.TotalSeconds);
}
if (gatewayRouteDefaultTimeout is not null)
{
extension["gatewayRouteDefaultSeconds"] = (int)Math.Ceiling(gatewayRouteDefaultTimeout.Value.TotalSeconds);
}
if (gatewayGlobalCap is not null)
{
extension["gatewayGlobalCapSeconds"] = (int)Math.Ceiling(gatewayGlobalCap.Value.TotalSeconds);
}
return extension;
}
private static TimeSpan? NormalizeTimeout(TimeSpan value)
{
if (value <= TimeSpan.Zero)
{
return null;
}
var roundedSeconds = Math.Max(1, (int)Math.Ceiling(value.TotalSeconds));
return TimeSpan.FromSeconds(roundedSeconds);
}
private JsonObject GenerateComponents(IReadOnlyList<ConnectionState> connections)
{
var components = new JsonObject();
@@ -246,7 +636,12 @@ public sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
}
// Generate security schemes
var allEndpoints = connections.SelectMany(c => c.Endpoints.Values);
var allEndpoints = connections
.SelectMany(connection => connection.Endpoints.Values)
.Select(endpoint => endpoint with
{
RequiringClaims = ResolveEffectiveClaimRequirements(endpoint)
});
var securitySchemes = ClaimSecurityMapper.GenerateSecuritySchemes(allEndpoints, _options.TokenUrl);
if (securitySchemes.Count > 0)
{
@@ -283,4 +678,56 @@ public sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
return tags;
}
private IReadOnlyList<ClaimRequirement> ResolveEffectiveClaimRequirements(EndpointDescriptor endpoint)
{
if (_effectiveClaimsStore is null)
{
return endpoint.RequiringClaims;
}
return _effectiveClaimsStore.GetEffectiveClaims(
endpoint.ServiceName,
endpoint.Method,
endpoint.Path);
}
private static bool AreClaimSetsEquivalent(
IReadOnlyList<ClaimRequirement> left,
IReadOnlyList<ClaimRequirement> right)
{
if (left.Count != right.Count)
{
return false;
}
if (left.Count == 0)
{
return true;
}
var leftFingerprints = left
.Select(BuildClaimFingerprint)
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray();
var rightFingerprints = right
.Select(BuildClaimFingerprint)
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray();
for (var i = 0; i < leftFingerprints.Length; i++)
{
if (!string.Equals(leftFingerprints[i], rightFingerprints[i], StringComparison.Ordinal))
{
return false;
}
}
return true;
}
private static string BuildClaimFingerprint(ClaimRequirement claim)
{
return $"{claim.Type}\u001f{claim.Value}";
}
}

View File

@@ -5,6 +5,11 @@ namespace StellaOps.Router.Gateway;
/// </summary>
public static class RouterHttpContextKeys
{
/// <summary>
/// Key for the translated request path resolved from Gateway route translation.
/// </summary>
public const string TranslatedRequestPath = "Stella.TranslatedRequestPath";
/// <summary>
/// Key for the resolved <see cref="StellaOps.Router.Common.Models.EndpointDescriptor"/>.
/// </summary>
@@ -25,8 +30,18 @@ public static class RouterHttpContextKeys
/// </summary>
public const string TargetMicroservice = "Stella.TargetMicroservice";
/// <summary>
/// Key for the route-level target microservice hint inferred from the gateway route entry.
/// </summary>
public const string RouteTargetMicroservice = "Stella.RouteTargetMicroservice";
/// <summary>
/// Key for the resolved endpoint path template (EndpointDescriptor.Path).
/// </summary>
public const string TargetEndpointPathTemplate = "Stella.TargetEndpointPathTemplate";
/// <summary>
/// Key for the route-level default timeout (TimeSpan) resolved from Gateway route table.
/// </summary>
public const string RouteDefaultTimeout = "Stella.RouteDefaultTimeout";
}

View File

@@ -87,17 +87,52 @@ internal sealed class DefaultRoutingPlugin : IRoutingPlugin
return Task.FromResult<RoutingDecision?>(null);
}
var effectiveTimeout = ResolveEffectiveTimeout(endpoint, context);
var decision = new RoutingDecision
{
Endpoint = endpoint,
Connection = selected,
TransportType = selected.TransportType,
EffectiveTimeout = TimeSpan.FromMilliseconds(_options.RoutingTimeoutMs)
EffectiveTimeout = effectiveTimeout
};
return Task.FromResult<RoutingDecision?>(decision);
}
private TimeSpan ResolveEffectiveTimeout(EndpointDescriptor endpoint, RoutingContext context)
{
var endpointTimeout = NormalizeTimeout(endpoint.DefaultTimeout);
var routeDefaultTimeout = context.RouteDefaultTimeout.HasValue
? NormalizeTimeout(context.RouteDefaultTimeout.Value)
: null;
var gatewayRouteDefaultTimeout = NormalizeTimeout(TimeSpan.FromMilliseconds(_options.RoutingTimeoutMs));
var gatewayGlobalCap = NormalizeTimeout(TimeSpan.FromMilliseconds(_options.GlobalTimeoutCapMs));
var resolved = endpointTimeout ??
routeDefaultTimeout ??
gatewayRouteDefaultTimeout ??
TimeSpan.FromSeconds(30);
if (gatewayGlobalCap.HasValue && resolved > gatewayGlobalCap.Value)
{
resolved = gatewayGlobalCap.Value;
}
return resolved;
}
private static TimeSpan? NormalizeTimeout(TimeSpan value)
{
if (value <= TimeSpan.Zero)
{
return null;
}
var roundedSeconds = Math.Max(1, (int)Math.Ceiling(value.TotalSeconds));
return TimeSpan.FromSeconds(roundedSeconds);
}
private List<ConnectionState> FilterByVersion(
List<ConnectionState> candidates,
string? requestedVersion)

View File

@@ -66,6 +66,17 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
InstanceDescriptor instance,
IReadOnlyList<EndpointDescriptor> endpoints,
CancellationToken cancellationToken)
{
await ConnectAsync(instance, endpoints, schemas: null, openApiInfo: null, cancellationToken);
}
/// <inheritdoc />
public async Task ConnectAsync(
InstanceDescriptor instance,
IReadOnlyList<EndpointDescriptor> endpoints,
IReadOnlyDictionary<string, SchemaDefinition>? schemas,
ServiceOpenApiInfo? openApiInfo,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -75,7 +86,8 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
// Create request queue (for sending to gateway)
_requestQueue = _queueFactory.Create<RpcRequestMessage>(new MessageQueueOptions
{
QueueName = _options.GetRequestQueueName("gateway"),
QueueName = _options.GetGatewayControlQueueName(),
ConsumerGroup = _options.ConsumerGroup,
ConsumerName = $"{instance.ServiceName}-{instance.InstanceId}"
});
@@ -83,6 +95,7 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
_responseQueue = _queueFactory.Create<RpcResponseMessage>(new MessageQueueOptions
{
QueueName = _options.ResponseQueueName,
ConsumerGroup = _options.ConsumerGroup,
ConsumerName = $"{instance.ServiceName}-{instance.InstanceId}"
});
@@ -90,6 +103,7 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
_serviceIncomingQueue = _queueFactory.Create<RpcResponseMessage>(new MessageQueueOptions
{
QueueName = _options.GetRequestQueueName(instance.ServiceName),
ConsumerGroup = _options.ConsumerGroup,
ConsumerName = $"{instance.ServiceName}-{instance.InstanceId}",
DefaultLeaseDuration = _options.LeaseDuration
});
@@ -98,7 +112,9 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
var helloPayload = new HelloPayload
{
Instance = instance,
Endpoints = endpoints
Endpoints = endpoints,
Schemas = schemas ?? new Dictionary<string, SchemaDefinition>(),
OpenApiInfo = openApiInfo
};
var helloMessage = new RpcRequestMessage

View File

@@ -0,0 +1,134 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Messaging.DependencyInjection;
using StellaOps.Router.Common.Plugins;
using StellaOps.Router.Transport.Messaging.Options;
using System.Globalization;
namespace StellaOps.Router.Transport.Messaging;
/// <summary>
/// Plugin implementation for messaging transport.
/// </summary>
public sealed class MessagingTransportPlugin : IRouterTransportPlugin
{
/// <inheritdoc />
public string TransportName => "messaging";
/// <inheritdoc />
public string DisplayName => "Messaging Transport";
/// <inheritdoc />
public bool IsAvailable(IServiceProvider services) => true;
/// <inheritdoc />
public void Register(RouterTransportRegistrationContext context)
{
var services = context.Services;
var configuration = context.Configuration;
var configSectionPath = context.ConfigurationSection ?? "Router:Transport:Messaging";
var configSection = configuration.GetSection(configSectionPath);
services.AddMessagingPlugins(configuration, options =>
{
options.ConfigurationSection = configSectionPath;
options.RequireTransport = true;
var pluginDirectory = configSection["PluginDirectory"];
if (!string.IsNullOrWhiteSpace(pluginDirectory))
{
options.PluginDirectory = pluginDirectory;
}
var searchPattern = configSection["SearchPattern"];
if (!string.IsNullOrWhiteSpace(searchPattern))
{
options.SearchPattern = searchPattern;
}
});
services.AddOptions<MessagingTransportOptions>()
.Configure(options =>
{
if (!string.IsNullOrWhiteSpace(configSection["RequestQueueTemplate"]))
{
options.RequestQueueTemplate = configSection["RequestQueueTemplate"]!;
}
if (!string.IsNullOrWhiteSpace(configSection["GatewayControlQueueServiceName"]))
{
options.GatewayControlQueueServiceName = configSection["GatewayControlQueueServiceName"]!;
}
if (!string.IsNullOrWhiteSpace(configSection["ResponseQueueName"]))
{
options.ResponseQueueName = configSection["ResponseQueueName"]!;
}
if (!string.IsNullOrWhiteSpace(configSection["ConsumerGroup"]))
{
options.ConsumerGroup = configSection["ConsumerGroup"]!;
}
if (int.TryParse(configSection["BatchSize"], NumberStyles.Integer, CultureInfo.InvariantCulture, out var batchSize)
&& batchSize > 0)
{
options.BatchSize = batchSize;
}
options.RequestTimeout = ParseDuration(configSection["RequestTimeout"], options.RequestTimeout);
options.LeaseDuration = ParseDuration(configSection["LeaseDuration"], options.LeaseDuration);
options.HeartbeatInterval = ParseDuration(configSection["HeartbeatInterval"], options.HeartbeatInterval);
})
.Validate(opts => !string.IsNullOrWhiteSpace(opts.RequestQueueTemplate), "RequestQueueTemplate is required.")
.Validate(opts => !string.IsNullOrWhiteSpace(opts.GatewayControlQueueServiceName), "GatewayControlQueueServiceName is required.")
.Validate(opts => !string.IsNullOrWhiteSpace(opts.ResponseQueueName), "ResponseQueueName is required.")
.ValidateOnStart();
if (context.Mode.HasFlag(RouterTransportMode.Server))
{
services.AddMessagingTransportServer();
}
if (context.Mode.HasFlag(RouterTransportMode.Client))
{
services.AddMessagingTransportClient();
}
}
private static TimeSpan ParseDuration(string? rawValue, TimeSpan fallback)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return fallback;
}
var trimmed = rawValue.Trim();
if (TimeSpan.TryParse(trimmed, CultureInfo.InvariantCulture, out var parsed) && parsed > TimeSpan.Zero)
{
return parsed;
}
if (trimmed.Length < 2)
{
return fallback;
}
var suffix = char.ToLowerInvariant(trimmed[^1]);
var numberPart = trimmed[..^1];
if (!double.TryParse(numberPart, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) || value <= 0)
{
return fallback;
}
return suffix switch
{
's' => TimeSpan.FromSeconds(value),
'm' => TimeSpan.FromMinutes(value),
'h' => TimeSpan.FromHours(value),
'd' => TimeSpan.FromDays(value),
_ => fallback
};
}
}

View File

@@ -88,9 +88,10 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable
// Create the gateway request queue (receives requests from all services)
_requestQueue = _queueFactory.Create<RpcRequestMessage>(new MessageQueueOptions
{
QueueName = _options.GetRequestQueueName("gateway"),
QueueName = _options.GetGatewayControlQueueName(),
ConsumerGroup = _options.ConsumerGroup,
ConsumerName = _options.ConsumerGroup,
DeadLetterQueue = _options.GetRequestQueueName("gateway") + _options.DeadLetterSuffix,
DeadLetterQueue = _options.GetGatewayControlQueueName() + _options.DeadLetterSuffix,
DefaultLeaseDuration = _options.LeaseDuration
});
@@ -98,6 +99,7 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable
_responseQueue = _queueFactory.Create<RpcResponseMessage>(new MessageQueueOptions
{
QueueName = _options.ResponseQueueName,
ConsumerGroup = _options.ConsumerGroup,
ConsumerName = _options.ConsumerGroup,
DefaultLeaseDuration = _options.LeaseDuration
});
@@ -359,6 +361,7 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable
_queueFactory.Create<RpcResponseMessage>(new MessageQueueOptions
{
QueueName = _options.GetRequestQueueName(svc),
ConsumerGroup = _options.ConsumerGroup,
ConsumerName = _options.ConsumerGroup
}));

View File

@@ -7,6 +7,17 @@ namespace StellaOps.Router.Transport.Messaging.Options;
/// </summary>
public class MessagingTransportOptions
{
/// <summary>
/// Gets or sets the logical queue segment reserved for gateway control traffic
/// (HELLO, HEARTBEAT, and microservice-to-gateway responses).
/// </summary>
/// <remarks>
/// This must not overlap with any real microservice name. The default
/// avoids collision with the "gateway" service.
/// </remarks>
[Required]
public string GatewayControlQueueServiceName { get; set; } = "gateway-control";
/// <summary>
/// Gets or sets the queue name template for incoming requests.
/// Use {service} placeholder for service-specific queues.
@@ -59,4 +70,12 @@ public class MessagingTransportOptions
{
return RequestQueueTemplate.Replace("{service}", serviceName);
}
/// <summary>
/// Gets the queue name used for gateway control traffic.
/// </summary>
public string GetGatewayControlQueueName()
{
return GetRequestQueueName(GatewayControlQueueServiceName);
}
}

View File

@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| RVM-06 | DONE | Updated messaging microservice HELLO payload to include schemas/OpenAPI metadata via the new schema-aware `IMicroserviceTransport.ConnectAsync(...)` overload. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/StellaOps.Router.Transport.Messaging.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -46,7 +46,7 @@ public sealed class AuthorizationMiddlewareTests
public async Task InvokeAsync_NoClaims_CallsNext()
{
// Arrange
var context = CreateHttpContextWithEndpoint();
var context = CreateHttpContextWithEndpoint([new Claim("sub", "alice")]);
_claimsStore
.Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test"))
.Returns(Array.Empty<ClaimRequirement>());
@@ -59,6 +59,27 @@ public sealed class AuthorizationMiddlewareTests
context.Response.StatusCode.Should().NotBe(403);
}
[Fact]
public async Task InvokeAsync_LegacyMetadataWithoutAuthFlag_FailsClosedWith401()
{
var endpoint = new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "GET",
Path = "/api/test",
AllowAnonymous = false,
RequiresAuthentication = false
};
var context = CreateHttpContextWithEndpoint(endpoint: endpoint);
await _middleware.InvokeAsync(context);
_next.Verify(n => n(It.IsAny<HttpContext>()), Times.Never);
context.Response.StatusCode.Should().Be(401);
}
[Fact]
public async Task InvokeAsync_UserHasRequiredClaims_CallsNext()
{
@@ -215,7 +236,7 @@ public sealed class AuthorizationMiddlewareTests
public async Task InvokeAsync_ForbiddenResponse_ContainsErrorDetails()
{
// Arrange
var context = CreateHttpContextWithEndpoint();
var context = CreateHttpContextWithEndpoint([new Claim("sub", "alice")]);
context.Response.Body = new MemoryStream();
_claimsStore
@@ -233,18 +254,39 @@ public sealed class AuthorizationMiddlewareTests
context.Response.ContentType.Should().Contain("application/json");
}
[Fact]
public async Task InvokeAsync_AllowAnonymousEndpoint_CallsNextWithoutAuthentication()
{
var endpoint = new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "GET",
Path = "/api/test",
AllowAnonymous = true
};
var context = CreateHttpContextWithEndpoint(endpoint: endpoint);
await _middleware.InvokeAsync(context);
_next.Verify(n => n(context), Times.Once);
}
private static HttpContext CreateHttpContext()
{
var context = new DefaultHttpContext();
return context;
}
private static HttpContext CreateHttpContextWithEndpoint(Claim[]? userClaims = null)
private static HttpContext CreateHttpContextWithEndpoint(
Claim[]? userClaims = null,
EndpointDescriptor? endpoint = null)
{
var context = new DefaultHttpContext();
// Set resolved endpoint
var endpoint = new EndpointDescriptor
endpoint ??= new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",

View File

@@ -56,21 +56,21 @@ public sealed class MessagingTransportIntegrationTests
var tlsServer = new TlsTransportServer(tlsOptions, NullLogger<TlsTransportServer>.Instance);
var transportClient = new GatewayTransportClient(
NullLogger<GatewayTransportClient>.Instance,
tcpServer,
tlsServer,
NullLogger<GatewayTransportClient>.Instance,
messagingServer);
// Act & Assert - construction should succeed with messaging server
var hostedService = new GatewayHostedService(
tcpServer,
tlsServer,
routingState.Object,
transportClient,
claimsStore.Object,
gatewayOptions,
new GatewayServiceStatus(),
NullLogger<GatewayHostedService>.Instance,
tcpServer: tcpServer,
tlsServer: tlsServer,
openApiCache: null,
messagingServer: messagingServer);
@@ -92,21 +92,21 @@ public sealed class MessagingTransportIntegrationTests
var tlsServer = new TlsTransportServer(tlsOptions, NullLogger<TlsTransportServer>.Instance);
var transportClient = new GatewayTransportClient(
NullLogger<GatewayTransportClient>.Instance,
tcpServer,
tlsServer,
NullLogger<GatewayTransportClient>.Instance,
messagingServer: null);
// Act & Assert - construction should succeed without messaging server
var hostedService = new GatewayHostedService(
tcpServer,
tlsServer,
routingState.Object,
transportClient,
claimsStore.Object,
gatewayOptions,
new GatewayServiceStatus(),
NullLogger<GatewayHostedService>.Instance,
tcpServer: tcpServer,
tlsServer: tlsServer,
openApiCache: null,
messagingServer: null);
@@ -132,9 +132,9 @@ public sealed class MessagingTransportIntegrationTests
// Act
var transportClient = new GatewayTransportClient(
NullLogger<GatewayTransportClient>.Instance,
tcpServer,
tlsServer,
NullLogger<GatewayTransportClient>.Instance,
messagingServer);
// Assert
@@ -152,9 +152,9 @@ public sealed class MessagingTransportIntegrationTests
// No messaging server provided
var transportClient = new GatewayTransportClient(
NullLogger<GatewayTransportClient>.Instance,
tcpServer,
tlsServer,
NullLogger<GatewayTransportClient>.Instance,
messagingServer: null);
var connection = new ConnectionState
@@ -193,6 +193,7 @@ public sealed class MessagingTransportIntegrationTests
Assert.Equal("localhost:6379", options.ConnectionString);
Assert.Null(options.Database);
Assert.Equal("router:requests:{service}", options.RequestQueueTemplate);
Assert.Equal("gateway-control", options.GatewayControlQueueServiceName);
Assert.Equal("router:responses", options.ResponseQueueName);
Assert.Equal("router-gateway", options.ConsumerGroup);
Assert.Equal("30s", options.RequestTimeout);

View File

@@ -0,0 +1,293 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Gateway.WebService.Routing;
using StellaOps.Router.Gateway;
using StellaOps.Router.Gateway.Configuration;
namespace StellaOps.Gateway.WebService.Tests.Middleware;
[Trait("Category", "Unit")]
public sealed class RouteDispatchMiddlewareMicroserviceTests
{
[Fact]
public async Task InvokeAsync_MicroserviceRouteWithTranslatesTo_SetsTranslatedPath()
{
// Arrange
var resolver = new StellaOpsRouteResolver(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = "/api/v1/advisory-ai/adapters",
TranslatesTo = "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters"
}
]);
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
var nextCalled = false;
var middleware = new RouteDispatchMiddleware(
_ =>
{
nextCalled = true;
return Task.CompletedTask;
},
resolver,
httpClientFactory.Object,
NullLogger<RouteDispatchMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/advisory-ai/adapters/openai/chat/completions";
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.True(nextCalled);
Assert.Equal(
"/v1/advisory-ai/adapters/openai/chat/completions",
context.Items[RouterHttpContextKeys.TranslatedRequestPath] as string);
Assert.Equal(
"advisoryai",
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
}
[Fact]
public async Task InvokeAsync_MicroserviceRouteWithoutTranslatesTo_DoesNotSetTranslatedPath()
{
// Arrange
var resolver = new StellaOpsRouteResolver(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = "/api/v1/timeline"
}
]);
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
var middleware = new RouteDispatchMiddleware(
_ => Task.CompletedTask,
resolver,
httpClientFactory.Object,
NullLogger<RouteDispatchMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/timeline";
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.False(context.Items.ContainsKey(RouterHttpContextKeys.TranslatedRequestPath));
Assert.Equal(
"timeline",
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
}
[Fact]
public async Task InvokeAsync_MicroserviceRouteWithGenericHost_PrefersPathHintForTargetService()
{
// Arrange
var resolver = new StellaOpsRouteResolver(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = "/vulnexplorer",
TranslatesTo = "http://api.stella-ops.local"
}
]);
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
var middleware = new RouteDispatchMiddleware(
_ => Task.CompletedTask,
resolver,
httpClientFactory.Object,
NullLogger<RouteDispatchMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Path = "/vulnexplorer";
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.Equal(
"vulnexplorer",
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
}
[Fact]
public async Task InvokeAsync_MicroserviceRouteWithDefaultTimeout_SetsRouteTimeoutContextItem()
{
// Arrange
var resolver = new StellaOpsRouteResolver(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = "/api/v1/timeline",
DefaultTimeout = "15s"
}
]);
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
var middleware = new RouteDispatchMiddleware(
_ => Task.CompletedTask,
resolver,
httpClientFactory.Object,
NullLogger<RouteDispatchMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/timeline/events";
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.True(context.Items.TryGetValue(RouterHttpContextKeys.RouteDefaultTimeout, out var timeoutObj));
Assert.IsType<TimeSpan>(timeoutObj);
Assert.Equal(TimeSpan.FromSeconds(15), (TimeSpan)timeoutObj!);
}
[Fact]
public async Task InvokeAsync_MicroserviceRoute_BrowserNavigation_ServesSpaFallback()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"stella-router-spa-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
await File.WriteAllTextAsync(
Path.Combine(tempDir, "index.html"),
"<!DOCTYPE html><html><body><h1>SPA Root</h1></body></html>");
var resolver = new StellaOpsRouteResolver(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = "/policy",
TranslatesTo = "http://policy-gateway.stella-ops.local"
},
new StellaOpsRoute
{
Type = StellaOpsRouteType.StaticFiles,
Path = "/",
TranslatesTo = tempDir,
Headers = new Dictionary<string, string> { ["x-spa-fallback"] = "true" }
}
]);
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
var nextCalled = false;
var middleware = new RouteDispatchMiddleware(
_ =>
{
nextCalled = true;
return Task.CompletedTask;
},
resolver,
httpClientFactory.Object,
NullLogger<RouteDispatchMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/policy";
context.Request.Headers.Accept = "text/html,application/xhtml+xml";
context.Response.Body = new MemoryStream();
await middleware.InvokeAsync(context);
Assert.False(nextCalled);
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
context.Response.Body.Position = 0;
var body = await new StreamReader(context.Response.Body).ReadToEndAsync();
Assert.Contains("SPA Root", body, StringComparison.Ordinal);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task InvokeAsync_MicroserviceApiPath_DoesNotUseSpaFallback()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"stella-router-spa-api-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
await File.WriteAllTextAsync(
Path.Combine(tempDir, "index.html"),
"<!DOCTYPE html><html><body><h1>SPA Root</h1></body></html>");
var resolver = new StellaOpsRouteResolver(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = "/api/v1/policy",
TranslatesTo = "http://policy-gateway.stella-ops.local/api/v1/policy"
},
new StellaOpsRoute
{
Type = StellaOpsRouteType.StaticFiles,
Path = "/",
TranslatesTo = tempDir,
Headers = new Dictionary<string, string> { ["x-spa-fallback"] = "true" }
}
]);
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
var nextCalled = false;
var middleware = new RouteDispatchMiddleware(
_ =>
{
nextCalled = true;
return Task.CompletedTask;
},
resolver,
httpClientFactory.Object,
NullLogger<RouteDispatchMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/api/v1/policy/check";
context.Request.Headers.Accept = "text/html,application/xhtml+xml";
context.Response.Body = new MemoryStream();
await middleware.InvokeAsync(context);
Assert.True(nextCalled);
Assert.Equal(
"policy-gateway",
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0349-T | DONE | Revalidated 2026-01-07; test coverage audit for Router Gateway WebService tests. |
| AUDIT-0349-A | DONE | Waived (test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| RGH-01-T | DONE | 2026-02-22: Added route-dispatch unit tests for microservice SPA fallback and API-prefix bypass behavior. |

View File

@@ -223,6 +223,67 @@ public sealed class RouterConnectionManagerTests : IDisposable
await manager.StopAsync(CancellationToken.None);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StartAsync_RuntimeSchemasOverrideGeneratedSchemas_WhenSchemaIdsMatch()
{
// Arrange
_options.Routers.Add(new RouterEndpointConfig
{
Host = "localhost",
Port = 5000,
TransportType = TransportType.InMemory
});
var schemaId = "shared_schema";
var runtimeSchema = new SchemaDefinition
{
SchemaId = schemaId,
SchemaJson = "{\"type\":\"object\",\"properties\":{\"status\":{\"type\":\"string\"}}}",
ETag = "runtime"
};
var generatedSchema = new SchemaDefinition
{
SchemaId = schemaId,
SchemaJson = "{invalid-json",
ETag = "generated"
};
var discoveryProvider = new TestSchemaDiscoveryProvider(
endpoints: [],
schemas: new Dictionary<string, SchemaDefinition>
{
[schemaId] = runtimeSchema
});
var generatedProviderMock = new Mock<IGeneratedEndpointProvider>();
generatedProviderMock.Setup(provider => provider.GetSchemaDefinitions())
.Returns(new Dictionary<string, SchemaDefinition>
{
[schemaId] = generatedSchema
});
using var manager = new RouterConnectionManager(
Options.Create(_options),
discoveryProvider,
_requestDispatcherMock.Object,
_transportMock.Object,
NullLogger<RouterConnectionManager>.Instance,
generatedProviderMock.Object);
// Act
await manager.StartAsync(CancellationToken.None);
// Assert
manager.Connections.Should().HaveCount(1);
manager.Connections[0].Schemas.Should().ContainKey(schemaId);
manager.Connections[0].Schemas[schemaId].SchemaJson.Should().Be(runtimeSchema.SchemaJson);
manager.Connections[0].Schemas[schemaId].ETag.Should().Be(runtimeSchema.ETag);
// Cleanup
await manager.StopAsync(CancellationToken.None);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StartAsync_AfterDispose_ThrowsObjectDisposedException()
@@ -348,4 +409,24 @@ public sealed class RouterConnectionManagerTests : IDisposable
}
#endregion
private sealed class TestSchemaDiscoveryProvider :
IEndpointDiscoveryProvider,
IEndpointSchemaDefinitionProvider
{
private readonly IReadOnlyList<EndpointDescriptor> _endpoints;
private readonly IReadOnlyDictionary<string, SchemaDefinition> _schemas;
public TestSchemaDiscoveryProvider(
IReadOnlyList<EndpointDescriptor> endpoints,
IReadOnlyDictionary<string, SchemaDefinition> schemas)
{
_endpoints = endpoints;
_schemas = schemas;
}
public IReadOnlyList<EndpointDescriptor> DiscoverEndpoints() => _endpoints;
public IReadOnlyDictionary<string, SchemaDefinition> DiscoverSchemaDefinitions() => _schemas;
}
}

View File

@@ -0,0 +1,346 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Primitives;
using StellaOps.Microservice;
using StellaOps.Microservice.AspNetCore;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.AspNet.Tests;
public sealed class AspNetCoreEndpointDiscoveryProviderTests
{
[Fact]
public void DiscoverEndpoints_UsesIHttpMethodMetadata()
{
var routeEndpoint = new RouteEndpoint(
_ => Task.CompletedTask,
RoutePatternFactory.Parse("/api/v1/timeline"),
order: 0,
new EndpointMetadataCollection(new CustomHttpMethodMetadata("GET")),
displayName: "Timeline GET");
var dataSource = new StaticEndpointDataSource(routeEndpoint);
var options = new StellaRouterBridgeOptions
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Region = "local",
OnMissingAuthorization = MissingAuthorizationBehavior.AllowAuthenticated
};
var provider = new AspNetCoreEndpointDiscoveryProvider(
dataSource,
options,
new AllowAnonymousClaimMapper(),
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
var endpoints = provider.DiscoverEndpoints();
Assert.Single(endpoints);
Assert.Equal("GET", endpoints[0].Method);
Assert.Equal("/api/v1/timeline", endpoints[0].Path);
}
[Fact]
public void DiscoverSchemaDefinitions_EmitsOpenApiMetadataAndSchemas()
{
var routeEndpoint = new RouteEndpoint(
_ => Task.CompletedTask,
RoutePatternFactory.Parse("/api/v1/timeline/events"),
order: 0,
new EndpointMetadataCollection(
new CustomHttpMethodMetadata("POST"),
new CustomSummaryMetadata("Ingest timeline event"),
new CustomDescriptionMetadata("Indexes a tenant timeline event."),
new CustomTagsMetadata("timeline"),
new CustomAcceptsMetadata(typeof(TimelineEnvelope), false, "application/json"),
new CustomProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TimelineIngestedResponse), "application/json")),
displayName: "Timeline Ingest");
var dataSource = new StaticEndpointDataSource(routeEndpoint);
var options = new StellaRouterBridgeOptions
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Region = "local",
OnMissingAuthorization = MissingAuthorizationBehavior.AllowAuthenticated
};
var provider = new AspNetCoreEndpointDiscoveryProvider(
dataSource,
options,
new AllowAnonymousClaimMapper(),
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
var discovered = provider.DiscoverAspNetEndpoints();
var endpoint = Assert.Single(discovered);
Assert.NotNull(endpoint.SchemaInfo);
Assert.Equal("Ingest timeline event", endpoint.SchemaInfo!.Summary);
Assert.Equal("Indexes a tenant timeline event.", endpoint.SchemaInfo.Description);
Assert.Contains("timeline", endpoint.SchemaInfo.Tags);
Assert.NotNull(endpoint.SchemaInfo.RequestSchemaId);
Assert.NotNull(endpoint.SchemaInfo.ResponseSchemaId);
Assert.Equal(StatusCodes.Status200OK, endpoint.SchemaInfo.ResponseStatusCode);
var schemaProvider = Assert.IsAssignableFrom<IEndpointSchemaDefinitionProvider>(provider);
var schemas = schemaProvider.DiscoverSchemaDefinitions();
Assert.True(schemas.ContainsKey(endpoint.SchemaInfo.RequestSchemaId!));
Assert.True(schemas.ContainsKey(endpoint.SchemaInfo.ResponseSchemaId!));
Assert.Contains("\"eventId\"", schemas[endpoint.SchemaInfo.RequestSchemaId!].SchemaJson, StringComparison.Ordinal);
Assert.Contains("\"status\"", schemas[endpoint.SchemaInfo.ResponseSchemaId!].SchemaJson, StringComparison.Ordinal);
}
[Fact]
public void DiscoverSchemaDefinitions_PrefersTypedProducesMetadataOverIResult()
{
var routeEndpoint = new RouteEndpoint(
_ => Task.CompletedTask,
RoutePatternFactory.Parse("/api/v1/timeline"),
order: 0,
new EndpointMetadataCollection(
new CustomHttpMethodMetadata("GET"),
new CustomProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(IResult), "application/json"),
new CustomProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TimelineIngestedResponse), "application/json")),
displayName: "Timeline Query");
var dataSource = new StaticEndpointDataSource(routeEndpoint);
var options = new StellaRouterBridgeOptions
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Region = "local",
OnMissingAuthorization = MissingAuthorizationBehavior.AllowAuthenticated
};
var provider = new AspNetCoreEndpointDiscoveryProvider(
dataSource,
options,
new AllowAnonymousClaimMapper(),
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
var endpoint = Assert.Single(provider.DiscoverAspNetEndpoints());
Assert.NotNull(endpoint.SchemaInfo?.ResponseSchemaId);
var schemaProvider = Assert.IsAssignableFrom<IEndpointSchemaDefinitionProvider>(provider);
var schemas = schemaProvider.DiscoverSchemaDefinitions();
Assert.True(schemas.ContainsKey(endpoint.SchemaInfo!.ResponseSchemaId!));
Assert.Contains("\"status\"", schemas[endpoint.SchemaInfo.ResponseSchemaId!].SchemaJson, StringComparison.Ordinal);
}
[Fact]
public void DiscoverSchemaDefinitions_PreservesSuccessResponseStatusCodeFromProducesMetadata()
{
var routeEndpoint = new RouteEndpoint(
_ => Task.CompletedTask,
RoutePatternFactory.Parse("/api/v1/timeline/events"),
order: 0,
new EndpointMetadataCollection(
new CustomHttpMethodMetadata("POST"),
new CustomAcceptsMetadata(typeof(TimelineEnvelope), false, "application/json"),
new CustomProducesResponseTypeMetadata(StatusCodes.Status201Created, typeof(TimelineIngestedResponse), "application/json")),
displayName: "Timeline Ingest Created");
var provider = new AspNetCoreEndpointDiscoveryProvider(
new StaticEndpointDataSource(routeEndpoint),
new StellaRouterBridgeOptions
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Region = "local",
OnMissingAuthorization = MissingAuthorizationBehavior.AllowAuthenticated
},
new AllowAnonymousClaimMapper(),
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
var endpoint = Assert.Single(provider.DiscoverAspNetEndpoints());
Assert.NotNull(endpoint.SchemaInfo?.ResponseSchemaId);
Assert.Equal(StatusCodes.Status201Created, endpoint.SchemaInfo!.ResponseStatusCode);
}
[Fact]
public void DiscoverEndpoints_AuthorizeWithoutClaims_StillRequiresAuthentication()
{
var routeEndpoint = new RouteEndpoint(
_ => Task.CompletedTask,
RoutePatternFactory.Parse("/api/v1/protected"),
order: 0,
new EndpointMetadataCollection(new CustomHttpMethodMetadata("GET")),
displayName: "Protected GET");
var provider = new AspNetCoreEndpointDiscoveryProvider(
new StaticEndpointDataSource(routeEndpoint),
new StellaRouterBridgeOptions
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Region = "local",
OnMissingAuthorization = MissingAuthorizationBehavior.AllowAuthenticated
},
new AuthorizeOnlyClaimMapper(),
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
var endpoint = Assert.Single(provider.DiscoverEndpoints());
Assert.False(endpoint.AllowAnonymous);
Assert.True(endpoint.RequiresAuthentication);
}
[Fact]
public void DiscoverEndpoints_OnMissingAuthorizationWarnAndAllow_RequiresAuthentication()
{
var routeEndpoint = new RouteEndpoint(
_ => Task.CompletedTask,
RoutePatternFactory.Parse("/api/v1/missing-auth"),
order: 0,
new EndpointMetadataCollection(new CustomHttpMethodMetadata("GET")),
displayName: "Missing Auth GET");
var provider = new AspNetCoreEndpointDiscoveryProvider(
new StaticEndpointDataSource(routeEndpoint),
new StellaRouterBridgeOptions
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Region = "local",
OnMissingAuthorization = MissingAuthorizationBehavior.WarnAndAllow
},
new NoAuthorizationClaimMapper(),
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
var endpoint = Assert.Single(provider.DiscoverEndpoints());
Assert.False(endpoint.AllowAnonymous);
Assert.True(endpoint.RequiresAuthentication);
}
[Fact]
public void DiscoverEndpoints_OnMissingAuthorizationRequireExplicit_Throws()
{
var routeEndpoint = new RouteEndpoint(
_ => Task.CompletedTask,
RoutePatternFactory.Parse("/api/v1/missing-auth"),
order: 0,
new EndpointMetadataCollection(new CustomHttpMethodMetadata("GET")),
displayName: "Missing Auth GET");
var provider = new AspNetCoreEndpointDiscoveryProvider(
new StaticEndpointDataSource(routeEndpoint),
new StellaRouterBridgeOptions
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Region = "local",
OnMissingAuthorization = MissingAuthorizationBehavior.RequireExplicit
},
new NoAuthorizationClaimMapper(),
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
Assert.Throws<InvalidOperationException>(() => provider.DiscoverEndpoints());
}
private sealed class StaticEndpointDataSource(params Endpoint[] endpoints) : EndpointDataSource
{
private readonly IReadOnlyList<Endpoint> _endpoints = endpoints;
public override IReadOnlyList<Endpoint> Endpoints => _endpoints;
public override IChangeToken GetChangeToken() =>
new CancellationChangeToken(CancellationToken.None);
}
private sealed class CustomHttpMethodMetadata(params string[] methods) : IHttpMethodMetadata
{
public IReadOnlyList<string> HttpMethods { get; } = methods;
public bool AcceptCorsPreflight => false;
}
private sealed class AllowAnonymousClaimMapper : IAuthorizationClaimMapper
{
public Task<AuthorizationMappingResult> MapAsync(
RouteEndpoint endpoint,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Map(endpoint));
}
public AuthorizationMappingResult Map(RouteEndpoint endpoint)
{
return new AuthorizationMappingResult
{
AllowAnonymous = true,
Source = AuthorizationSource.AspNetMetadata
};
}
}
private sealed class AuthorizeOnlyClaimMapper : IAuthorizationClaimMapper
{
public Task<AuthorizationMappingResult> MapAsync(RouteEndpoint endpoint, CancellationToken cancellationToken = default)
=> Task.FromResult(Map(endpoint));
public AuthorizationMappingResult Map(RouteEndpoint endpoint)
=> new()
{
AllowAnonymous = false,
Source = AuthorizationSource.AspNetMetadata
};
}
private sealed class NoAuthorizationClaimMapper : IAuthorizationClaimMapper
{
public Task<AuthorizationMappingResult> MapAsync(RouteEndpoint endpoint, CancellationToken cancellationToken = default)
=> Task.FromResult(Map(endpoint));
public AuthorizationMappingResult Map(RouteEndpoint endpoint)
=> new()
{
AllowAnonymous = false,
Source = AuthorizationSource.None
};
}
private sealed record TimelineEnvelope(string EventId, string TenantId);
private sealed record TimelineIngestedResponse(string EventId, string Status);
private sealed class CustomSummaryMetadata(string summary) : IEndpointSummaryMetadata
{
public string Summary { get; } = summary;
}
private sealed class CustomDescriptionMetadata(string description) : IEndpointDescriptionMetadata
{
public string Description { get; } = description;
}
private sealed class CustomTagsMetadata(params string[] tags) : ITagsMetadata
{
public IReadOnlyList<string> Tags { get; } = tags;
}
private sealed class CustomAcceptsMetadata(
Type requestType,
bool isOptional,
params string[] contentTypes) : IAcceptsMetadata
{
public Type? RequestType { get; } = requestType;
public IReadOnlyList<string> ContentTypes { get; } = contentTypes;
public bool IsOptional { get; } = isOptional;
}
private sealed class CustomProducesResponseTypeMetadata(
int statusCode,
Type responseType,
params string[] contentTypes) : IProducesResponseTypeMetadata
{
public Type? Type { get; } = responseType;
public int StatusCode { get; } = statusCode;
public string? Description => null;
public IEnumerable<string> ContentTypes { get; } = contentTypes;
}
}

View File

@@ -0,0 +1,133 @@
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Matching;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Primitives;
using StellaOps.Microservice.AspNetCore;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Identity;
namespace StellaOps.Router.AspNet.Tests;
public sealed class AspNetRouterRequestDispatcherTests
{
[Fact]
public async Task DispatchAsync_GatewayEnforcedWithoutEnvelope_ReturnsForbidden()
{
var routeEndpoint = new RouteEndpoint(
async context => await context.Response.WriteAsync("ok"),
RoutePatternFactory.Parse("/api/v1/ping"),
order: 0,
new EndpointMetadataCollection(new HttpMethodMetadata(["GET"])),
displayName: "Ping");
var dispatcher = CreateDispatcher(
routeEndpoint,
new StellaRouterBridgeOptions
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Region = "local",
AuthorizationTrustMode = GatewayAuthorizationTrustMode.GatewayEnforced,
IdentityEnvelopeSigningKey = "test-signing-key-123"
});
var response = await dispatcher.DispatchAsync(new RequestFrame
{
RequestId = "req-1",
Method = "GET",
Path = "/api/v1/ping",
Headers = new Dictionary<string, string>(),
Payload = ReadOnlyMemory<byte>.Empty
});
Assert.Equal(StatusCodes.Status403Forbidden, response.StatusCode);
}
[Fact]
public async Task DispatchAsync_GatewayEnforcedWithValidEnvelope_DispatchesAndSetsIdentity()
{
var routeEndpoint = new RouteEndpoint(
async context =>
{
var subject = context.User.FindFirst("sub")?.Value ?? "missing";
await context.Response.WriteAsync(subject);
},
RoutePatternFactory.Parse("/api/v1/ping"),
order: 0,
new EndpointMetadataCollection(new HttpMethodMetadata(["GET"])),
displayName: "Ping");
const string signingKey = "test-signing-key-123";
var dispatcher = CreateDispatcher(
routeEndpoint,
new StellaRouterBridgeOptions
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Region = "local",
AuthorizationTrustMode = GatewayAuthorizationTrustMode.GatewayEnforced,
IdentityEnvelopeSigningKey = signingKey
});
var envelope = new GatewayIdentityEnvelope
{
Issuer = "gateway",
Subject = "alice",
Tenant = "tenant-a",
Scopes = ["timeline.read"],
Roles = ["reader"],
CorrelationId = "corr-1",
IssuedAtUtc = DateTimeOffset.UtcNow.AddSeconds(-5),
ExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(1)
};
var signature = GatewayIdentityEnvelopeCodec.Sign(envelope, signingKey);
var response = await dispatcher.DispatchAsync(new RequestFrame
{
RequestId = "req-2",
Method = "GET",
Path = "/api/v1/ping",
Headers = new Dictionary<string, string>
{
["X-StellaOps-Identity-Envelope"] = signature.Payload,
["X-StellaOps-Identity-Envelope-Signature"] = signature.Signature
},
Payload = ReadOnlyMemory<byte>.Empty
});
Assert.Equal(StatusCodes.Status200OK, response.StatusCode);
Assert.Equal("alice", Encoding.UTF8.GetString(response.Payload.ToArray()));
}
private static AspNetRouterRequestDispatcher CreateDispatcher(RouteEndpoint endpoint, StellaRouterBridgeOptions options)
{
var services = new ServiceCollection();
services.AddSingleton<EndpointSelector, NoOpEndpointSelector>();
var serviceProvider = services.BuildServiceProvider();
return new AspNetRouterRequestDispatcher(
serviceProvider,
new StaticEndpointDataSource(endpoint),
options,
NullLogger<AspNetRouterRequestDispatcher>.Instance);
}
private sealed class StaticEndpointDataSource(params Endpoint[] endpoints) : EndpointDataSource
{
private readonly IReadOnlyList<Endpoint> _endpoints = endpoints;
public override IReadOnlyList<Endpoint> Endpoints => _endpoints;
public override IChangeToken GetChangeToken()
=> new Microsoft.Extensions.Primitives.CancellationChangeToken(CancellationToken.None);
}
private sealed class NoOpEndpointSelector : EndpointSelector
{
public override Task SelectAsync(HttpContext httpContext, CandidateSet candidates) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,121 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Microservice.AspNetCore;
namespace StellaOps.Router.AspNet.Tests;
public sealed class DefaultAuthorizationClaimMapperTests
{
[Fact]
public async Task MapAsync_WithAuthorizationPolicyMetadata_MapsScopeClaimsAndMarksAspNetSource()
{
var endpoint = CreateEndpoint(
new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new TestScopeRequirement("timeline:read", "timeline:write"))
.Build());
var mapper = new DefaultAuthorizationClaimMapper(
new DictionaryPolicyProvider(),
NullLogger<DefaultAuthorizationClaimMapper>.Instance);
var result = await mapper.MapAsync(endpoint);
Assert.Equal(AuthorizationSource.AspNetMetadata, result.Source);
Assert.True(result.HasAuthorization);
Assert.False(result.AllowAnonymous);
Assert.Contains(result.Claims, claim => claim.Type == "scope" && claim.Value == "timeline:read");
Assert.Contains(result.Claims, claim => claim.Type == "scope" && claim.Value == "timeline:write");
}
[Fact]
public async Task MapAsync_WithAuthorizeDataPolicy_ResolvesPolicyClaimsFromProvider()
{
const string policyName = "timeline.read";
var endpoint = CreateEndpoint(new AuthorizeAttribute { Policy = policyName });
var mapper = new DefaultAuthorizationClaimMapper(
new DictionaryPolicyProvider(
new Dictionary<string, AuthorizationPolicy>(StringComparer.OrdinalIgnoreCase)
{
[policyName] = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim("scope", "timeline:read")
.Build()
}),
NullLogger<DefaultAuthorizationClaimMapper>.Instance);
var result = await mapper.MapAsync(endpoint);
Assert.Equal(AuthorizationSource.AspNetMetadata, result.Source);
Assert.Contains(policyName, result.Policies);
Assert.Contains(result.Claims, claim => claim.Type == "scope" && claim.Value == "timeline:read");
}
[Fact]
public void Map_WithAuthorizationPolicyMetadata_MapsScopeClaims()
{
var endpoint = CreateEndpoint(
new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new TestScopeRequirement("timeline:read"))
.Build());
var mapper = new DefaultAuthorizationClaimMapper(
new DictionaryPolicyProvider(),
NullLogger<DefaultAuthorizationClaimMapper>.Instance);
var result = mapper.Map(endpoint);
Assert.Equal(AuthorizationSource.AspNetMetadata, result.Source);
Assert.Contains(result.Claims, claim => claim.Type == "scope" && claim.Value == "timeline:read");
}
private static RouteEndpoint CreateEndpoint(params object[] metadata)
{
return new RouteEndpoint(
_ => Task.CompletedTask,
RoutePatternFactory.Parse("/api/v1/timeline"),
order: 0,
new EndpointMetadataCollection(metadata),
displayName: "timeline-endpoint");
}
private sealed class DictionaryPolicyProvider : IAuthorizationPolicyProvider
{
private readonly IReadOnlyDictionary<string, AuthorizationPolicy> _policies;
public DictionaryPolicyProvider(IReadOnlyDictionary<string, AuthorizationPolicy>? policies = null)
{
_policies = policies ?? new Dictionary<string, AuthorizationPolicy>(StringComparer.OrdinalIgnoreCase);
}
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
return Task.FromResult(_policies.TryGetValue(policyName, out var policy) ? policy : null);
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
{
return Task.FromResult(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
}
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
{
return Task.FromResult<AuthorizationPolicy?>(null);
}
}
private sealed class TestScopeRequirement : IAuthorizationRequirement
{
public TestScopeRequirement(params string[] requiredScopes)
{
RequiredScopes = requiredScopes;
}
public IReadOnlyCollection<string> RequiredScopes { get; }
}
}

View File

@@ -22,6 +22,9 @@
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.Tcp\StellaOps.Router.Transport.Tcp.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.Messaging\StellaOps.Router.Transport.Messaging.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Messaging.Transport.Valkey\StellaOps.Messaging.Transport.Valkey.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,359 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using StellaOps.Microservice;
using StellaOps.Router.AspNet;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Transport.Messaging.Options;
using StellaOps.Router.Transport.Tcp;
using Xunit;
namespace StellaOps.Router.AspNet.Tests;
public sealed class StellaRouterIntegrationHelperTests
{
[Fact]
public void AddRouterMicroservice_WhenRouterDisabled_ReturnsFalse()
{
var services = new ServiceCollection();
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["TimelineIndexer:Router:Enabled"] = "false"
});
var result = services.AddRouterMicroservice(
configuration,
serviceName: "timelineindexer",
version: "1.0.0",
routerOptionsSection: "TimelineIndexer:Router");
Assert.False(result);
}
[Fact]
public async Task AddRouterMicroservice_WhenEnabledWithoutGateway_UsesDefaults()
{
var services = new ServiceCollection();
services.AddLogging();
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["TimelineIndexer:Router:Enabled"] = "true",
["TimelineIndexer:Router:Region"] = "local"
});
var result = services.AddRouterMicroservice(
configuration,
serviceName: "timelineindexer",
version: "1.0.0",
routerOptionsSection: "TimelineIndexer:Router");
await using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<StellaMicroserviceOptions>>().Value;
var transport = provider.GetRequiredService<IMicroserviceTransport>();
Assert.True(result);
Assert.Single(options.Routers);
Assert.Equal("router.stella-ops.local", options.Routers[0].Host);
Assert.Equal(9100, options.Routers[0].Port);
Assert.Equal(TransportType.Messaging, options.Routers[0].TransportType);
Assert.Equal("MessagingTransportClient", transport.GetType().Name);
}
[Fact]
public async Task AddRouterMicroservice_WhenSectionMissing_FallsBackToGlobalRouterSection()
{
var services = new ServiceCollection();
services.AddLogging();
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["Router:Enabled"] = "true",
["Router:Region"] = "local",
["Router:Gateways:0:Host"] = "router.stella-ops.local",
["Router:Gateways:0:Port"] = "9100",
["Router:Gateways:0:TransportType"] = "Messaging",
["Router:Messaging:PluginDirectory"] = AppContext.BaseDirectory,
["Router:Messaging:SearchPattern"] = "StellaOps.Messaging.Transport.*.dll",
["Router:Messaging:valkey:ConnectionString"] = "cache.stella-ops.local:6379",
["Router:TransportPlugins:Directory"] = AppContext.BaseDirectory,
["Router:TransportPlugins:SearchPattern"] = "StellaOps.Router.Transport.*.dll"
});
var result = services.AddRouterMicroservice(
configuration,
serviceName: "timelineindexer",
version: "1.0.0",
routerOptionsSection: "TimelineIndexer:Router");
await using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<StellaMicroserviceOptions>>().Value;
Assert.True(result);
Assert.Single(options.Routers);
Assert.Equal("router.stella-ops.local", options.Routers[0].Host);
Assert.Equal(TransportType.Messaging, options.Routers[0].TransportType);
}
[Fact]
public void AddRouterMicroservice_WhenRouterOptionsSectionMissing_ThrowsArgumentException()
{
var services = new ServiceCollection();
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["TimelineIndexer:Router:Enabled"] = "true"
});
var ex = Assert.Throws<ArgumentException>(() =>
services.AddRouterMicroservice(
configuration,
serviceName: "timelineindexer",
version: "1.0.0",
routerOptionsSection: " "));
Assert.Contains("Router options section is required", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AddRouterMicroservice_WhenTransportPluginIsUnavailable_Throws()
{
var services = new ServiceCollection();
services.AddLogging();
var pluginDir = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "stellaops-router-plugin-missing-" + Guid.NewGuid().ToString("N")));
try
{
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["TimelineIndexer:Router:Enabled"] = "true",
["TimelineIndexer:Router:Region"] = "local",
["TimelineIndexer:Router:Gateways:0:Host"] = "router.stella-ops.local",
["TimelineIndexer:Router:Gateways:0:Port"] = "9100",
["TimelineIndexer:Router:Gateways:0:TransportType"] = "RabbitMq",
["TimelineIndexer:Router:TransportPlugins:Directory"] = pluginDir.FullName,
["TimelineIndexer:Router:TransportPlugins:SearchPattern"] = "StellaOps.Router.Transport.RabbitMq.dll"
});
var ex = Assert.Throws<InvalidOperationException>(() =>
services.AddRouterMicroservice(
configuration,
serviceName: "timelineindexer",
version: "1.0.0",
routerOptionsSection: "TimelineIndexer:Router"));
Assert.Contains("Transport plugin 'rabbitmq' is not available", ex.Message, StringComparison.OrdinalIgnoreCase);
}
finally
{
pluginDir.Delete(recursive: true);
}
}
[Fact]
public async Task AddRouterMicroservice_TcpGateway_ConfiguresTcpClientOptions()
{
var services = new ServiceCollection();
services.AddLogging();
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["TimelineIndexer:Router:Enabled"] = "true",
["TimelineIndexer:Router:Region"] = "local",
["TimelineIndexer:Router:Gateways:0:Host"] = "router.stella-ops.local",
["TimelineIndexer:Router:Gateways:0:Port"] = "9100",
["TimelineIndexer:Router:Gateways:0:TransportType"] = "Tcp"
});
var result = services.AddRouterMicroservice(
configuration,
serviceName: "timelineindexer",
version: "1.0.0",
routerOptionsSection: "TimelineIndexer:Router");
await using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<TcpTransportOptions>>().Value;
var transport = provider.GetRequiredService<IMicroserviceTransport>();
Assert.True(result);
Assert.Equal("router.stella-ops.local", options.Host);
Assert.Equal(9100, options.Port);
Assert.Equal("TcpTransportClient", transport.GetType().Name);
}
[Fact]
public async Task AddRouterMicroservice_MessagingGateway_ConfiguresMessagingAndValkeyOptions()
{
var services = new ServiceCollection();
services.AddLogging();
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["TimelineIndexer:Router:Enabled"] = "true",
["TimelineIndexer:Router:Region"] = "local",
["TimelineIndexer:Router:Gateways:0:Host"] = "router.stella-ops.local",
["TimelineIndexer:Router:Gateways:0:Port"] = "9100",
["TimelineIndexer:Router:Gateways:0:TransportType"] = "Messaging",
["TimelineIndexer:Router:Messaging:RequestQueueTemplate"] = "router:requests:{service}",
["TimelineIndexer:Router:Messaging:ResponseQueueName"] = "router:responses",
["TimelineIndexer:Router:Messaging:ConsumerGroup"] = "timelineindexer",
["TimelineIndexer:Router:Messaging:BatchSize"] = "21",
["TimelineIndexer:Router:Messaging:RequestTimeout"] = "45s",
["TimelineIndexer:Router:Messaging:LeaseDuration"] = "4m",
["TimelineIndexer:Router:Messaging:HeartbeatInterval"] = "12s",
["TimelineIndexer:Router:Messaging:valkey:ConnectionString"] = "cache.stella-ops.local:6379",
["TimelineIndexer:Router:Messaging:valkey:Database"] = "2"
});
var result = services.AddRouterMicroservice(
configuration,
serviceName: "timelineindexer",
version: "1.0.0",
routerOptionsSection: "TimelineIndexer:Router");
await using var provider = services.BuildServiceProvider();
var messaging = provider.GetRequiredService<IOptions<MessagingTransportOptions>>().Value;
var valkey = provider.GetRequiredService<IOptions<StellaOps.Messaging.Transport.Valkey.ValkeyTransportOptions>>().Value;
var transport = provider.GetRequiredService<IMicroserviceTransport>();
Assert.True(result);
Assert.Equal("router:requests:{service}", messaging.RequestQueueTemplate);
Assert.Equal("router:responses", messaging.ResponseQueueName);
Assert.Equal("timelineindexer", messaging.ConsumerGroup);
Assert.Equal(21, messaging.BatchSize);
Assert.Equal(TimeSpan.FromSeconds(45), messaging.RequestTimeout);
Assert.Equal(TimeSpan.FromMinutes(4), messaging.LeaseDuration);
Assert.Equal(TimeSpan.FromSeconds(12), messaging.HeartbeatInterval);
Assert.Equal("cache.stella-ops.local:6379", valkey.ConnectionString);
Assert.Equal(2, valkey.Database);
Assert.Equal("MessagingTransportClient", transport.GetType().Name);
}
[Fact]
public async Task AddRouterMicroservice_AssemblyVersion_NormalizesToSemver()
{
var services = new ServiceCollection();
services.AddLogging();
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["TimelineIndexer:Router:Enabled"] = "true",
["TimelineIndexer:Router:Region"] = "local",
["TimelineIndexer:Router:Gateways:0:Host"] = "router.stella-ops.local",
["TimelineIndexer:Router:Gateways:0:Port"] = "9100",
["TimelineIndexer:Router:Gateways:0:TransportType"] = "Tcp"
});
var result = services.AddRouterMicroservice(
configuration,
serviceName: "timelineindexer",
version: "1.0.0.0",
routerOptionsSection: "TimelineIndexer:Router");
await using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<StellaMicroserviceOptions>>().Value;
Assert.True(result);
Assert.Equal("1.0.0", options.Version);
}
[Fact]
public void AddRouterMicroservice_PreservesAspNetEndpointDiscoveryRegistration()
{
var services = new ServiceCollection();
services.AddLogging();
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["TimelineIndexer:Router:Enabled"] = "true",
["TimelineIndexer:Router:Region"] = "local",
["TimelineIndexer:Router:Gateways:0:Host"] = "router.stella-ops.local",
["TimelineIndexer:Router:Gateways:0:Port"] = "9100",
["TimelineIndexer:Router:Gateways:0:TransportType"] = "Tcp"
});
var result = services.AddRouterMicroservice(
configuration,
serviceName: "timelineindexer",
version: "1.0.0",
routerOptionsSection: "TimelineIndexer:Router");
var discoveryDescriptors = services
.Where(descriptor => descriptor.ServiceType == typeof(IEndpointDiscoveryProvider))
.ToArray();
Assert.True(result);
Assert.Single(discoveryDescriptors);
Assert.NotNull(discoveryDescriptors[0].ImplementationFactory);
}
[Fact]
public async Task AddRouterMicroservice_DiscoversMappedAspNetEndpoints()
{
_ = typeof(StellaOps.Router.Transport.Tcp.TcpTransportPlugin);
var configurationValues = new Dictionary<string, string?>
{
["TimelineIndexer:Router:Enabled"] = "true",
["TimelineIndexer:Router:Region"] = "local",
["TimelineIndexer:Router:Gateways:0:Host"] = "router.stella-ops.local",
["TimelineIndexer:Router:Gateways:0:Port"] = "9100",
["TimelineIndexer:Router:Gateways:0:TransportType"] = "Tcp",
["TimelineIndexer:Router:TransportPlugins:Directory"] = AppContext.BaseDirectory,
["TimelineIndexer:Router:TransportPlugins:SearchPattern"] = "StellaOps.Router.Transport.*.dll"
};
var builder = WebApplication.CreateBuilder();
builder.Configuration.AddInMemoryCollection(configurationValues);
builder.Services.AddAuthorization();
builder.Services.AddLogging();
var routerOptions = builder.Configuration
.GetSection("TimelineIndexer:Router")
.Get<StellaRouterOptionsBase>();
builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "timelineindexer",
version: "1.0.0",
routerOptionsSection: "TimelineIndexer:Router");
var app = builder.Build();
app.UseAuthorization();
app.TryUseStellaRouter(routerOptions);
app.MapGet("/api/v1/timeline", () => "ok").RequireAuthorization();
app.TryRefreshStellaRouterEndpoints(routerOptions);
await app.StartAsync();
try
{
var endpointDataSource = app.Services.GetRequiredService<EndpointDataSource>();
var routeEndpoints = endpointDataSource.Endpoints.OfType<RouteEndpoint>().ToList();
Assert.NotEmpty(routeEndpoints);
var discovery = app.Services.GetRequiredService<IEndpointDiscoveryProvider>();
var endpoints = discovery.DiscoverEndpoints();
Assert.Contains(endpoints, endpoint =>
endpoint.Method == "GET" &&
endpoint.Path == "/api/v1/timeline");
}
finally
{
await app.StopAsync();
await app.DisposeAsync();
}
}
private static IConfiguration BuildConfiguration(Dictionary<string, string?> values)
{
return new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
}
}

View File

@@ -39,7 +39,7 @@ public sealed class StellaRouterOptionsTests
Assert.True(options.EnableStellaEndpoints);
Assert.Equal(DispatchStrategy.AspNetFirst, options.DispatchStrategy);
Assert.Equal(AuthorizationMappingStrategy.Hybrid, options.AuthorizationMapping);
Assert.Equal(MissingAuthorizationBehavior.RequireExplicit, options.OnMissingAuthorization);
Assert.Equal(MissingAuthorizationBehavior.WarnAndAllow, options.OnMissingAuthorization);
Assert.Equal(TimeSpan.FromSeconds(30), options.DefaultTimeout);
Assert.Equal(TimeSpan.FromSeconds(10), options.HeartbeatInterval);
}

View File

@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| RVM-02 | DONE | Added `AddRouterMicroservice()` DI tests for disabled mode, gateway validation, TCP registration, and Valkey messaging options wiring via plugin-based transport activation; extended ASP.NET discovery tests for OpenAPI metadata/schema extraction and typed response-schema selection fallback. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaOps.Router.AspNet.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,63 @@
using System.Text.RegularExpressions;
namespace StellaOps.Router.AspNet.Tests;
public sealed class TransportPluginGuardrailTests
{
private static readonly string[] ForbiddenTransportRegistrations =
[
"AddMessagingTransportClient",
"AddTcpTransportClient",
"AddUdpTransportClient",
"AddRabbitMqTransportClient",
"AddTlsTransportClient"
];
[Fact]
public void WebServiceStartupPaths_MustNotDirectlyRegisterConcreteTransportClients()
{
var repoRoot = ResolveRepositoryRoot();
var sourceRoot = Path.Combine(repoRoot, "src");
var startupFiles = Directory.EnumerateFiles(sourceRoot, "Program.cs", SearchOption.AllDirectories)
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}__Tests{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}examples{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
.ToArray();
var violations = new List<string>();
foreach (var file in startupFiles)
{
var content = File.ReadAllText(file);
foreach (var forbiddenMethod in ForbiddenTransportRegistrations)
{
if (!Regex.IsMatch(content, $@"\b{forbiddenMethod}\s*\(", RegexOptions.CultureInvariant))
{
continue;
}
var relativePath = Path.GetRelativePath(repoRoot, file).Replace('\\', '/');
violations.Add($"{relativePath} uses {forbiddenMethod}()");
}
}
Assert.Empty(violations);
}
private static string ResolveRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")) &&
Directory.Exists(Path.Combine(current.FullName, "src")))
{
return current.FullName;
}
current = current.Parent;
}
throw new InvalidOperationException("Unable to locate repository root from test execution context.");
}
}

View File

@@ -0,0 +1,252 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Transport.Messaging;
using StellaOps.Router.Transport.Messaging.Options;
using StellaOps.Router.Transport.Messaging.Protocol;
using StellaOps.TestKit;
using System.Text.Json;
namespace StellaOps.Router.Common.Tests;
public sealed class MessagingTransportQueueOptionsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task MessagingTransportServer_StartAsync_UsesConfiguredConsumerGroup()
{
var options = Options.Create(new MessagingTransportOptions
{
ConsumerGroup = "router-gateway-test",
BatchSize = 1
});
var queueFactory = new RecordingQueueFactory();
var server = new MessagingTransportServer(
queueFactory,
options,
NullLogger<MessagingTransportServer>.Instance);
await server.StartAsync(CancellationToken.None);
await server.StopAsync(CancellationToken.None);
var requestQueue = queueFactory.CreatedQueues.Single(q =>
q.MessageType == typeof(RpcRequestMessage) &&
q.Options.QueueName == options.Value.GetGatewayControlQueueName());
var responseQueue = queueFactory.CreatedQueues.Single(q =>
q.MessageType == typeof(RpcResponseMessage) &&
q.Options.QueueName == options.Value.ResponseQueueName);
requestQueue.Options.ConsumerGroup.Should().Be("router-gateway-test");
responseQueue.Options.ConsumerGroup.Should().Be("router-gateway-test");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task MessagingTransportClient_ConnectAsync_UsesConfiguredConsumerGroup()
{
var options = Options.Create(new MessagingTransportOptions
{
ConsumerGroup = "timelineindexer-test",
BatchSize = 1
});
var queueFactory = new RecordingQueueFactory();
var client = new MessagingTransportClient(
queueFactory,
options,
NullLogger<MessagingTransportClient>.Instance);
var instance = new InstanceDescriptor
{
InstanceId = "timelineindexer-1",
ServiceName = "timelineindexer",
Version = "1.0.0",
Region = "local"
};
await client.ConnectAsync(
instance,
[
new EndpointDescriptor
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Method = "GET",
Path = "/api/v1/timeline"
}
],
CancellationToken.None);
await client.DisconnectAsync();
queueFactory.CreatedQueues.Should().NotBeEmpty();
queueFactory.CreatedQueues.Should().OnlyContain(q =>
q.Options.ConsumerGroup == "timelineindexer-test");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task MessagingTransportClient_ConnectAsync_IncludesSchemasAndOpenApiInfoInHelloPayload()
{
var options = Options.Create(new MessagingTransportOptions
{
ConsumerGroup = "timelineindexer-test",
BatchSize = 1
});
var queueFactory = new RecordingQueueFactory();
var client = new MessagingTransportClient(
queueFactory,
options,
NullLogger<MessagingTransportClient>.Instance);
var instance = new InstanceDescriptor
{
InstanceId = "timelineindexer-1",
ServiceName = "timelineindexer",
Version = "1.0.0",
Region = "local"
};
var schemaId = "TimelineEvent";
var schemas = new Dictionary<string, SchemaDefinition>
{
[schemaId] = new SchemaDefinition
{
SchemaId = schemaId,
SchemaJson = "{\"type\":\"object\"}",
ETag = "abc123"
}
};
await client.ConnectAsync(
instance,
[
new EndpointDescriptor
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Method = "GET",
Path = "/api/v1/timeline"
}
],
schemas,
new ServiceOpenApiInfo
{
Title = "timelineindexer",
Description = "Timeline service"
},
CancellationToken.None);
await client.DisconnectAsync();
var helloMessage = queueFactory.EnqueuedMessages
.OfType<RpcRequestMessage>()
.First(message => message.FrameType == Common.Enums.FrameType.Hello);
var payload = JsonSerializer.Deserialize<HelloPayload>(
Convert.FromBase64String(helloMessage.PayloadBase64),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
payload.Should().NotBeNull();
payload!.Schemas.Should().ContainKey(schemaId);
payload.Schemas[schemaId].SchemaJson.Should().Be("{\"type\":\"object\"}");
payload.OpenApiInfo.Should().NotBeNull();
payload.OpenApiInfo!.Title.Should().Be("timelineindexer");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void MessagingTransportOptions_DefaultControlQueue_DoesNotCollideWithGatewayServiceQueue()
{
var options = new MessagingTransportOptions();
var controlQueue = options.GetGatewayControlQueueName();
var gatewayServiceQueue = options.GetRequestQueueName("gateway");
controlQueue.Should().NotBe(gatewayServiceQueue);
controlQueue.Should().Be("router:requests:gateway-control");
}
private sealed class RecordingQueueFactory : IMessageQueueFactory
{
public string ProviderName => "test";
public List<CreatedQueue> CreatedQueues { get; } = new();
public List<object> EnqueuedMessages { get; } = new();
public IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options)
where TMessage : class
{
CreatedQueues.Add(new CreatedQueue(typeof(TMessage), CloneOptions(options)));
return new NoOpMessageQueue<TMessage>(options.QueueName, message => EnqueuedMessages.Add(message));
}
private static MessageQueueOptions CloneOptions(MessageQueueOptions options)
{
return new MessageQueueOptions
{
QueueName = options.QueueName,
ConsumerGroup = options.ConsumerGroup,
ConsumerName = options.ConsumerName,
DeadLetterQueue = options.DeadLetterQueue,
DefaultLeaseDuration = options.DefaultLeaseDuration,
MaxDeliveryAttempts = options.MaxDeliveryAttempts,
IdempotencyWindow = options.IdempotencyWindow,
ApproximateMaxLength = options.ApproximateMaxLength,
RetryInitialBackoff = options.RetryInitialBackoff,
RetryMaxBackoff = options.RetryMaxBackoff,
RetryBackoffMultiplier = options.RetryBackoffMultiplier
};
}
}
private sealed class NoOpMessageQueue<TMessage> : IMessageQueue<TMessage>
where TMessage : class
{
private readonly Action<TMessage>? _onEnqueue;
public NoOpMessageQueue(string queueName, Action<TMessage>? onEnqueue = null)
{
QueueName = queueName;
_onEnqueue = onEnqueue;
}
public string ProviderName => "test";
public string QueueName { get; }
public ValueTask<EnqueueResult> EnqueueAsync(
TMessage message,
EnqueueOptions? options = null,
CancellationToken cancellationToken = default)
{
_onEnqueue?.Invoke(message);
return ValueTask.FromResult(EnqueueResult.Succeeded(Guid.NewGuid().ToString("N")));
}
public ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
LeaseRequest request,
CancellationToken cancellationToken = default)
{
return ValueTask.FromResult<IReadOnlyList<IMessageLease<TMessage>>>([]);
}
public ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
ClaimRequest request,
CancellationToken cancellationToken = default)
{
return ValueTask.FromResult<IReadOnlyList<IMessageLease<TMessage>>>([]);
}
public ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(0L);
}
}
private sealed record CreatedQueue(Type MessageType, MessageQueueOptions Options);
}

View File

@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| RVM-06 | DONE | Added messaging HELLO payload coverage to verify schema/openapi metadata propagation from microservice transport client. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Router/__Tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,163 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Authorization;
namespace StellaOps.Router.Gateway.Tests.Authorization;
[Trait("Category", "Unit")]
public sealed class AuthorityClaimsRefreshServiceTests
{
[Fact]
public async Task ExecuteAsync_InitialRefresh_UpdatesClaimsStore()
{
var overrides = BuildOverrides("timeline.read");
var provider = new FakeAuthorityClaimsProvider(overrides);
var store = new RecordingClaimsStore();
using var service = CreateService(provider, store, usePushNotifications: false);
await service.StartAsync(CancellationToken.None);
await WaitForAsync(() => store.AuthorityUpdateCount >= 1);
await service.StopAsync(CancellationToken.None);
store.AuthorityUpdateCount.Should().Be(1);
}
[Fact]
public async Task ExecuteAsync_PushWithUnchangedOverrides_DoesNotUpdateClaimsStoreTwice()
{
var overrides = BuildOverrides("timeline.read");
var provider = new FakeAuthorityClaimsProvider(overrides);
var store = new RecordingClaimsStore();
using var service = CreateService(provider, store, usePushNotifications: true);
await service.StartAsync(CancellationToken.None);
await WaitForAsync(() => store.AuthorityUpdateCount >= 1);
provider.RaiseOverridesChanged(overrides);
await Task.Delay(50);
await service.StopAsync(CancellationToken.None);
store.AuthorityUpdateCount.Should().Be(1);
}
[Fact]
public async Task ExecuteAsync_PushWithChangedOverrides_UpdatesClaimsStoreAgain()
{
var initialOverrides = BuildOverrides("timeline.read");
var changedOverrides = BuildOverrides("timeline.write");
var provider = new FakeAuthorityClaimsProvider(initialOverrides);
var store = new RecordingClaimsStore();
using var service = CreateService(provider, store, usePushNotifications: true);
await service.StartAsync(CancellationToken.None);
await WaitForAsync(() => store.AuthorityUpdateCount >= 1);
provider.RaiseOverridesChanged(changedOverrides);
await WaitForAsync(() => store.AuthorityUpdateCount >= 2);
await service.StopAsync(CancellationToken.None);
store.AuthorityUpdateCount.Should().Be(2);
}
private static AuthorityClaimsRefreshService CreateService(
IAuthorityClaimsProvider provider,
IEffectiveClaimsStore store,
bool usePushNotifications)
{
return new AuthorityClaimsRefreshService(
provider,
store,
Options.Create(new AuthorityConnectionOptions
{
Enabled = true,
AuthorityUrl = "http://authority.stella-ops.local",
RefreshInterval = TimeSpan.FromHours(1),
WaitForAuthorityOnStartup = false,
UseAuthorityPushNotifications = usePushNotifications
}),
NullLogger<AuthorityClaimsRefreshService>.Instance);
}
private static async Task WaitForAsync(Func<bool> condition)
{
var timeout = TimeSpan.FromSeconds(2);
var started = DateTime.UtcNow;
while (!condition())
{
if (DateTime.UtcNow - started > timeout)
{
throw new TimeoutException("Condition was not satisfied within timeout.");
}
await Task.Delay(20);
}
}
private static IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> BuildOverrides(string scope)
{
return new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
{
[EndpointKey.Create("timelineindexer", "GET", "/api/v1/timeline")] =
[
new ClaimRequirement
{
Type = "scope",
Value = scope
}
]
};
}
private sealed class FakeAuthorityClaimsProvider : IAuthorityClaimsProvider
{
private readonly IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> _initialOverrides;
public FakeAuthorityClaimsProvider(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> initialOverrides)
{
_initialOverrides = initialOverrides;
}
public bool IsAvailable => true;
public event EventHandler<ClaimsOverrideChangedEventArgs>? OverridesChanged;
public Task<IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>> GetOverridesAsync(
CancellationToken cancellationToken)
{
return Task.FromResult(_initialOverrides);
}
public void RaiseOverridesChanged(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
{
OverridesChanged?.Invoke(this, new ClaimsOverrideChangedEventArgs
{
Overrides = overrides
});
}
}
private sealed class RecordingClaimsStore : IEffectiveClaimsStore
{
public int AuthorityUpdateCount { get; private set; }
public IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path)
{
return [];
}
public void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints)
{
}
public void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
{
AuthorityUpdateCount++;
}
public void RemoveService(string serviceName)
{
}
}
}

View File

@@ -0,0 +1,212 @@
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Moq;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Middleware;
namespace StellaOps.Router.Gateway.Tests.Middleware;
[Trait("Category", "Unit")]
public sealed class EndpointResolutionMiddlewareTests
{
[Fact]
public async Task Invoke_UsesTranslatedRequestPath_WhenPresent()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/api/v1/advisory-ai/adapters";
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/v1/advisory-ai/adapters";
var resolvedEndpoint = new EndpointDescriptor
{
ServiceName = "advisoryai",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/v1/advisory-ai/adapters"
};
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.ResolveEndpoint(HttpMethods.Get, "/v1/advisory-ai/adapters"))
.Returns(resolvedEndpoint);
var nextCalled = false;
var middleware = new EndpointResolutionMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
// Act
await middleware.Invoke(context, routingState.Object);
// Assert
nextCalled.Should().BeTrue();
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().BeSameAs(resolvedEndpoint);
routingState.Verify(
state => state.ResolveEndpoint(HttpMethods.Get, "/v1/advisory-ai/adapters"),
Times.Once);
routingState.Verify(
state => state.ResolveEndpoint(HttpMethods.Get, "/api/v1/advisory-ai/adapters"),
Times.Never);
}
[Fact]
public async Task Invoke_UsesRouteTargetMicroserviceHint_WhenPresent()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/advisoryai";
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/";
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "advisoryai";
var signerRoot = new EndpointDescriptor
{
ServiceName = "signer",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/"
};
var advisoryRoot = new EndpointDescriptor
{
ServiceName = "advisoryai",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/"
};
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.GetAllConnections())
.Returns(
[
CreateConnection("conn-signer", "signer", signerRoot),
CreateConnection("conn-advisory", "advisoryai", advisoryRoot)
]);
var middleware = new EndpointResolutionMiddleware(_ => Task.CompletedTask);
// Act
await middleware.Invoke(context, routingState.Object);
// Assert
context.Response.StatusCode.Should().NotBe(StatusCodes.Status404NotFound);
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().BeSameAs(advisoryRoot);
context.Items[RouterHttpContextKeys.TargetMicroservice].Should().Be("advisoryai");
routingState.Verify(
state => state.ResolveEndpoint(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task Invoke_DoesNotFallbackToDifferentService_WhenTargetHintHasNoMatch()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/api/v1/timeline/events";
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/api/v1/timeline/events";
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "timelineindexer";
var policyEndpoint = new EndpointDescriptor
{
ServiceName = "policy-gateway",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/api/v1/timeline/events"
};
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.GetAllConnections())
.Returns(
[
CreateConnection("conn-policy", "policy-gateway", policyEndpoint)
]);
routingState
.Setup(state => state.ResolveEndpoint(HttpMethods.Get, "/api/v1/timeline/events"))
.Returns(policyEndpoint);
var nextCalled = false;
var middleware = new EndpointResolutionMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
// Act
await middleware.Invoke(context, routingState.Object);
// Assert
nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
routingState.Verify(
state => state.ResolveEndpoint(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task Invoke_MatchesRouteHintWithServicePrefixAlias()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/findingsLedger";
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/";
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "findings";
var findingsRoot = new EndpointDescriptor
{
ServiceName = "findings-ledger",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/"
};
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.GetAllConnections())
.Returns(
[
CreateConnection("conn-findings", "findings-ledger", findingsRoot)
]);
var middleware = new EndpointResolutionMiddleware(_ => Task.CompletedTask);
// Act
await middleware.Invoke(context, routingState.Object);
// Assert
context.Response.StatusCode.Should().NotBe(StatusCodes.Status404NotFound);
context.Items[RouterHttpContextKeys.TargetMicroservice].Should().Be("findings-ledger");
}
private static ConnectionState CreateConnection(
string connectionId,
string serviceName,
EndpointDescriptor endpoint)
{
var instance = new InstanceDescriptor
{
InstanceId = connectionId,
ServiceName = serviceName,
Version = endpoint.Version,
Region = "local"
};
var connection = new ConnectionState
{
ConnectionId = connectionId,
Instance = instance,
TransportType = StellaOps.Router.Common.Enums.TransportType.Messaging
};
connection.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
return connection;
}
}

View File

@@ -0,0 +1,116 @@
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Middleware;
using System.Text;
namespace StellaOps.Router.Gateway.Tests.Middleware;
[Trait("Category", "Unit")]
public sealed class TransportDispatchMiddlewareTests
{
[Fact]
public async Task Invoke_UsesTranslatedRequestPath_WhenBuildingTransportFrame()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Post;
context.Request.Path = "/api/v1/advisory-ai/adapters/openai/chat/completions";
context.Request.QueryString = new QueryString("?model=gpt-4o-mini");
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{\"prompt\":\"hello\"}"));
context.Response.Body = new MemoryStream();
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/v1/advisory-ai/adapters/openai/chat/completions";
var endpoint = new EndpointDescriptor
{
ServiceName = "advisoryai",
Version = "1.0.0",
Method = HttpMethods.Post,
Path = "/v1/advisory-ai/adapters/openai/chat/completions"
};
var connection = new ConnectionState
{
ConnectionId = "conn-1",
Instance = new InstanceDescriptor
{
InstanceId = "advisoryai-1",
ServiceName = "advisoryai",
Version = "1.0.0",
Region = "local"
},
TransportType = TransportType.Messaging
};
var decision = new RoutingDecision
{
Endpoint = endpoint,
Connection = connection,
TransportType = TransportType.Messaging,
EffectiveTimeout = TimeSpan.FromSeconds(30)
};
context.Items[RouterHttpContextKeys.RoutingDecision] = decision;
Frame? sentFrame = null;
var transportClient = new Mock<ITransportClient>();
transportClient
.Setup(client => client.SendRequestAsync(
connection,
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((_, frame, _, _) =>
{
sentFrame = frame;
})
.ReturnsAsync(() => FrameConverter.ToFrame(new ResponseFrame
{
RequestId = Guid.NewGuid().ToString("N"),
StatusCode = StatusCodes.Status200OK,
Payload = Encoding.UTF8.GetBytes("{\"ok\":true}")
}));
transportClient
.Setup(client => client.SendCancelAsync(connection, It.IsAny<Guid>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
transportClient
.Setup(client => client.SendStreamingAsync(
connection,
It.IsAny<Frame>(),
It.IsAny<Stream>(),
It.IsAny<Func<Stream, Task>>(),
It.IsAny<PayloadLimits>(),
It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.UpdateConnection(connection.ConnectionId, It.IsAny<Action<ConnectionState>>()))
.Callback<string, Action<ConnectionState>>((_, update) => update(connection));
var environment = new Mock<IHostEnvironment>();
environment.SetupGet(env => env.EnvironmentName).Returns(Environments.Production);
var middleware = new TransportDispatchMiddleware(
_ => Task.CompletedTask,
NullLogger<TransportDispatchMiddleware>.Instance,
environment.Object);
// Act
await middleware.Invoke(context, transportClient.Object, routingState.Object);
// Assert
sentFrame.Should().NotBeNull();
var requestFrame = FrameConverter.ToRequestFrame(sentFrame!);
requestFrame.Should().NotBeNull();
requestFrame!.Path.Should().Be("/v1/advisory-ai/adapters/openai/chat/completions?model=gpt-4o-mini");
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
}
}

View File

@@ -0,0 +1,543 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Authorization;
using StellaOps.Router.Gateway.Configuration;
using StellaOps.Router.Gateway.OpenApi;
using System.Text.Json.Nodes;
namespace StellaOps.Router.Gateway.Tests.OpenApi;
[Trait("Category", "Unit")]
public sealed class OpenApiDocumentGeneratorTests
{
[Fact]
public void GenerateDocument_ProjectsPaths_ToGatewayMicroserviceRoutes()
{
// Arrange
var endpoint = new EndpointDescriptor
{
ServiceName = "advisoryai",
Version = "1.0.0",
Method = "GET",
Path = "/v1/advisory-ai/adapters",
SchemaInfo = new EndpointSchemaInfo
{
Summary = "List AI adapters",
Description = "Returns available advisory AI adapters."
}
};
var connection = new ConnectionState
{
ConnectionId = "conn-1",
Instance = new InstanceDescriptor
{
InstanceId = "advisoryai-1",
ServiceName = "advisoryai",
Version = "1.0.0",
Region = "local"
},
TransportType = TransportType.Messaging
};
connection.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.GetAllConnections())
.Returns([connection]);
var routeCatalog = new GatewayRouteCatalog(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = "/api/v1/advisory-ai/adapters",
TranslatesTo = "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters"
}
]);
var generator = new OpenApiDocumentGenerator(
routingState.Object,
Options.Create(new OpenApiAggregationOptions()),
routeCatalog);
// Act
var documentJson = generator.GenerateDocument();
var document = JsonNode.Parse(documentJson)!.AsObject();
var paths = document["paths"]!.AsObject();
// Assert
paths.ContainsKey("/api/v1/advisory-ai/adapters").Should().BeTrue();
paths.ContainsKey("/v1/advisory-ai/adapters").Should().BeFalse();
}
[Fact]
public void GenerateDocument_DoesNotCrossProjectRootRoutes_AcrossServices()
{
// Arrange
var advisoryEndpoint = new EndpointDescriptor
{
ServiceName = "advisoryai",
Version = "1.0.0",
Method = "GET",
Path = "/v1/advisory-ai/adapters"
};
var platformEndpoint = new EndpointDescriptor
{
ServiceName = "platform",
Version = "1.0.0",
Method = "GET",
Path = "/api/admin/tenants"
};
var advisoryConnection = CreateConnection("advisoryai", advisoryEndpoint);
var platformConnection = CreateConnection("platform", platformEndpoint);
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.GetAllConnections())
.Returns([advisoryConnection, platformConnection]);
var routeCatalog = new GatewayRouteCatalog(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = "/advisoryai",
TranslatesTo = "http://advisoryai.stella-ops.local"
},
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = "/platform",
TranslatesTo = "http://platform.stella-ops.local"
}
]);
var generator = new OpenApiDocumentGenerator(
routingState.Object,
Options.Create(new OpenApiAggregationOptions()),
routeCatalog);
// Act
var documentJson = generator.GenerateDocument();
var document = JsonNode.Parse(documentJson)!.AsObject();
var paths = document["paths"]!.AsObject();
// Assert
paths.ContainsKey("/advisoryai/v1/advisory-ai/adapters").Should().BeTrue();
paths.ContainsKey("/platform/api/admin/tenants").Should().BeTrue();
paths.ContainsKey("/advisoryai/api/admin/tenants").Should().BeFalse();
paths.ContainsKey("/platform/v1/advisory-ai/adapters").Should().BeFalse();
}
[Fact]
public void GenerateDocument_UsesRoutePathServiceKey_WhenTranslatesToHostIsGeneric()
{
// Arrange
var vulnEndpoint = new EndpointDescriptor
{
ServiceName = "vulnexplorer",
Version = "1.0.0",
Method = "GET",
Path = "/api/vuln-explorer/search"
};
var advisoryEndpoint = new EndpointDescriptor
{
ServiceName = "advisoryai",
Version = "1.0.0",
Method = "GET",
Path = "/v1/advisory-ai/adapters"
};
var vulnConnection = CreateConnection("vulnexplorer", vulnEndpoint);
var advisoryConnection = CreateConnection("advisoryai", advisoryEndpoint);
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.GetAllConnections())
.Returns([vulnConnection, advisoryConnection]);
var routeCatalog = new GatewayRouteCatalog(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = "/vulnexplorer",
TranslatesTo = "http://api.stella-ops.local"
}
]);
var generator = new OpenApiDocumentGenerator(
routingState.Object,
Options.Create(new OpenApiAggregationOptions()),
routeCatalog);
// Act
var documentJson = generator.GenerateDocument();
var document = JsonNode.Parse(documentJson)!.AsObject();
var paths = document["paths"]!.AsObject();
// Assert
paths.ContainsKey("/vulnexplorer/api/vuln-explorer/search").Should().BeTrue();
paths.ContainsKey("/vulnexplorer/v1/advisory-ai/adapters").Should().BeFalse();
}
[Fact]
public void GenerateDocument_SkipsUnmappedEndpoints_WhenRouteCatalogIsProvided()
{
// Arrange
var graphEndpoint = new EndpointDescriptor
{
ServiceName = "graph",
Version = "1.0.0",
Method = "POST",
Path = "/graph/diff"
};
var graphConnection = CreateConnection("graph", graphEndpoint);
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.GetAllConnections())
.Returns([graphConnection]);
var routeCatalog = new GatewayRouteCatalog(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = "/api/v1/advisory-ai/adapters",
TranslatesTo = "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters"
}
]);
var generator = new OpenApiDocumentGenerator(
routingState.Object,
Options.Create(new OpenApiAggregationOptions()),
routeCatalog);
// Act
var documentJson = generator.GenerateDocument();
var document = JsonNode.Parse(documentJson)!.AsObject();
var paths = document["paths"]!.AsObject();
// Assert
paths.ContainsKey("/graph/diff").Should().BeFalse();
}
[Fact]
public void GenerateDocument_AuthOnlyEndpoint_EmitsBearerSecurityAndGatewayAuthMetadata()
{
var endpoint = new EndpointDescriptor
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Method = "GET",
Path = "/api/v1/timeline",
AllowAnonymous = false,
RequiresAuthentication = true,
AuthorizationSource = EndpointAuthorizationSource.AspNetMetadata
};
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["security"].Should().NotBeNull();
operation["x-stellaops-gateway-auth"]!["requiresAuthentication"]!.GetValue<bool>().Should().BeTrue();
operation["x-stellaops-gateway-auth"]!["allowAnonymous"]!.GetValue<bool>().Should().BeFalse();
}
[Fact]
public void GenerateDocument_LegacyEndpointWithoutAuthFlag_FailsClosedInAuthMetadata()
{
var endpoint = new EndpointDescriptor
{
ServiceName = "notifier",
Version = "1.0.0",
Method = "POST",
Path = "/api/v2/incidents/{deliveryId}/ack",
AllowAnonymous = false,
RequiresAuthentication = false,
AuthorizationSource = EndpointAuthorizationSource.None
};
var routingState = new Mock<IGlobalRoutingState>();
routingState.Setup(state => state.GetAllConnections()).Returns([CreateConnection("notifier", endpoint)]);
var generator = new OpenApiDocumentGenerator(
routingState.Object,
Options.Create(new OpenApiAggregationOptions()));
var document = JsonNode.Parse(generator.GenerateDocument())!.AsObject();
var operation = document["paths"]!["/api/v2/incidents/{deliveryId}/ack"]!["post"]!.AsObject();
operation["security"].Should().NotBeNull();
operation["x-stellaops-gateway-auth"]!["allowAnonymous"]!.GetValue<bool>().Should().BeFalse();
operation["x-stellaops-gateway-auth"]!["requiresAuthentication"]!.GetValue<bool>().Should().BeTrue();
}
[Fact]
public void GenerateDocument_EmitsTimeoutExtensionWithEffectiveSeconds()
{
var endpoint = new EndpointDescriptor
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Method = "GET",
Path = "/api/v1/timeline",
DefaultTimeout = TimeSpan.FromSeconds(90)
};
var routingState = new Mock<IGlobalRoutingState>();
routingState.Setup(state => state.GetAllConnections()).Returns([CreateConnection("timelineindexer", endpoint)]);
var generator = new OpenApiDocumentGenerator(
routingState.Object,
Options.Create(new OpenApiAggregationOptions()),
routeCatalog: null,
routingOptions: Options.Create(new RoutingOptions
{
RoutingTimeoutMs = 30000,
GlobalTimeoutCapMs = 20000
}));
var document = JsonNode.Parse(generator.GenerateDocument())!.AsObject();
var operation = document["paths"]!["/api/v1/timeline"]!["get"]!.AsObject();
var timeout = operation["x-stellaops-timeout"]!.AsObject();
timeout["effectiveSeconds"]!.GetValue<int>().Should().Be(20);
timeout["source"]!.GetValue<string>().Should().Be("endpointCapped");
operation["x-stellaops-timeout-seconds"]!.GetValue<int>().Should().Be(20);
}
[Fact]
public void GenerateDocument_ScopedEndpoint_EmitsOAuth2ScopeRequirements()
{
var endpoint = new EndpointDescriptor
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Method = "POST",
Path = "/api/v1/timeline/events",
AllowAnonymous = false,
RequiresAuthentication = true,
RequiringClaims =
[
new ClaimRequirement
{
Type = "scope",
Value = "timeline.write"
}
]
};
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/events"]!["post"]!.AsObject();
var securityArray = operation["security"]!.AsArray();
var requirement = securityArray[0]!.AsObject();
requirement.ContainsKey("BearerAuth").Should().BeTrue();
requirement["OAuth2"]!.AsArray().Select(node => node!.GetValue<string>())
.Should().ContainSingle(scope => scope == "timeline.write");
}
[Fact]
public void GenerateDocument_AuthorityOverrideClaims_UsesEffectiveClaimsForSecurityAndExtensions()
{
var endpoint = new EndpointDescriptor
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Method = "GET",
Path = "/api/v1/timeline",
AllowAnonymous = false,
RequiresAuthentication = true,
RequiringClaims =
[
new ClaimRequirement
{
Type = "scope",
Value = "timeline.read"
}
]
};
var routingState = new Mock<IGlobalRoutingState>();
routingState.Setup(state => state.GetAllConnections()).Returns([CreateConnection("timelineindexer", endpoint)]);
var effectiveClaimsStore = new TestEffectiveClaimsStore();
effectiveClaimsStore.UpdateFromAuthority(
new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
{
[EndpointKey.Create("timelineindexer", "GET", "/api/v1/timeline")] =
[
new ClaimRequirement
{
Type = "scope",
Value = "timeline.override"
}
]
});
var generator = new OpenApiDocumentGenerator(
routingState.Object,
Options.Create(new OpenApiAggregationOptions()),
routeCatalog: null,
routingOptions: null,
effectiveClaimsStore);
var document = JsonNode.Parse(generator.GenerateDocument())!.AsObject();
var operation = document["paths"]!["/api/v1/timeline"]!["get"]!.AsObject();
var security = operation["security"]!.AsArray()[0]!.AsObject();
var authExtension = operation["x-stellaops-gateway-auth"]!.AsObject();
security["OAuth2"]!.AsArray().Select(node => node!.GetValue<string>())
.Should().ContainSingle(scope => scope == "timeline.override");
authExtension["effectiveClaimSource"]!.GetValue<string>().Should().Be("AuthorityOverride");
authExtension["claimRequirements"]!.AsArray()
.Select(node => node!["value"]!.GetValue<string>())
.Should().ContainSingle(scope => scope == "timeline.override");
}
[Fact]
public void GenerateDocument_ResponseStatusCodeFromEndpointMetadata_PrefersEndpointStatus()
{
var endpoint = new EndpointDescriptor
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Method = "POST",
Path = "/api/v1/timeline/events",
SchemaInfo = new EndpointSchemaInfo
{
ResponseSchemaId = "TimelineEventCreated",
ResponseStatusCode = 201
}
};
var connection = CreateConnection(
"timelineindexer",
endpoint,
new Dictionary<string, SchemaDefinition>
{
["TimelineEventCreated"] = new()
{
SchemaId = "TimelineEventCreated",
SchemaJson = "{\"type\":\"object\"}",
ETag = "schema"
}
});
var routingState = new Mock<IGlobalRoutingState>();
routingState.Setup(state => state.GetAllConnections()).Returns([connection]);
var generator = new OpenApiDocumentGenerator(
routingState.Object,
Options.Create(new OpenApiAggregationOptions()));
var document = JsonNode.Parse(generator.GenerateDocument())!.AsObject();
var responses = document["paths"]!["/api/v1/timeline/events"]!["post"]!["responses"]!.AsObject();
responses.ContainsKey("201").Should().BeTrue();
responses.ContainsKey("200").Should().BeFalse();
}
[Fact]
public void GenerateDocument_WithoutExplicitDescription_UsesSummaryAsDescriptionFallback()
{
var endpoint = new EndpointDescriptor
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Method = "GET",
Path = "/api/v1/timeline"
};
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 /api/v1/timeline");
operation["description"]!.GetValue<string>().Should().Be("GET /api/v1/timeline");
}
private static ConnectionState CreateConnection(
string serviceName,
EndpointDescriptor endpoint,
IReadOnlyDictionary<string, SchemaDefinition>? schemas = null)
{
var connection = new ConnectionState
{
ConnectionId = $"conn-{serviceName}",
Instance = new InstanceDescriptor
{
InstanceId = $"{serviceName}-1",
ServiceName = serviceName,
Version = "1.0.0",
Region = "local"
},
TransportType = TransportType.Messaging,
Schemas = schemas ?? new Dictionary<string, SchemaDefinition>()
};
connection.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
return connection;
}
private sealed class TestEffectiveClaimsStore : IEffectiveClaimsStore
{
private readonly Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> _authority = new();
public IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path)
{
var key = EndpointKey.Create(serviceName, method, path);
return _authority.TryGetValue(key, out var claims) ? claims : [];
}
public void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints)
{
// Not needed for this test fixture.
}
public void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
{
_authority.Clear();
foreach (var entry in overrides)
{
_authority[entry.Key] = entry.Value;
}
}
public void RemoveService(string serviceName)
{
// Not needed for this test fixture.
}
}
}

View File

@@ -0,0 +1,128 @@
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Configuration;
using StellaOps.Router.Gateway.Routing;
namespace StellaOps.Router.Gateway.Tests.Routing;
[Trait("Category", "Unit")]
public sealed class DefaultRoutingPluginTests
{
[Fact]
public async Task ChooseInstanceAsync_EndpointTimeout_IsCappedByGlobalCap()
{
var endpoint = CreateEndpoint(TimeSpan.FromSeconds(90));
var plugin = CreatePlugin(
new RoutingOptions
{
RoutingTimeoutMs = 30000,
GlobalTimeoutCapMs = 20000
});
var decision = await plugin.ChooseInstanceAsync(
CreateRoutingContext(endpoint, routeDefaultTimeout: TimeSpan.FromSeconds(10)),
CancellationToken.None);
Assert.NotNull(decision);
Assert.Equal(TimeSpan.FromSeconds(20), decision!.EffectiveTimeout);
}
[Fact]
public async Task ChooseInstanceAsync_UsesRouteDefaultTimeout_WhenEndpointTimeoutMissing()
{
var endpoint = CreateEndpoint(TimeSpan.Zero);
var plugin = CreatePlugin(
new RoutingOptions
{
RoutingTimeoutMs = 30000,
GlobalTimeoutCapMs = 120000
});
var decision = await plugin.ChooseInstanceAsync(
CreateRoutingContext(endpoint, routeDefaultTimeout: TimeSpan.FromSeconds(12)),
CancellationToken.None);
Assert.NotNull(decision);
Assert.Equal(TimeSpan.FromSeconds(12), decision!.EffectiveTimeout);
}
[Fact]
public async Task ChooseInstanceAsync_UsesGatewayRouteDefault_WhenNoEndpointOrRouteTimeout()
{
var endpoint = CreateEndpoint(TimeSpan.Zero);
var plugin = CreatePlugin(
new RoutingOptions
{
RoutingTimeoutMs = 25000,
GlobalTimeoutCapMs = 120000
});
var decision = await plugin.ChooseInstanceAsync(
CreateRoutingContext(endpoint, routeDefaultTimeout: null),
CancellationToken.None);
Assert.NotNull(decision);
Assert.Equal(TimeSpan.FromSeconds(25), decision!.EffectiveTimeout);
}
private static DefaultRoutingPlugin CreatePlugin(RoutingOptions options)
{
return new DefaultRoutingPlugin(
Options.Create(options),
Options.Create(new RouterNodeConfig
{
Region = "local",
NodeId = "gateway-1",
Environment = "dev"
}));
}
private static RoutingContext CreateRoutingContext(
EndpointDescriptor endpoint,
TimeSpan? routeDefaultTimeout)
{
return new RoutingContext
{
Method = endpoint.Method,
Path = endpoint.Path,
Endpoint = endpoint,
Headers = new Dictionary<string, string>(),
AvailableConnections = [CreateConnection(endpoint)],
GatewayRegion = "local",
RouteDefaultTimeout = routeDefaultTimeout,
CancellationToken = CancellationToken.None
};
}
private static ConnectionState CreateConnection(EndpointDescriptor endpoint)
{
var connection = new ConnectionState
{
ConnectionId = "conn-1",
Instance = new InstanceDescriptor
{
InstanceId = "svc-1",
ServiceName = endpoint.ServiceName,
Version = endpoint.Version,
Region = "local"
},
Status = InstanceHealthStatus.Healthy,
TransportType = TransportType.Messaging
};
connection.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
return connection;
}
private static EndpointDescriptor CreateEndpoint(TimeSpan timeout)
{
return new EndpointDescriptor
{
ServiceName = "timelineindexer",
Version = "1.0.0",
Method = "GET",
Path = "/api/v1/timeline",
DefaultTimeout = timeout
};
}
}