Repair router frontdoor convergence and live route contracts
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user