- Implemented comprehensive tests for verdict artifact generation to ensure deterministic outputs across various scenarios, including identical inputs, parallel execution, and change ordering. - Created helper methods for generating sample verdict inputs and computing canonical hashes. - Added tests to validate the stability of canonical hashes, proof spine ordering, and summary statistics. - Introduced a new PowerShell script to update SHA256 sums for files, ensuring accurate hash generation and file integrity checks.
858 lines
31 KiB
Markdown
858 lines
31 KiB
Markdown
# Router · ASP.NET Endpoint Bridge
|
|
|
|
## Status
|
|
|
|
- **Version:** 0.2 (revised)
|
|
- **Last updated:** 2025-12-24 (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`:
|
|
|
|
```csharp
|
|
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; }
|
|
|
|
// === 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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
public interface IAspNetEndpointDiscoveryProvider : IEndpointDiscoveryProvider
|
|
{
|
|
/// <summary>
|
|
/// Discover ASP.NET endpoints with full metadata.
|
|
/// </summary>
|
|
IReadOnlyList<AspNetEndpointDescriptor> DiscoverAspNetEndpoints();
|
|
}
|
|
```
|
|
|
|
### 4. IAuthorizationClaimMapper
|
|
|
|
Authorization-to-claims mapping interface:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
public interface IAspNetRouterRequestDispatcher
|
|
{
|
|
/// <summary>
|
|
/// Dispatch a Router request frame through ASP.NET pipeline.
|
|
/// </summary>
|
|
Task<ResponseFrame> DispatchAsync(
|
|
RequestFrame request,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Endpoint Discovery Algorithm
|
|
|
|
### Step 1: Enumerate Endpoints
|
|
|
|
```csharp
|
|
var endpoints = endpointDataSource.Endpoints
|
|
.OfType<RouteEndpoint>()
|
|
.Where(e => e.Metadata.GetMetadata<HttpMethodMetadata>() is not null)
|
|
.Where(options.EndpointFilter ?? (_ => true));
|
|
```
|
|
|
|
### Step 2: Extract Metadata per Endpoint
|
|
|
|
For each `RouteEndpoint`:
|
|
|
|
1. **HTTP Method**: From `HttpMethodMetadata`
|
|
2. **Path**: Normalize route pattern (see below)
|
|
3. **Authorization**: From `IAuthorizeData`, `IAllowAnonymous`
|
|
4. **Parameters**: From route pattern + parameter binding metadata
|
|
5. **Responses**: From `IProducesResponseTypeMetadata`
|
|
6. **OpenAPI**: From `IEndpointNameMetadata`, `IEndpointSummaryMetadata`, `ITagsMetadata`
|
|
|
|
### Step 3: Normalize Route Pattern
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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")]` | `ClaimRequirement(ClaimTypes.Role, "A")`, `ClaimRequirement(ClaimTypes.Role, "B")` |
|
|
| `[AllowAnonymous]` | `AllowAnonymous = true`, empty claims |
|
|
| `.RequireAuthorization("Policy")` | Same as `[Authorize(Policy)]` |
|
|
|
|
### Policy Resolution
|
|
|
|
```csharp
|
|
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:
|
|
foreach (var value in claimsReq.AllowedValues ?? [null])
|
|
{
|
|
claims.Add(new ClaimRequirement
|
|
{
|
|
Type = claimsReq.ClaimType,
|
|
Value = value
|
|
});
|
|
}
|
|
break;
|
|
|
|
case RolesAuthorizationRequirement rolesReq:
|
|
foreach (var role in rolesReq.AllowedRoles)
|
|
{
|
|
claims.Add(new ClaimRequirement
|
|
{
|
|
Type = ClaimTypes.Role,
|
|
Value = role
|
|
});
|
|
}
|
|
break;
|
|
|
|
// Other requirement types: log warning, continue
|
|
}
|
|
}
|
|
|
|
return claims;
|
|
}
|
|
```
|
|
|
|
### YAML Override Merge
|
|
|
|
When `AuthorizationMappingStrategy.Hybrid`:
|
|
|
|
```csharp
|
|
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 │
|
|
│ • 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)
|
|
```
|
|
|
|
### HttpContext Construction
|
|
|
|
```csharp
|
|
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);
|
|
|
|
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`:
|
|
|
|
```csharp
|
|
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-Tenant", 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
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```yaml
|
|
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) |
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests
|
|
|
|
1. **Discovery determinism**: Same endpoints → same descriptor order
|
|
2. **Route normalization**: Constraints stripped, paths normalized
|
|
3. **Authorization mapping**: Policies → claims correctly
|
|
4. **Metadata extraction**: All ASP.NET metadata captured
|
|
|
|
### Integration Tests
|
|
|
|
1. **Minimal API dispatch**: Route parameters, query, body binding
|
|
2. **Controller dispatch**: Attribute routing, model binding
|
|
3. **Authorization flow**: Claims checked, 403 on failure
|
|
4. **Filter execution**: Endpoint filters run correctly
|
|
5. **Error mapping**: Exceptions → correct status codes
|
|
|
|
### End-to-End Tests
|
|
|
|
1. **HELLO registration**: Bridge endpoints appear in Gateway
|
|
2. **Gateway routing**: HTTP request → Router → ASP.NET → response
|
|
3. **OpenAPI aggregation**: Bridged endpoints in Gateway OpenAPI
|
|
|
|
---
|
|
|
|
## Migration Guide
|
|
|
|
### From HTTP-Only Service
|
|
|
|
```csharp
|
|
// 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]`)
|
|
|
|
1. Remove `[StellaEndpoint]` handler classes
|
|
2. Add `AddStellaRouterBridge()` configuration
|
|
3. Add `UseStellaRouterBridge()` middleware
|
|
4. Add/update `microservice.yaml` for claim overrides
|
|
5. 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`
|