Router · ASP.NET Endpoint Bridge
Status
- Version: 0.2 (revised)
- Last updated: 2026-08-28 (UTC)
- Implementation sprint:
SPRINT_8100_0011_0001
Problem Statement
StellaOps Router routes external HTTP requests (via Gateway) to internal microservices over binary transports (TCP/TLS/Messaging). Most StellaOps microservices are ASP.NET Core WebServices that register routes via standard ASP.NET endpoint registration (controllers or minimal APIs).
Current behavior: StellaOps.Microservice discovers and registers only Router-native endpoints implemented via [StellaEndpoint] handlers. If a service has only ASP.NET endpoints, it either:
- Registers zero Router endpoints (Gateway cannot route to it), or
- Duplicates routes as
[StellaEndpoint]handlers (route drift + duplicated security metadata).
Desired behavior: Treat ASP.NET endpoint registration as the single source of truth and automatically bridge it to Router endpoint registration.
Design Goals
| Goal | Description |
|---|---|
| Single source of truth | ASP.NET endpoint registration defines what the service exposes |
| Full ASP.NET fidelity | Authorization, filters, model binding, and all ASP.NET features work correctly |
| Determinism | Endpoint discovery produces stable ordering and normalized paths |
| Security alignment | Authorization metadata flows to Router RequiringClaims |
| Opt-in integration | Services explicitly enable the bridge via Program.cs |
| Offline-first | No runtime network dependency for discovery/dispatch |
Non-Goals (v0.1)
| Feature | Reason |
|---|---|
| SignalR/WebSocket support | Different protocol semantics |
| gRPC endpoint bridging | Different protocol |
| Streaming request bodies | Router SDK buffering limitation |
| Custom route constraints | Complexity; document as limitation |
| API versioning (header/query) | Complexity; use path-based versioning |
| Automatic schema generation | Depends on source generators; future enhancement |
Architecture Overview
┌─────────────────────────────────────────────────────────────────────┐
│ ASP.NET WebService │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Program.cs │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ builder.Services.AddStellaRouterBridge(options => { │ │ │
│ │ │ options.ServiceName = "scanner"; │ │ │
│ │ │ options.Version = "1.0.0"; │ │ │
│ │ │ }); │ │ │
│ │ │ app.UseStellaRouterBridge(); │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ StellaOps.Microservice.AspNetCore │ │
│ │ ┌─────────────────────┐ ┌─────────────────────────────────┐ │ │
│ │ │ Discovery Provider │ │ Request Dispatcher │ │ │
│ │ │ ─────────────────── │ │ ─────────────────────────────── │ │ │
│ │ │ • EndpointDataSource│ │ • RequestFrame → HttpContext │ │ │
│ │ │ • Metadata extraction│ │ • ASP.NET pipeline execution │ │ │
│ │ │ • Route normalization│ │ • ResponseFrame capture │ │ │
│ │ │ • Auth claim mapping │ │ • DI scope management │ │ │
│ │ └──────────┬──────────┘ └──────────────┬──────────────────┘ │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ Router SDK (StellaOps.Microservice) │ │ │
│ │ │ • HELLO registration with discovered endpoints │ │ │
│ │ │ • Request routing to dispatcher │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ ASP.NET Endpoints (unchanged) │ │
│ │ • Minimal APIs: app.MapGet("/api/...", handler) │ │
│ │ • Controllers: [ApiController] with [HttpGet], etc. │ │
│ │ • Route groups: app.MapGroup("/api").MapScannerEndpoints() │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
│ Router Transport (TCP/TLS/Messaging)
▼
┌───────────────────────────────┐
│ StellaOps.Gateway.WebService │
│ • HELLO processing │
│ • Endpoint routing │
│ • OpenAPI aggregation │
└───────────────────────────────┘
Component Design
1. StellaRouterBridgeOptions
Configuration for the bridge, specified in Program.cs:
public sealed class StellaRouterBridgeOptions
{
// === Required: Service Identity ===
public required string ServiceName { get; set; }
public required string Version { get; set; }
public required string Region { get; set; }
public string? InstanceId { get; set; } // Auto-generated if null
// === Authorization Mapping ===
public AuthorizationMappingStrategy AuthorizationMapping { get; set; }
= AuthorizationMappingStrategy.Hybrid;
public MissingAuthorizationBehavior OnMissingAuthorization { get; set; }
= MissingAuthorizationBehavior.RequireExplicit;
// === YAML Overrides ===
public string? YamlConfigPath { get; set; }
// === Metadata Extraction ===
public bool ExtractSchemas { get; set; } = true;
public bool ExtractOpenApiMetadata { get; set; } = true;
// === Route Handling ===
public UnsupportedConstraintBehavior OnUnsupportedConstraint { get; set; }
= UnsupportedConstraintBehavior.WarnAndStrip;
public Func<RouteEndpoint, bool>? EndpointFilter { get; set; }
// === Publication carve-out (see "Excluded path prefixes" below) ===
public IList<string> ExcludedPathPrefixes { get; set; } = new List<string>
{
"/health", "/healthz", "/readyz", "/livez",
"/metrics", "/swagger", "/openapi", "/.well-known/openapi"
};
public bool IncludeExcludedPathsInRouter { get; set; }
// === Defaults ===
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
}
public enum AuthorizationMappingStrategy
{
YamlOnly, // Only use YAML overrides
AspNetMetadataOnly, // Only use ASP.NET metadata
Hybrid // ASP.NET + YAML (YAML wins on conflict)
}
public enum MissingAuthorizationBehavior
{
RequireExplicit, // Fail if no auth metadata
AllowAuthenticated, // Allow with empty claims (authenticated required)
WarnAndAllow // Log warning, allow with empty claims
}
public enum UnsupportedConstraintBehavior
{
Fail, // Fail discovery on unsupported constraint
WarnAndStrip, // Log warning, strip constraint
SilentStrip // Strip without warning
}
2. AspNetEndpointDescriptor
Extended endpoint descriptor with full ASP.NET metadata:
public sealed record AspNetEndpointDescriptor
{
// === Core Identity ===
public required string ServiceName { get; init; }
public required string Version { get; init; }
public required string Method { get; init; }
public required string Path { get; init; }
public TimeSpan DefaultTimeout { get; init; } = TimeSpan.FromSeconds(30);
public bool SupportsStreaming { get; init; }
// === Authorization ===
public IReadOnlyList<ClaimRequirement> RequiringClaims { get; init; } = [];
public IReadOnlyList<string> AuthorizationPolicies { get; init; } = [];
public IReadOnlyList<string> Roles { get; init; } = [];
public bool AllowAnonymous { get; init; }
public AuthorizationSource AuthorizationSource { get; init; }
// === Parameters ===
public IReadOnlyList<ParameterDescriptor> Parameters { get; init; } = [];
// === Responses ===
public IReadOnlyList<ResponseDescriptor> Responses { get; init; } = [];
// === OpenAPI ===
public string? OperationId { get; init; }
public string? Summary { get; init; }
public string? Description { get; init; }
public IReadOnlyList<string> Tags { get; init; } = [];
// === Schema ===
public EndpointSchemaInfo? SchemaInfo { get; init; }
// === Internal ===
internal RouteEndpoint? OriginalEndpoint { get; init; }
internal string? OriginalRoutePattern { get; init; }
/// <summary>
/// Convert to standard EndpointDescriptor for HELLO payload.
/// </summary>
public EndpointDescriptor ToEndpointDescriptor() => new()
{
ServiceName = ServiceName,
Version = Version,
Method = Method,
Path = Path,
DefaultTimeout = DefaultTimeout,
SupportsStreaming = SupportsStreaming,
RequiringClaims = RequiringClaims,
SchemaInfo = SchemaInfo
};
}
public sealed record ParameterDescriptor
{
public required string Name { get; init; }
public required ParameterSource Source { get; init; }
public required Type Type { get; init; }
public bool IsRequired { get; init; } = true;
public object? DefaultValue { get; init; }
public string? Description { get; init; }
}
public enum ParameterSource { Route, Query, Header, Body, Services }
public sealed record ResponseDescriptor
{
public required int StatusCode { get; init; }
public Type? ResponseType { get; init; }
public string? Description { get; init; }
public string? ContentType { get; init; } = "application/json";
}
public enum AuthorizationSource { None, AspNetMetadata, YamlOverride, Hybrid }
3. IAspNetEndpointDiscoveryProvider
Discovery provider interface:
public interface IAspNetEndpointDiscoveryProvider : IEndpointDiscoveryProvider
{
/// <summary>
/// Discover ASP.NET endpoints with full metadata.
/// </summary>
IReadOnlyList<AspNetEndpointDescriptor> DiscoverAspNetEndpoints();
}
4. IAuthorizationClaimMapper
Authorization-to-claims mapping interface:
public interface IAuthorizationClaimMapper
{
/// <summary>
/// Map ASP.NET authorization metadata to Router claim requirements.
/// </summary>
Task<AuthorizationMappingResult> MapAsync(
RouteEndpoint endpoint,
CancellationToken cancellationToken = default);
}
public sealed record AuthorizationMappingResult
{
public IReadOnlyList<ClaimRequirement> Claims { get; init; } = [];
public IReadOnlyList<string> Policies { get; init; } = [];
public IReadOnlyList<string> Roles { get; init; } = [];
public bool AllowAnonymous { get; init; }
public AuthorizationSource Source { get; init; }
}
5. IAspNetRouterRequestDispatcher
Request dispatch interface (the matched endpoint delegate runs, not the complete HTTP middleware pipeline):
public interface IAspNetRouterRequestDispatcher
{
/// <summary>
/// Dispatch a Router request frame to its matched ASP.NET endpoint delegate.
/// </summary>
Task<ResponseFrame> DispatchAsync(
RequestFrame request,
CancellationToken cancellationToken = default);
}
Verified against source 00f64ca4d165fc54e7c291795b65326869b7627d (2026-08-31), with the full Router ASP.NET suite112/112: the service dispatcher uses ASP.NET’s template matcher for literals, parameters, catch-alls and complex segments such as {version:int}:activate. It then applies the existing route constraints and endpoint ranking before invoking the handler. A suffix is part of the route, not a literalized parameter name; malformed or overflow integer values do not invoke the activation handler. Matching does not replace service authorization or tenant endpoint filters.
Re-verify with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaOps.Router.AspNet.Tests.csproj -Class '*AspNetRouterRequestDispatcherTests' -BuildProjectReferences -Restore. The complex-segment cases check actual parameter binding, suffix/method/constraint rejection, encoded values and optional separators; the adjacent cases retain signed-envelope, cancellation and catch-all guards. A new service image is required to exercise the changed dispatcher live.
Endpoint Discovery Algorithm
Step 1: Enumerate Endpoints
var endpoints = endpointDataSource.Endpoints
.OfType<RouteEndpoint>()
.Where(e => e.Metadata.GetMetadata<HttpMethodMetadata>() is not null)
.Where(options.EndpointFilter ?? (_ => true))
.Where(e => !IsExcludedPath(Normalize(e.RoutePattern))); // ExcludedPathPrefixes
Excluded path prefixes
ExcludedPathPrefixes withholds matching paths from the HELLO payload only. The dispatcher matches against the raw ASP.NET EndpointDataSource, so an excluded path is unpublished, never unservable — a gateway-side change to how these paths are reached needs no service-side change. Matching is StartsWith, and IncludeExcludedPathsInRouter disables the filter wholesale.
Under the 2026-08-28 owner ruling every microservice auto-publishes, so this list is the estate’s only structural carve-out and every entry needs a recorded reason. The retained entries share one: they are per-instance or flat-namespace signals the gateway’s unhinted global resolver cannot own. Published endpoints resolve without a service hint, and identical templates are ranked by specificity, then health, then last heartbeat — so 30 services publishing GET /healthz would hand the gateway whichever service heartbeated most recently. /.well-known/openapi is on the list for the same reason (seven services declare that identical template, the gateway owns the path as a system path, and OpenApiAggregator fetches each document by direct HTTP).
The rest of /.well-known is publishable and must not be excluded: a bare /.well-known entry withheld Authority’s GET /.well-known/openid-configuration — an endpoint written specifically so the bridge could publish OIDC discovery, since OpenIddict serves it from middleware that EndpointDataSource cannot see — and left policy-engine’s /.well-known/risk-profile-schema unreachable. Removed 2026-08-28. Narrow any new entry to the exact colliding sub-prefix; a broad prefix silently swallows domain endpoints beneath it.
Step 2: Extract Metadata per Endpoint
For each RouteEndpoint:
- HTTP Method: From
HttpMethodMetadata - Path: Normalize route pattern (see below)
- Authorization: From
IAuthorizeData,IAllowAnonymous - Parameters: From route pattern + parameter binding metadata
- Responses: From
IProducesResponseTypeMetadata - OpenAPI: From
IEndpointNameMetadata,IEndpointSummaryMetadata,ITagsMetadata
Step 3: Normalize Route Pattern
public static string NormalizeRoutePattern(RoutePattern pattern)
{
var raw = pattern.RawText ?? BuildFromSegments(pattern);
// 1. Ensure leading slash
if (!raw.StartsWith('/'))
raw = "/" + raw;
// 2. Strip constraints: {id:int} → {id}
raw = Regex.Replace(raw, @"\{(\*?)([A-Za-z0-9_]+)(:[^}]+)?\}", "{$2}");
// 3. Normalize catch-all: {**path} → {path}
raw = raw.Replace("**", "", StringComparison.Ordinal);
// 4. Remove trailing slash
raw = raw.TrimEnd('/');
// 5. Empty path becomes "/"
return string.IsNullOrEmpty(raw) ? "/" : raw;
}
Step 4: Deterministic Ordering
Sort endpoints for stable HELLO payloads:
var ordered = endpoints
.OrderBy(e => e.Path, StringComparer.OrdinalIgnoreCase)
.ThenBy(e => GetMethodOrder(e.Method))
.ThenBy(e => e.OriginalEndpoint?.DisplayName ?? "");
static int GetMethodOrder(string method) => method.ToUpperInvariant() switch
{
"GET" => 0,
"POST" => 1,
"PUT" => 2,
"PATCH" => 3,
"DELETE" => 4,
"OPTIONS" => 5,
"HEAD" => 6,
_ => 7
};
Authorization Mapping
Mapping Rules
| ASP.NET Metadata | Router Mapping |
|---|---|
[Authorize] (no args) | Empty RequiringClaims (authenticated required) |
[Authorize(Policy = "X")] | Resolve policy → claims via IAuthorizationPolicyProvider |
[Authorize(Roles = "A,B")] | One role requirement with AllowedValues = ["A", "B"] |
[AllowAnonymous] | AllowAnonymous = true, empty claims |
.RequireAuthorization("Policy") | Same as [Authorize(Policy)] |
Policy Resolution
Each claim/role requirement preserves its own alternatives: values inside one requirement are OR, while separate requirements (including separate attributes and multiple direct AuthorizationPolicy metadata entries) remain AND. Both mapping entry points process all direct policy entries, not only the last one. The flattened Roles list is descriptive metadata, not the enforcement list.
public async Task<IReadOnlyList<ClaimRequirement>> ResolvePolicyAsync(
string policyName,
IAuthorizationPolicyProvider policyProvider)
{
var policy = await policyProvider.GetPolicyAsync(policyName);
if (policy is null)
return [];
var claims = new List<ClaimRequirement>();
foreach (var requirement in policy.Requirements)
{
switch (requirement)
{
case ClaimsAuthorizationRequirement claimsReq:
claims.Add(MapAlternatives(claimsReq.ClaimType, claimsReq.AllowedValues));
break;
case RolesAuthorizationRequirement rolesReq:
claims.Add(MapAlternatives(ClaimTypes.Role, rolesReq.AllowedRoles));
break;
// Other requirement types: log warning, continue
}
}
return claims;
}
static ClaimRequirement MapAlternatives(string type, IEnumerable<string>? alternatives)
{
var values = alternatives?.Distinct(StringComparer.Ordinal)
.OrderBy(value => value, StringComparer.Ordinal).ToArray() ?? [];
if (values.Length == 1 && values[0] is { } value)
return new ClaimRequirement { Type = type, Value = value };
return new ClaimRequirement { Type = type, AllowedValues = values.Length > 0 ? values : null };
}
Null or empty allowed-value collections mean claim presence; empty strings and literal whitespace inside a nonempty collection are not removed. A nonempty [null] alternative list remains nonmatching, including after endpoint JSON serialization; it must not become scalar Value = null (presence). Custom StellaOps scope requirements retain their explicit ALL/ANY behavior.
The source-level regression proof is PublishedAuthorizationSemanticsTests, which compares real ASP.NET policy composition/authorization with the mapped, JSON-roundtripped endpoint and the Gateway claims store/middleware. This is not a live publication receipt: producers must carry the corrected mapper and republish metadata; existing Authority and YAML override precedence is unchanged.
Unmappable requirements fail OPEN at the gateway (ROA-1)
The “other requirement types: log warning, continue” arm above is not benign. On the router path the host’s UseAuthorization() middleware never runs — the dispatcher invokes the endpoint delegate directly (see Request Dispatch below) — so the gateway’s AuthorizationMiddleware is the only enforcement point, and it enforces exactly the claim requirements this mapper published. A policy built with RequireAssertion(...) yields an opaque AssertionRequirement: nothing is published, the gateway sees zero claim requirements, and any authenticated caller passes. Measured live 2026-09-04: GET /api/v1/opsmemory/decisions returned 200 to a token carrying only openid profile while a typed policy on the same gateway returned 403 to the same token.
Rules that follow:
- Register scope policies with a requirement type the mapper can read:
AddStellaOpsScopePolicy/AddStellaOpsAnyScopePolicy(StellaOpsScopeRequirement), orRequireStellaOpsScopes/RequireAnyStellaOpsScopeswhen an existingRequireAuthenticatedUser()must be preserved, on hosts running theStellaOpsBearerresource-server scheme; a host-local typed requirement exposingRequiredScopesandRequireAllScopesplus its ownAuthorizationHandler<T>otherwise (GraphScopeRequirement,AdvisoryAiScopeRequirement,AirGapScopeRequirement). NeverRequireAssertionfor a gateway-reachable endpoint. - The choice is per BRANCH, not per host (SPRINT_20260904_003 ROA-2). A host whose
AddAuthorizationblock is unconditional while its authentication is conditional must take the host-local shape: the resource-server helpers addStellaOpsBearerto the policy’sAuthenticationSchemesand needStellaOpsScopeAuthorizationHandler, so in the branch that registers neither — AirGap’sHeaderScopeAuthenticationHandlerpath, for instance — the policy binds to an absent scheme instead of failing open. Register the policies and their handler as ONE unit (AddAirGapAuthorization,AddAdvisoryAIAuthorization); a typed requirement with no evaluator denies every request. - The mapper logs an unmappable requirement at Warning, once per (policy, requirement type), naming the policy and the service. Treat it as a missing gate, not noise.
GatewayUnmappablePolicyConformanceTestsfails the build on any newRequireAssertionin productionsrc/and on any customIAuthorizationRequirementwithout a scope property. The existing registrations are pinned, shrink-only, indocs/architecture/gateway-authorization/unmappable-policy-register.json; the per-family inventory with deployment reachability is the README beside it.- The fail-open branch itself (
AuthorizationMiddleware: empty effective claims →_next) is pinned as current behaviour byPublishedAuthorizationSemanticsTests.AssertionPolicy_DeniedByFramework_ButPassesGateway_UntilFailClosedLands. Flipping it to fail-closed is remediation (b) inSPRINT_20260904_003and requires the inventory to be actioned first.
YAML Override Merge
When AuthorizationMappingStrategy.Hybrid:
public IReadOnlyList<ClaimRequirement> MergeWithYaml(
IReadOnlyList<ClaimRequirement> aspNetClaims,
EndpointOverrideConfig? yamlOverride)
{
if (yamlOverride?.RequiringClaims is not { Count: > 0 } yamlClaims)
return aspNetClaims;
// YAML completely replaces ASP.NET claims when specified
return yamlClaims;
}
Request Dispatch
Dispatch Flow
RequestFrame (from Router)
│
▼
┌───────────────────────────────────────┐
│ 1. Create DI Scope │
│ var scope = CreateAsyncScope() │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 2. Build HttpContext │
│ • Method, Path, QueryString │
│ • RawTarget (IHttpRequestFeature) │
│ • Headers (including identity) │
│ • Body stream │
│ • RequestServices = scope.Provider │
│ • CancellationToken wiring │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 3. Match Endpoint │
│ Use ASP.NET EndpointSelector │
│ Preserves constraints/precedence │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 4. Populate Identity │
│ Map X-StellaOps-* headers to │
│ ClaimsPrincipal on HttpContext │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 5. Execute RequestDelegate │
│ Runs full ASP.NET pipeline: │
│ • Endpoint filters │
│ • Authorization filters │
│ • Model binding │
│ • Handler execution │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 6. Capture Response │
│ • Status code │
│ • Headers (filtered) │
│ • Body bytes (buffered) │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 7. Dispose Scope │
│ await scope.DisposeAsync() │
└───────────────────────────────────────┘
│
▼
ResponseFrame (to Router)
Raw request target (IHttpRequestFeature.RawTarget)
Kestrel exposes the request target exactly as received (path plus query, percent-encoding intact) through IHttpRequestFeature.RawTarget, and HttpRequest.Path is the once-decoded form (%2F kept, everything else decoded). Endpoints that must decode a route segment exactly once, such as the Vulnerabilities Hub’s product-key routes whose keys carry canonical PURL escapes (%252F), read RawTarget instead of the double-decoded route value and fail closed without it. Before RAR-12 (2026-09-02) the dispatcher built a DefaultHttpContext with no RawTarget at all, so every such read answered HTTP 400 The raw route target is unavailable. through the Router while the direct Kestrel read passed.
Contract: the gateway sends the wire target in RequestFrame.RawTarget (envelope field rawTarget, optional), route prefix translated the same way as RequestFrame.Path. The dispatcher populates IHttpRequestFeature.RawTarget from it, and from RequestFrame.Path (query included) when the gateway sent none. HttpRequest.Path and QueryString still come from RequestFrame.Path, so route matching in the service is unchanged. See the Router architecture dossier (“The raw request target travels beside the path”) for the gateway side.
Verified against source b44e7c9ca3a7d2ca4fb265a2889d7e982a7fc5c2 (2026-09-02). Re-verify with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaOps.Router.AspNet.Tests.csproj -Method '*RawTarget*' -BuildProjectReferences -Restore (two cases: the wire form with %252F/%253A reaches the endpoint byte for byte; a frame without a raw target falls back to the frame path). A service exercises the change live only after its image is rebuilt with the new StellaOps.Microservice.AspNetCore.
HttpContext Construction
public async Task<ResponseFrame> DispatchAsync(
RequestFrame request,
CancellationToken cancellationToken)
{
await using var scope = _serviceProvider.CreateAsyncScope();
var httpContext = new DefaultHttpContext
{
RequestServices = scope.ServiceProvider
};
// Link cancellation
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
httpContext.RequestAborted);
httpContext.RequestAborted = linkedCts.Token;
// Populate request
var httpRequest = httpContext.Request;
httpRequest.Method = request.Method;
(httpRequest.Path, httpRequest.QueryString) = ParsePathAndQuery(request.Path);
// The wire target, so exactly-once decoders (IHttpRequestFeature.RawTarget readers) work
// through the Router; the frame path is the fallback for a gateway that sent none.
httpContext.Features.GetRequiredFeature<IHttpRequestFeature>().RawTarget =
string.IsNullOrWhiteSpace(request.RawTarget) ? request.Path : request.RawTarget;
foreach (var (key, value) in request.Headers)
{
httpRequest.Headers[key] = value;
}
if (request.Body is { Length: > 0 })
{
httpRequest.Body = new MemoryStream(request.Body);
httpRequest.ContentLength = request.Body.Length;
}
// Set trace identifier
httpContext.TraceIdentifier = request.CorrelationId;
// Populate identity from headers
PopulateIdentity(httpContext, request.Headers);
// Match and execute endpoint
var endpoint = await MatchEndpointAsync(httpContext);
if (endpoint is null)
{
return CreateNotFoundResponse(request.CorrelationId);
}
httpContext.SetEndpoint(endpoint);
// Capture response
var responseBody = new MemoryStream();
httpContext.Response.Body = responseBody;
try
{
await endpoint.RequestDelegate!(httpContext);
}
catch (Exception ex)
{
return CreateErrorResponse(request.CorrelationId, ex);
}
// Build response frame
return new ResponseFrame
{
CorrelationId = request.CorrelationId,
StatusCode = httpContext.Response.StatusCode,
Headers = CaptureResponseHeaders(httpContext.Response),
Body = responseBody.ToArray()
};
}
Identity Population
Map Gateway-provided identity headers to ClaimsPrincipal:
private void PopulateIdentity(HttpContext httpContext, IReadOnlyDictionary<string, string> headers)
{
var claims = new List<Claim>();
if (headers.TryGetValue("X-StellaOps-Actor", out var actor) && !string.IsNullOrEmpty(actor))
{
claims.Add(new Claim(ClaimTypes.NameIdentifier, actor));
claims.Add(new Claim(StellaOpsClaimTypes.Subject, actor));
}
if (headers.TryGetValue("X-StellaOps-TenantId", out var tenant) && !string.IsNullOrEmpty(tenant))
{
claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenant));
}
if (headers.TryGetValue("X-StellaOps-Scopes", out var scopes) && !string.IsNullOrEmpty(scopes))
{
foreach (var scope in scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope));
}
}
if (claims.Count > 0)
{
var identity = new ClaimsIdentity(claims, "StellaRouter");
httpContext.User = new ClaimsPrincipal(identity);
}
}
Program.cs Integration
Service Registration
public static class StellaRouterBridgeExtensions
{
public static IServiceCollection AddStellaRouterBridge(
this IServiceCollection services,
Action<StellaRouterBridgeOptions> configure)
{
var options = new StellaRouterBridgeOptions
{
ServiceName = "",
Version = "",
Region = ""
};
configure(options);
ValidateOptions(options);
services.AddSingleton(options);
services.AddSingleton<IAuthorizationClaimMapper, DefaultAuthorizationClaimMapper>();
services.AddSingleton<IAspNetEndpointDiscoveryProvider, AspNetCoreEndpointDiscoveryProvider>();
services.AddSingleton<IAspNetRouterRequestDispatcher, AspNetRouterRequestDispatcher>();
// Register as IEndpointDiscoveryProvider for Router SDK integration
services.AddSingleton<IEndpointDiscoveryProvider>(sp =>
sp.GetRequiredService<IAspNetEndpointDiscoveryProvider>());
// Wire into Router SDK
services.AddStellaMicroservice(microserviceOptions =>
{
microserviceOptions.ServiceName = options.ServiceName;
microserviceOptions.Version = options.Version;
microserviceOptions.Region = options.Region;
microserviceOptions.InstanceId = options.InstanceId ?? Guid.NewGuid().ToString();
});
return services;
}
public static IApplicationBuilder UseStellaRouterBridge(this IApplicationBuilder app)
{
// Ensure EndpointDataSource is available (after UseRouting)
var endpointDataSource = app.ApplicationServices
.GetService<EndpointDataSource>()
?? throw new InvalidOperationException(
"UseStellaRouterBridge must be called after UseRouting()");
// Discovery happens on first Router HELLO
// Dispatch is handled by Router SDK
return app;
}
}
YAML Override Format
The existing microservice.yaml format is extended:
microservice:
serviceName: scanner
version: "1.0.0"
region: "${REGION:default}"
endpoints:
# Override by method + path
- method: POST
path: /api/reports
timeoutSeconds: 60
supportsStreaming: false
requiringClaims:
- type: "scanner.reports.read"
# Replaces any ASP.NET-derived claims for this endpoint
# Endpoint with no authorization (explicitly allow authenticated)
- method: GET
path: /api/health
requiringClaims: [] # Empty = authenticated only, no specific claims
# Override specific claim type mapping
- method: DELETE
path: /api/scans/{id}
requiringClaims:
- type: "role"
value: "scanner-admin"
- type: "scanner.scans.delete"
ASP.NET Feature Support Matrix
Fully Supported
| Feature | Discovery | Dispatch | Notes |
|---|---|---|---|
Minimal APIs (MapGet, etc.) | ✓ | ✓ | Primary use case |
Controllers ([ApiController]) | ✓ | ✓ | Full support |
Route groups (MapGroup) | ✓ | ✓ | Path composition |
[Authorize] attribute | ✓ | ✓ | Claims extraction |
[AllowAnonymous] | ✓ | ✓ | Explicit anonymous |
.RequireAuthorization() | ✓ | ✓ | Policy resolution |
[FromBody] binding | ✓ (type) | ✓ | JSON deserialization |
[FromRoute] binding | ✓ | ✓ | Path parameters |
[FromQuery] binding | ✓ | ✓ | Query parameters |
[FromHeader] binding | ✓ | ✓ | Header values |
[FromServices] injection | N/A | ✓ | DI resolution |
.Produces<T>() | ✓ | N/A | Schema metadata |
.WithName() / .WithSummary() | ✓ | N/A | OpenAPI metadata |
.WithTags() | ✓ | N/A | Grouping |
| Endpoint filters | N/A | ✓ | Filter pipeline |
CancellationToken | N/A | ✓ | From Router frame |
Route constraints ({id:int}) | ✓ (stripped) | ✓ | ASP.NET matcher |
Catch-all routes ({**path}) | ✓ | ✓ | Normalized |
Not Supported (v0.1)
| Feature | Reason | Workaround |
|---|---|---|
| SignalR hubs | Different protocol | Use native ASP.NET |
| gRPC services | Different protocol | Use native gRPC |
| Streaming request bodies | SDK limitation | Use IRawStellaEndpoint |
| Custom constraints | Complexity | Use standard constraints |
| API versioning (header/query) | Complexity | Path-based versioning |
IFormFile uploads | Not buffered | Use raw endpoint |
Error Handling
Discovery Errors
| Condition | Behavior | Configuration |
|---|---|---|
| No authorization metadata | Fail discovery | OnMissingAuthorization = RequireExplicit |
| Unsupported constraint | Log warning, strip | OnUnsupportedConstraint = WarnAndStrip |
| Duplicate endpoints | Log warning, keep first | Always |
| Invalid route pattern | Skip endpoint, log error | Always |
Dispatch Errors
| Condition | Response |
|---|---|
| No matching endpoint | 404 Not Found |
| Authorization failure | 403 Forbidden |
| Model binding failure | 400 Bad Request |
| Handler exception | 500 Internal Server Error |
| Cancellation | No response (connection closed) |
Troubleshooting
Common Issues
1. Endpoints Not Discovered
Symptom: Gateway shows 0 endpoints for service, or specific endpoints missing.
Causes & Solutions:
| Cause | Solution |
|---|---|
UseStellaRouterBridge() called before MapControllers() | Call UseStellaRouterBridge() after all endpoint registration |
Route filtered by EndpointFilter | Check filter logic, ensure endpoint matches |
Path sits under an ExcludedPathPrefixes entry | Matching is StartsWith, so a broad prefix swallows endpoints beneath it — narrow the entry rather than adding an exception |
Missing [Authorize] with RequireExplicit | Add authorization or change MissingAuthorizationBehavior |
Debug:
// Enable discovery logging
builder.Logging.AddFilter("StellaOps.Microservice.AspNetCore", LogLevel.Debug);
2. Authorization Claims Not Extracted
Symptom: Endpoints registered but RequiringClaims is empty when it shouldn’t be.
Causes & Solutions:
| Cause | Solution |
|---|---|
[Authorize] without policy/roles | Add explicit policy or roles |
| Policy not registered | Register policy with AddAuthorization() |
AuthorizationMappingStrategy.YamlOnly set | Use Hybrid or AspNetMetadataOnly |
| Custom policy doesn’t have claim requirements | Use claims-based policies |
Debug:
// Log authorization mapping
var mapper = app.Services.GetRequiredService<IAuthorizationClaimMapper>();
var result = await mapper.MapAsync(endpoint);
Console.WriteLine($"Claims: {string.Join(", ", result.Claims)}");
3. Request Dispatch Fails with 404
Symptom: Gateway routes request but microservice returns 404.
Causes & Solutions:
| Cause | Solution |
|---|---|
| Path parameters not matched | Verify parameter names in route pattern |
| Method mismatch | Verify HTTP method matches endpoint |
| Route constraint rejected value | Use standard constraints that bridge supports |
| Catch-all route not handled | Ensure {**path} is normalized correctly |
Debug:
# Check registered endpoints
curl http://localhost:5000/.well-known/stella-endpoints
4. Model Binding Errors
Symptom: Requests return 400 Bad Request with binding errors.
Causes & Solutions:
| Cause | Solution |
|---|---|
[FromBody] type mismatch | Verify request body matches expected type |
| Required parameter missing | Include all [FromRoute]/[FromQuery] parameters |
[FromHeader] not populated | Headers forwarded via X-StellaOps-Header-* |
| Complex type not deserialized | Verify JSON serialization settings |
Debug:
// Enable model binding logging
builder.Logging.AddFilter("Microsoft.AspNetCore.Mvc.ModelBinding", LogLevel.Debug);
5. Identity Not Populated
Symptom: User.Identity is null or claims missing in endpoint handler.
Causes & Solutions:
| Cause | Solution |
|---|---|
| Gateway not forwarding identity | Verify Gateway identity-header-policy configured |
Missing X-StellaOps-UserId header | Gateway must send identity headers |
UseAuthentication() not called | Call before UseStellaRouterBridge() |
| Custom claims not mapped | Use ClaimsPrincipalBuilder for custom claims |
Debug:
app.MapGet("/debug/identity", (HttpContext ctx) =>
new {
IsAuthenticated = ctx.User.Identity?.IsAuthenticated,
Name = ctx.User.Identity?.Name,
Claims = ctx.User.Claims.Select(c => new { c.Type, c.Value })
});
6. Performance Issues
Symptom: High latency or memory usage.
Causes & Solutions:
| Cause | Solution |
|---|---|
| HttpContext allocation per request | Pool contexts (internal implementation) |
| Large request bodies buffered | Use streaming endpoints for large payloads |
| Discovery runs too frequently | Discovery runs once at startup; cache is stable |
| Many endpoints slow startup | Discovery is O(n) but runs once |
Metrics to monitor:
stellaops_router_bridge_requests_totalstellaops_router_bridge_request_duration_secondsstellaops_router_bridge_dispatch_errors_total
7. YAML Override Not Applied
Symptom: Claims from YAML file not appearing in endpoint registration.
Causes & Solutions:
| Cause | Solution |
|---|---|
YamlConfigPath not set | Set options.YamlConfigPath = "router.yaml" |
| File not found | Use absolute path or verify relative path |
| Path pattern doesn’t match | YAML paths are case-insensitive, verify pattern |
AuthorizationMappingStrategy.AspNetMetadataOnly | Use YamlOnly or Hybrid |
Example YAML:
# router.yaml
endpoints:
- path: "/api/admin/**"
requiringClaims:
- type: "Role"
value: "admin"
Diagnostic Endpoints
The bridge adds optional diagnostic endpoints (development only):
| Endpoint | Description |
|---|---|
/.well-known/stella-endpoints | Lists all discovered endpoints |
/.well-known/stella-bridge-status | Shows bridge configuration and health |
Enable in development:
builder.Services.AddStellaRouterBridge(options =>
{
options.EnableDiagnosticEndpoints = builder.Environment.IsDevelopment();
// ...
});
Logging Categories
Configure logging for troubleshooting:
{
"Logging": {
"LogLevel": {
"StellaOps.Microservice.AspNetCore.Discovery": "Debug",
"StellaOps.Microservice.AspNetCore.Authorization": "Debug",
"StellaOps.Microservice.AspNetCore.Dispatch": "Information"
}
}
}
Testing Strategy
Unit Tests
- Discovery determinism: Same endpoints → same descriptor order
- Route normalization: Constraints stripped, paths normalized
- Authorization mapping: Policies → claims correctly
- Metadata extraction: All ASP.NET metadata captured
Integration Tests
- Minimal API dispatch: Route parameters, query, body binding
- Controller dispatch: Attribute routing, model binding
- Authorization flow: Claims checked, 403 on failure
- Filter execution: Endpoint filters run correctly
- Error mapping: Exceptions → correct status codes
End-to-End Tests
- HELLO registration: Bridge endpoints appear in Gateway
- Gateway routing: HTTP request → Router → ASP.NET → response
- OpenAPI aggregation: Bridged endpoints in Gateway OpenAPI
Migration Guide
From HTTP-Only Service
// Before: HTTP only
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
await app.RunAsync();
// After: HTTP + Router bridge
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddStellaRouterBridge(options =>
{
options.ServiceName = "myservice";
options.Version = "1.0.0";
options.Region = "default";
});
builder.Services.AddMessagingTransportClient(); // Add transport
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaRouterBridge(); // Enable bridge
app.MapControllers();
await app.RunAsync();
From Dual Registration (HTTP + [StellaEndpoint])
- Remove
[StellaEndpoint]handler classes - Add
AddStellaRouterBridge()configuration - Add
UseStellaRouterBridge()middleware - Add/update
microservice.yamlfor claim overrides - Remove duplicate endpoint registrations
Related Documents
- Router architecture:
docs/modules/router/architecture.md - Migration guide:
docs/modules/router/migration-guide.md - Gateway identity policy:
docs/modules/gateway/identity-header-policy.md - Implementation sprint:
docs/implplan/SPRINT_8100_0011_0001_router_sdk_aspnet_bridge.md
