Repair router frontdoor convergence and live route contracts

This commit is contained in:
master
2026-03-09 19:09:19 +02:00
parent 49d1c57597
commit bf937c9395
25 changed files with 740 additions and 61 deletions

View File

@@ -1,7 +1,8 @@
using System.Security.Claims;
using System.Text.Json;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway;
using System.Text.Json;
namespace StellaOps.Gateway.WebService.Authorization;
@@ -66,6 +67,8 @@ public sealed class AuthorizationMiddleware
return;
}
var resolvedScopes = ResolveGatewayScopes(context);
foreach (var required in effectiveClaims)
{
var userClaims = context.User.Claims;
@@ -78,13 +81,7 @@ public sealed class AuthorizationMiddleware
else if (string.Equals(required.Type, "scope", StringComparison.OrdinalIgnoreCase) ||
string.Equals(required.Type, "scp", StringComparison.OrdinalIgnoreCase))
{
// Scope claims may be space-separated (RFC 6749 §3.3) or individual claims.
// Check both: exact match on individual claims, and contains-within-space-separated.
hasClaim = userClaims.Any(c =>
c.Type == required.Type &&
(c.Value == required.Value ||
c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Any(s => string.Equals(s, required.Value, StringComparison.Ordinal))));
hasClaim = HasRequiredScope(userClaims, resolvedScopes, required.Value);
}
else
{
@@ -108,6 +105,34 @@ public sealed class AuthorizationMiddleware
await _next(context);
}
private static ISet<string>? ResolveGatewayScopes(HttpContext context)
{
return context.Items.TryGetValue(GatewayContextKeys.Scopes, out var scopesObj) &&
scopesObj is ISet<string> scopes &&
scopes.Count > 0
? scopes
: null;
}
private static bool HasRequiredScope(
IEnumerable<Claim> userClaims,
ISet<string>? resolvedScopes,
string requiredScope)
{
if (resolvedScopes is not null && resolvedScopes.Contains(requiredScope))
{
return true;
}
// Scope claims may be space-separated (RFC 6749 section 3.3) or individual claims.
return userClaims.Any(c =>
(string.Equals(c.Type, "scope", StringComparison.OrdinalIgnoreCase) ||
string.Equals(c.Type, "scp", StringComparison.OrdinalIgnoreCase)) &&
(c.Value == requiredScope ||
c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Any(s => string.Equals(s, requiredScope, StringComparison.Ordinal))));
}
private static Task WriteUnauthorizedAsync(HttpContext context, EndpointDescriptor endpoint)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;

View File

@@ -0,0 +1,73 @@
using System.Net;
namespace StellaOps.Gateway.WebService.Configuration;
/// <summary>
/// Resolves container listener URLs from ASP.NET Core's explicit URL and port environment variables.
/// </summary>
public static class ContainerFrontdoorBindingResolver
{
public static IReadOnlyList<Uri> ResolveConfiguredUrls(
string? serverUrls,
string? explicitUrls,
string? explicitHttpPorts,
string? explicitHttpsPorts)
{
var rawUrls = !string.IsNullOrWhiteSpace(serverUrls)
? SplitEntries(serverUrls)
: !string.IsNullOrWhiteSpace(explicitUrls)
? SplitEntries(explicitUrls)
: BuildUrlsFromPorts(explicitHttpPorts, explicitHttpsPorts);
return rawUrls
.Select(TryCreateListenUri)
.Where(static uri => uri is not null)
.Cast<Uri>()
.DistinctBy(static uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static IEnumerable<string> BuildUrlsFromPorts(string? explicitHttpPorts, string? explicitHttpsPorts)
{
foreach (var port in SplitEntries(explicitHttpPorts))
{
if (int.TryParse(port, out var value) && value > 0)
{
yield return $"http://0.0.0.0:{value}";
}
}
foreach (var port in SplitEntries(explicitHttpsPorts))
{
if (int.TryParse(port, out var value) && value > 0)
{
yield return $"https://0.0.0.0:{value}";
}
}
}
private static IEnumerable<string> SplitEntries(string? value)
{
return string.IsNullOrWhiteSpace(value)
? []
: value.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static Uri? TryCreateListenUri(string rawUrl)
{
var normalized = NormalizeWildcardHost(rawUrl);
return Uri.TryCreate(normalized, UriKind.Absolute, out var uri) ? uri : null;
}
private static string NormalizeWildcardHost(string rawUrl)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return string.Empty;
}
return rawUrl
.Replace("://+", $"://{IPAddress.Any}", StringComparison.Ordinal)
.Replace("://*", $"://{IPAddress.Any}", StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,36 @@
using StellaOps.Router.Gateway.Configuration;
namespace StellaOps.Gateway.WebService.Configuration;
internal static class GatewayHealthThresholdPolicy
{
private static readonly TimeSpan DefaultMessagingHeartbeatInterval = TimeSpan.FromSeconds(10);
internal static void ApplyMinimums(HealthOptions options, GatewayMessagingTransportOptions messaging)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(messaging);
var heartbeatInterval = GatewayValueParser.ParseDuration(
messaging.HeartbeatInterval,
DefaultMessagingHeartbeatInterval);
if (heartbeatInterval <= TimeSpan.Zero)
{
heartbeatInterval = DefaultMessagingHeartbeatInterval;
}
var minimumDegradedThreshold = TimeSpan.FromTicks(heartbeatInterval.Ticks * 2);
var minimumStaleThreshold = TimeSpan.FromTicks(heartbeatInterval.Ticks * 3);
if (options.DegradedThreshold < minimumDegradedThreshold)
{
options.DegradedThreshold = minimumDegradedThreshold;
}
if (options.StaleThreshold < minimumStaleThreshold)
{
options.StaleThreshold = minimumStaleThreshold;
}
}
}

View File

@@ -433,6 +433,14 @@ public sealed class IdentityHeaderPolicyMiddleware
scopes.Add("scheduler.runs.preview");
scopes.Add("scheduler.runs.manage");
}
// Legacy operator sessions still mint orch:quota and rely on the gateway to
// resolve the equivalent fine-grained quota scopes before frontdoor auth.
if (scopes.Contains("orch:quota"))
{
scopes.Add("quota.read");
scopes.Add("quota.admin");
}
}
private void StoreIdentityContext(HttpContext context, IdentityContext identity)

View File

@@ -371,6 +371,7 @@ static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, Gatewa
options.StaleThreshold = GatewayValueParser.ParseDuration(health.StaleThreshold, options.StaleThreshold);
options.DegradedThreshold = GatewayValueParser.ParseDuration(health.DegradedThreshold, options.DegradedThreshold);
options.CheckInterval = GatewayValueParser.ParseDuration(health.CheckInterval, options.CheckInterval);
GatewayHealthThresholdPolicy.ApplyMinimums(options, gateway.Value.Transports.Messaging);
});
builder.Services.AddOptions<OpenApiAggregationOptions>()
@@ -413,19 +414,18 @@ static bool ShouldApplyStellaOpsLocalBinding()
static void ConfigureContainerFrontdoorBindings(WebApplicationBuilder builder)
{
var currentUrls = builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey) ?? string.Empty;
var currentUrls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls(
builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey),
Environment.GetEnvironmentVariable("ASPNETCORE_URLS"),
Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS"),
Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORTS"));
builder.WebHost.ConfigureKestrel((context, kestrel) =>
{
var defaultCert = LoadDefaultCertificate(context.Configuration);
foreach (var rawUrl in currentUrls.Split(';', StringSplitOptions.RemoveEmptyEntries))
foreach (var uri in currentUrls)
{
if (!Uri.TryCreate(rawUrl.Trim(), UriKind.Absolute, out var uri))
{
continue;
}
var address = ResolveListenAddress(uri.Host);
if (string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
@@ -537,4 +537,3 @@ static string? ResolveAuthorityClaimsUrl(GatewayAuthorityOptions authorityOption
return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/');
}

View File

@@ -6,6 +6,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Gateway.WebService.Tests" />
</ItemGroup>
<ItemGroup>
<!-- Router Libraries (now in same module) -->
<ProjectReference Include="..\__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj" />

View File

@@ -61,11 +61,12 @@
},
"Health": {
"StaleThreshold": "30s",
"DegradedThreshold": "15s",
"DegradedThreshold": "20s",
"CheckInterval": "5s"
},
"Routes": [
{ "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/v1/approvals", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/approvals" },
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" },
{ "Type": "ReverseProxy", "Path": "/api/v1/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local/api/v1/vexlens" },
{ "Type": "ReverseProxy", "Path": "/api/v1/notify", "TranslatesTo": "http://notify.stella-ops.local/api/v1/notify" },
@@ -79,9 +80,9 @@
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/risk", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/analytics", "TranslatesTo": "http://platform.stella-ops.local/api/analytics" },
{ "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://orchestrator.stella-ops.local/api/releases" },
{ "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://orchestrator.stella-ops.local/api/approvals" },
{ "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://jobengine.stella-ops.local/api/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://jobengine.stella-ops.local/api/releases" },
{ "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://jobengine.stella-ops.local/api/approvals" },
{ "Type": "ReverseProxy", "Path": "/api/v1/platform", "TranslatesTo": "http://platform.stella-ops.local/api/v1/platform" },
{ "Type": "ReverseProxy", "Path": "/api/v1/scanner", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scanner" },
{ "Type": "ReverseProxy", "Path": "/api/v1/jobengine", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/jobengine", "PreserveAuthHeaders": true },