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

@@ -597,6 +597,16 @@ public static class StellaOpsScopes
/// </summary>
public const string AnalyticsRead = "analytics.read";
// Platform context scopes
public const string PlatformContextRead = "platform.context.read";
public const string PlatformContextWrite = "platform.context.write";
// Doctor scopes
public const string DoctorRun = "doctor:run";
public const string DoctorRunFull = "doctor:run:full";
public const string DoctorExport = "doctor:export";
public const string DoctorAdmin = "doctor:admin";
private static readonly IReadOnlyList<string> AllScopes = BuildAllScopes();
private static readonly HashSet<string> KnownScopes = new(AllScopes, StringComparer.OrdinalIgnoreCase);

View File

@@ -203,157 +203,52 @@ internal static class AuthorizeEndpointExtensions
return sb.ToString();
}
private static readonly string LoginTemplate = LoadLoginTemplate();
private static string LoadLoginTemplate()
{
var assembly = typeof(AuthorizeEndpointExtensions).Assembly;
var resourceName = "StellaOps.Authority.Pages.login.html";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static string BuildLoginHtml(
OpenIddictRequest request, string? error = null, string? username = null)
{
var enc = HtmlEncoder.Default;
var sb = new StringBuilder(8192);
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"utf-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
sb.AppendLine("<title>Sign In &mdash; StellaOps</title>");
sb.AppendLine("<style>");
// Build error block
var errorBlock = string.IsNullOrWhiteSpace(error)
? string.Empty
: $"<div class=\"error\">{enc.Encode(error)}</div>";
// Reset
sb.AppendLine("*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}");
// Build username value attribute
var usernameValue = string.IsNullOrWhiteSpace(username)
? string.Empty
: $" value=\"{enc.Encode(username)}\"";
// Body — warm amber light theme matching the Angular app
sb.AppendLine("body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;");
sb.AppendLine("background:linear-gradient(175deg,#FFFCF5 0%,#FFF9ED 40%,#FFFFFF 100%);");
sb.AppendLine("color:#3D2E0A;display:flex;align-items:center;justify-content:center;min-height:100vh;");
sb.AppendLine("-webkit-font-smoothing:antialiased;position:relative;overflow:hidden}");
// Animated background radials
sb.AppendLine("body::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;");
sb.AppendLine("background:radial-gradient(ellipse 70% 50% at 50% 0%,rgba(245,166,35,0.08) 0%,transparent 60%),");
sb.AppendLine("radial-gradient(ellipse 60% 50% at 0% 100%,rgba(245,166,35,0.04) 0%,transparent 50%),");
sb.AppendLine("radial-gradient(ellipse 50% 40% at 100% 80%,rgba(212,146,10,0.03) 0%,transparent 50%);");
sb.AppendLine("pointer-events:none;z-index:0}");
// Card — frosted glass on warm light
sb.AppendLine(".card{position:relative;z-index:1;background:rgba(255,255,255,0.8);");
sb.AppendLine("backdrop-filter:blur(24px) saturate(1.4);-webkit-backdrop-filter:blur(24px) saturate(1.4);");
sb.AppendLine("border-radius:24px;padding:2.5rem 2rem 2rem;width:100%;max-width:400px;");
sb.AppendLine("border:1px solid rgba(212,201,168,0.25);");
sb.AppendLine("box-shadow:0 0 60px rgba(245,166,35,0.06),0 20px 60px rgba(28,18,0,0.06),");
sb.AppendLine("0 8px 24px rgba(28,18,0,0.04),inset 0 1px 0 rgba(255,255,255,0.8);");
sb.AppendLine("animation:card-entrance 600ms cubic-bezier(0.18,0.89,0.32,1) both}");
// Logo container
sb.AppendLine(".logo-wrap{text-align:center;margin-bottom:0.25rem}");
sb.AppendLine(".logo-wrap img{width:56px;height:56px;border-radius:14px;");
sb.AppendLine("filter:drop-shadow(0 4px 12px rgba(245,166,35,0.2));");
sb.AppendLine("animation:logo-pop 650ms cubic-bezier(0.34,1.56,0.64,1) 100ms both}");
// Title
sb.AppendLine("h1{font-size:1.5rem;text-align:center;margin-bottom:0.25rem;color:#1C1200;font-weight:700;");
sb.AppendLine("letter-spacing:-0.03em;animation:slide-up 500ms ease 200ms both}");
// Subtitle
sb.AppendLine(".subtitle{text-align:center;color:#6B5A2E;font-size:.8125rem;margin-bottom:1.5rem;");
sb.AppendLine("font-weight:400;animation:fade-in 400ms ease 350ms both}");
// Error
sb.AppendLine(".error{background:#fef2f2;border:1px solid rgba(239,68,68,0.2);color:#991b1b;");
sb.AppendLine("padding:.75rem;border-radius:12px;margin-bottom:1rem;font-size:.8125rem;font-weight:500;");
sb.AppendLine("display:flex;align-items:center;gap:.5rem}");
sb.AppendLine(".error::before{content:'';width:6px;height:6px;border-radius:50%;background:#ef4444;flex-shrink:0}");
// Labels
sb.AppendLine("label{display:block;font-size:.75rem;font-weight:600;color:#6B5A2E;margin-bottom:.375rem;");
sb.AppendLine("letter-spacing:0.03em;text-transform:uppercase}");
// Inputs
sb.AppendLine("input[type=text],input[type=password]{width:100%;padding:.75rem .875rem;");
sb.AppendLine("background:#FFFCF5;border:1px solid rgba(212,201,168,0.4);border-radius:12px;");
sb.AppendLine("color:#3D2E0A;font-size:.9375rem;margin-bottom:1rem;outline:none;font-family:inherit;");
sb.AppendLine("transition:border-color .2s,box-shadow .2s}");
sb.AppendLine("input[type=text]:focus,input[type=password]:focus{border-color:#F5A623;");
sb.AppendLine("box-shadow:0 0 0 3px rgba(245,166,35,0.15)}");
sb.AppendLine("input[type=text]::placeholder,input[type=password]::placeholder{color:#9A8F78}");
// Button — amber gradient CTA
sb.AppendLine("button{width:100%;padding:.875rem;margin-top:0.25rem;");
sb.AppendLine("background:linear-gradient(135deg,#F5A623 0%,#D4920A 100%);");
sb.AppendLine("color:#fff;border:none;border-radius:14px;font-size:1rem;font-weight:600;");
sb.AppendLine("cursor:pointer;font-family:inherit;letter-spacing:0.01em;position:relative;overflow:hidden;");
sb.AppendLine("transition:transform .22s cubic-bezier(0.18,0.89,0.32,1),box-shadow .22s;");
sb.AppendLine("box-shadow:0 2px 12px rgba(245,166,35,0.3),0 1px 3px rgba(28,18,0,0.08)}");
sb.AppendLine("button:hover{transform:translateY(-2px);");
sb.AppendLine("box-shadow:0 6px 24px rgba(245,166,35,0.4),0 2px 8px rgba(28,18,0,0.08)}");
sb.AppendLine("button:active{transform:translateY(0);");
sb.AppendLine("box-shadow:0 1px 6px rgba(245,166,35,0.2),0 1px 2px rgba(28,18,0,0.06)}");
sb.AppendLine("button:focus-visible{outline:2px solid rgba(245,166,35,0.5);outline-offset:3px}");
// Shimmer effect on button
sb.AppendLine("button::after{content:'';position:absolute;inset:0;");
sb.AppendLine("background:linear-gradient(105deg,transparent 38%,rgba(255,255,255,0.3) 50%,transparent 62%);");
sb.AppendLine("background-size:250% 100%;animation:shimmer 2.2s ease 1.2s}");
// Keyframes
sb.AppendLine("@keyframes card-entrance{from{opacity:0;transform:translateY(24px) scale(0.97)}to{opacity:1;transform:translateY(0) scale(1)}}");
sb.AppendLine("@keyframes logo-pop{from{opacity:0;transform:scale(0.6)}to{opacity:1;transform:scale(1)}}");
sb.AppendLine("@keyframes slide-up{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}");
sb.AppendLine("@keyframes fade-in{from{opacity:0}to{opacity:1}}");
sb.AppendLine("@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-100% 0}}");
// Reduced motion
sb.AppendLine("@media(prefers-reduced-motion:reduce){.card,h1,.subtitle,.logo-wrap img,button::after{animation:none!important}");
sb.AppendLine(".card,h1,.subtitle,.logo-wrap img{opacity:1}button{transition:none}}");
// Responsive
sb.AppendLine("@media(max-width:480px){.card{margin:0 1rem;padding:2rem 1.5rem 1.75rem;border-radius:20px}}");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine("<form class=\"card\" method=\"post\" action=\"\">");
// Logo
sb.AppendLine("<div class=\"logo-wrap\"><img src=\"/assets/img/site.png\" alt=\"\" width=\"56\" height=\"56\" /></div>");
sb.AppendLine("<h1>StellaOps</h1>");
sb.AppendLine("<p class=\"subtitle\">Sign in to continue</p>");
if (!string.IsNullOrWhiteSpace(error))
{
sb.Append("<div class=\"error\">").Append(enc.Encode(error)).AppendLine("</div>");
}
// Hidden fields for OIDC parameters
AppendHidden(sb, "response_type", request.ResponseType);
AppendHidden(sb, "client_id", request.ClientId);
AppendHidden(sb, "redirect_uri", request.RedirectUri);
AppendHidden(sb, "scope", request.Scope);
AppendHidden(sb, "state", request.State);
AppendHidden(sb, "nonce", request.Nonce);
AppendHidden(sb, "code_challenge", request.CodeChallenge);
AppendHidden(sb, "code_challenge_method", request.CodeChallengeMethod);
// Build OIDC hidden fields
var hiddenSb = new StringBuilder();
AppendHidden(hiddenSb, "response_type", request.ResponseType);
AppendHidden(hiddenSb, "client_id", request.ClientId);
AppendHidden(hiddenSb, "redirect_uri", request.RedirectUri);
AppendHidden(hiddenSb, "scope", request.Scope);
AppendHidden(hiddenSb, "state", request.State);
AppendHidden(hiddenSb, "nonce", request.Nonce);
AppendHidden(hiddenSb, "code_challenge", request.CodeChallenge);
AppendHidden(hiddenSb, "code_challenge_method", request.CodeChallengeMethod);
if (!string.IsNullOrWhiteSpace(request.GetParameter("audience")?.ToString()))
{
AppendHidden(sb, "audience", request.GetParameter("audience")?.ToString());
AppendHidden(hiddenSb, "audience", request.GetParameter("audience")?.ToString());
}
sb.AppendLine("<label for=\"username\">Username</label>");
sb.Append("<input type=\"text\" id=\"username\" name=\"username\" autocomplete=\"username\" placeholder=\"Enter username\" required");
if (!string.IsNullOrWhiteSpace(username))
{
sb.Append(" value=\"").Append(enc.Encode(username)).Append('"');
}
sb.AppendLine(" />");
sb.AppendLine("<label for=\"password\">Password</label>");
sb.AppendLine("<input type=\"password\" id=\"password\" name=\"password\" autocomplete=\"current-password\" placeholder=\"Enter password\" required />");
sb.AppendLine("<button type=\"submit\">Sign In</button>");
sb.AppendLine("</form>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
return LoginTemplate
.Replace("{{error_block}}", errorBlock)
.Replace("{{username_value}}", usernameValue)
.Replace("{{oidc_hidden_fields}}", hiddenSb.ToString());
}
private static void AppendHidden(StringBuilder sb, string name, string? value)

View File

@@ -0,0 +1,217 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Net.Http.Headers;
namespace StellaOps.Authority;
internal static class OpenIddictGatewayBridgeEndpointExtensions
{
private static readonly HashSet<string> HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"TE",
"Trailers",
"Transfer-Encoding",
"Upgrade"
};
private static readonly HashSet<string> IgnoredRequestHeaders = new(StringComparer.OrdinalIgnoreCase)
{
HeaderNames.Host,
HeaderNames.ContentLength
};
public static void MapOpenIddictGatewayBridgeEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapMethods("/connect/authorize", [HttpMethods.Get, HttpMethods.Post], (
HttpContext context,
IHttpClientFactory httpClientFactory,
CancellationToken cancellationToken) =>
ProxyToAuthorityAsync(context, httpClientFactory, "/authorize", cancellationToken))
.AllowAnonymous()
.WithName("GatewayBridgeAuthorize")
.WithSummary("OpenID Connect authorization endpoint.")
.WithDescription("Bridges Gateway microservice `/connect/authorize` requests to Authority `/authorize`.");
endpoints.MapPost("/connect/token", (
HttpContext context,
IHttpClientFactory httpClientFactory,
CancellationToken cancellationToken) =>
ProxyToAuthorityAsync(context, httpClientFactory, "/token", cancellationToken))
.AllowAnonymous()
.WithName("GatewayBridgeToken")
.WithSummary("OAuth2 token endpoint.")
.WithDescription("Bridges Gateway microservice `/connect/token` requests to Authority `/token`.");
endpoints.MapPost("/connect/introspect", (
HttpContext context,
IHttpClientFactory httpClientFactory,
CancellationToken cancellationToken) =>
ProxyToAuthorityAsync(context, httpClientFactory, "/introspect", cancellationToken))
.AllowAnonymous()
.WithName("GatewayBridgeIntrospect")
.WithSummary("OAuth2 introspection endpoint.")
.WithDescription("Bridges Gateway microservice `/connect/introspect` requests to Authority `/introspect`.");
endpoints.MapPost("/connect/revoke", (
HttpContext context,
IHttpClientFactory httpClientFactory,
CancellationToken cancellationToken) =>
ProxyToAuthorityAsync(context, httpClientFactory, "/revoke", cancellationToken))
.AllowAnonymous()
.WithName("GatewayBridgeRevoke")
.WithSummary("OAuth2 revocation endpoint.")
.WithDescription("Bridges Gateway microservice `/connect/revoke` requests to Authority `/revoke`.");
endpoints.MapGet("/well-known/openid-configuration", (
HttpContext context,
IHttpClientFactory httpClientFactory,
CancellationToken cancellationToken) =>
ProxyToAuthorityAsync(context, httpClientFactory, "/.well-known/openid-configuration", cancellationToken))
.AllowAnonymous()
.WithName("GatewayBridgeOpenIdConfiguration")
.WithSummary("OpenID Provider configuration endpoint.")
.WithDescription("Bridges Gateway microservice `/.well-known/openid-configuration` requests to Authority OIDC discovery.");
}
private static async Task ProxyToAuthorityAsync(
HttpContext context,
IHttpClientFactory httpClientFactory,
string authorityPath,
CancellationToken cancellationToken)
{
var loopbackPort = await ResolveLoopbackPortAsync(cancellationToken).ConfigureAwait(false);
var query = context.Request.QueryString.HasValue
? context.Request.QueryString.Value
: string.Empty;
var upstreamUri = new Uri($"http://127.0.0.1:{loopbackPort}{authorityPath}{query}", UriKind.Absolute);
using var upstreamRequest = new HttpRequestMessage(
new HttpMethod(context.Request.Method),
upstreamUri);
if (context.Request.ContentLength is > 0 ||
context.Request.Headers.ContainsKey(HeaderNames.TransferEncoding))
{
var body = new MemoryStream();
await context.Request.Body.CopyToAsync(body, cancellationToken).ConfigureAwait(false);
body.Position = 0;
upstreamRequest.Content = new StreamContent(body);
}
foreach (var header in context.Request.Headers)
{
if (IgnoredRequestHeaders.Contains(header.Key) || HopByHopHeaders.Contains(header.Key))
{
continue;
}
var values = header.Value.ToArray();
if (!upstreamRequest.Headers.TryAddWithoutValidation(header.Key, values))
{
upstreamRequest.Content?.Headers.TryAddWithoutValidation(header.Key, values);
}
}
var client = httpClientFactory.CreateClient("AuthorityBridge");
HttpResponseMessage upstreamResponse;
try
{
upstreamResponse = await client.SendAsync(
upstreamRequest,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException)
{
context.Response.StatusCode = StatusCodes.Status502BadGateway;
await context.Response.WriteAsJsonAsync(new
{
error = "authority_upstream_unavailable",
message = "Authority upstream endpoint could not be reached."
}, cancellationToken).ConfigureAwait(false);
return;
}
using (upstreamResponse)
{
context.Response.StatusCode = (int)upstreamResponse.StatusCode;
foreach (var header in upstreamResponse.Headers)
{
if (!HopByHopHeaders.Contains(header.Key))
{
context.Response.Headers[header.Key] = header.Value.ToArray();
}
}
foreach (var header in upstreamResponse.Content.Headers)
{
if (!HopByHopHeaders.Contains(header.Key) &&
!header.Key.Equals(HeaderNames.ContentLength, StringComparison.OrdinalIgnoreCase))
{
context.Response.Headers[header.Key] = header.Value.ToArray();
}
}
await upstreamResponse.Content.CopyToAsync(context.Response.Body, cancellationToken).ConfigureAwait(false);
}
}
private static async Task<int> ResolveLoopbackPortAsync(CancellationToken cancellationToken)
{
foreach (var port in GetCandidateLoopbackPorts())
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
try
{
await socket.ConnectAsync(IPAddress.Loopback, port, cancellationToken).ConfigureAwait(false);
return port;
}
catch (SocketException)
{
// Probe next candidate.
}
}
return 80;
}
private static IEnumerable<int> GetCandidateLoopbackPorts()
{
var seen = new HashSet<int>();
var rawUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
if (!string.IsNullOrWhiteSpace(rawUrls))
{
foreach (var rawUrl in rawUrls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var uri))
{
continue;
}
if (uri.Port > 0 && seen.Add(uri.Port))
{
yield return uri.Port;
}
}
}
if (seen.Add(80))
{
yield return 80;
}
if (seen.Add(8440))
{
yield return 8440;
}
}
}

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign In &mdash; StellaOps</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
background:#080A12;
color:#F0EDE4;display:flex;align-items:center;justify-content:center;min-height:100vh;
-webkit-font-smoothing:antialiased;position:relative;overflow:hidden}
body::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;
background:radial-gradient(ellipse 55% 45% at 50% 38%,rgba(224,154,24,0.07) 0%,transparent 70%),
radial-gradient(ellipse 40% 30% at 20% 80%,rgba(8,10,18,0.5) 0%,transparent 50%);
pointer-events:none;z-index:0}
.card{position:relative;z-index:1;background:rgba(18,21,31,0.85);
backdrop-filter:blur(24px) saturate(1.4);-webkit-backdrop-filter:blur(24px) saturate(1.4);
border-radius:24px;padding:2.5rem 2rem 2rem;width:100%;max-width:400px;
border:1px solid rgba(224,154,24,0.12);
box-shadow:0 0 60px rgba(224,154,24,0.04),0 20px 60px rgba(0,0,0,0.3),
0 8px 24px rgba(0,0,0,0.2),inset 0 1px 0 rgba(255,255,255,0.04);
animation:card-entrance 600ms cubic-bezier(0.18,0.89,0.32,1) both}
.logo-wrap{text-align:center;margin-bottom:0.25rem}
.logo-wrap img{max-width:248px;border-radius:32px;object-fit:contain;
filter:drop-shadow(0 4px 12px rgba(224,154,24,0.35));
animation:logo-pop 650ms cubic-bezier(0.34,1.56,0.64,1) 100ms both}
h1{font-size:1.5rem;text-align:center;margin-bottom:0.25rem;color:#F0EDE4;font-weight:700;
letter-spacing:-0.03em;animation:slide-up 500ms ease 200ms both}
.subtitle{text-align:center;color:#7A7568;font-size:.8125rem;margin-bottom:1.5rem;
font-weight:400;animation:fade-in 400ms ease 350ms both}
.error{background:rgba(239,68,68,0.12);border:1px solid rgba(239,68,68,0.25);color:#f87171;
padding:.75rem;border-radius:12px;margin-bottom:1rem;font-size:.8125rem;font-weight:500;
display:flex;align-items:center;gap:.5rem}
.error::before{content:'';width:6px;height:6px;border-radius:50%;background:#ef4444;flex-shrink:0}
label{display:block;font-size:.75rem;font-weight:600;color:#7A7568;margin-bottom:.375rem;
letter-spacing:0.03em;text-transform:uppercase}
input[type=text],input[type=password]{width:100%;padding:.75rem .875rem;
background:rgba(255,255,255,0.05);border:1px solid rgba(224,154,24,0.15);border-radius:12px;
color:#F0EDE4;font-size:.9375rem;margin-bottom:1rem;outline:none;font-family:inherit;
transition:border-color .2s,box-shadow .2s}
input[type=text]:focus,input[type=password]:focus{border-color:#CC8810;
box-shadow:0 0 0 3px rgba(224,154,24,0.15)}
input[type=text]::placeholder,input[type=password]::placeholder{color:#7A7568}
button{width:100%;padding:.875rem;margin-top:0.25rem;
background:linear-gradient(135deg,#D4920A 0%,#B8800A 100%);
color:#080A12;border:none;border-radius:14px;font-size:1rem;font-weight:700;
cursor:pointer;font-family:inherit;letter-spacing:0.01em;position:relative;overflow:hidden;
transition:transform .22s cubic-bezier(0.18,0.89,0.32,1),box-shadow .22s;
box-shadow:0 2px 16px rgba(212,146,10,0.30),0 1px 3px rgba(0,0,0,0.25)}
button:hover{transform:translateY(-2px);
box-shadow:0 6px 28px rgba(212,146,10,0.40),0 2px 8px rgba(0,0,0,0.20)}
button:active{transform:translateY(0);
box-shadow:0 1px 8px rgba(212,146,10,0.20),0 1px 2px rgba(0,0,0,0.15)}
button:focus-visible{outline:2px solid rgba(212,146,10,0.5);outline-offset:3px}
button::after{content:'';position:absolute;inset:0;
background:linear-gradient(105deg,transparent 38%,rgba(255,255,255,0.20) 50%,transparent 62%);
background-size:250% 100%;animation:shimmer 2.2s ease 1.2s}
@keyframes card-entrance{from{opacity:0;transform:translateY(24px) scale(0.97)}to{opacity:1;transform:translateY(0) scale(1)}}
@keyframes logo-pop{from{opacity:0;transform:scale(0.6)}to{opacity:1;transform:scale(1)}}
@keyframes slide-up{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
@keyframes fade-in{from{opacity:0}to{opacity:1}}
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-100% 0}}
@media(prefers-reduced-motion:reduce){.card,h1,.subtitle,.logo-wrap img,button::after{animation:none!important}
.card,h1,.subtitle,.logo-wrap img{opacity:1}button{transition:none}}
@media(max-width:480px){.card{margin:0 1rem;padding:2rem 1.5rem 1.75rem;border-radius:20px}
.logo-wrap img{max-width:177px;border-radius:24px}}
</style>
</head>
<body>
<form class="card" method="post" action="">
<div class="logo-wrap"><img src="/assets/img/site.png" alt="" /></div>
<h1>StellaOps</h1>
<p class="subtitle">Sign in to continue</p>
{{error_block}}
{{oidc_hidden_fields}}
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" placeholder="Enter username" required{{username_value}} />
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" placeholder="Enter password" required />
<button type="submit">Sign In</button>
</form>
</body>
</html>

View File

@@ -19,6 +19,7 @@ using Microsoft.Net.Http.Headers;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using StellaOps.Router.AspNet;
// Using PostgreSQL storage with in-memory compatibility shim
using Serilog;
using Serilog.Events;
@@ -302,6 +303,15 @@ builder.Services.AddHttpClient("StellaOps.Auth.ServerIntegration.Metadata")
ServerCertificateCustomValidationCallback = System.Net.Http.HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
// The gateway bridge proxies OIDC requests to the Authority's own loopback.
// Disable auto-redirect so 302 responses (authorization code grants) are passed
// back to the caller instead of followed inside the container.
builder.Services.AddHttpClient("AuthorityBridge")
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.HttpClientHandler
{
AllowAutoRedirect = false
});
builder.Services.TryAddSingleton<StellaOpsBypassEvaluator>();
builder.Services.AddOptions<StellaOpsResourceServerOptions>()
@@ -445,6 +455,12 @@ builder.Services.Configure<OpenIddictServerOptions>(options =>
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration, configurationSection: null);
builder.Services.AddAuthorization();
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "authority",
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.TryAddStellaOpsLocalBinding("authority");
var app = builder.Build();
app.LogStellaOpsLocalHostname("authority");
@@ -1752,6 +1768,7 @@ app.UseRateLimiter();
app.UseStellaOpsCors();
app.UseAuthentication();
app.UseAuthorization();
app.TryUseStellaRouter(routerEnabled);
app.MapGet("/health", async (IAuthorityIdentityProviderRegistry registry, CancellationToken cancellationToken) =>
{
@@ -1784,6 +1801,18 @@ app.MapGet("/ready", (IAuthorityIdentityProviderRegistry registry) =>
}))
.WithName("ReadinessCheck");
app.MapGet("/api/v1/claims/overrides", (IConfiguration configuration) =>
{
var configured = configuration
.GetSection("Authority:Router:ClaimsOverrides")
.Get<RouterClaimsOverridesResponse>() ?? new RouterClaimsOverridesResponse();
return Results.Ok(NormalizeRouterClaimsOverrides(configured));
})
.WithName("GetRouterClaimsOverrides")
.WithSummary("Get router claims overrides")
.WithDescription("Returns Authority-managed endpoint claim overrides consumed by router-gateway authorization refresh.");
app.MapPost("/permalinks/vuln", async (
VulnPermalinkRequest request,
VulnPermalinkService service,
@@ -3148,6 +3177,7 @@ app.MapConsoleEndpoints();
app.MapConsoleAdminEndpoints();
app.MapConsoleBrandingEndpoints();
app.MapAuthorizeEndpoint();
app.MapOpenIddictGatewayBridgeEndpoints();
@@ -3177,6 +3207,7 @@ app.MapGet("/jwks", (AuthorityJwksService jwksService, HttpContext context) =>
app.Services.GetRequiredService<AuthorityAckTokenKeyManager>();
app.Services.GetRequiredService<AuthoritySigningKeyManager>();
app.TryRefreshStellaRouterEndpoints(routerEnabled);
app.Run();
static PluginHostOptions BuildPluginHostOptions(StellaOpsAuthorityOptions options, string basePath)
@@ -3237,3 +3268,89 @@ static bool TryParseUris(IReadOnlyCollection<string>? values, out IReadOnlyColle
uris = parsed;
return true;
}
static RouterClaimsOverridesResponse NormalizeRouterClaimsOverrides(RouterClaimsOverridesResponse response)
{
var overrides = response.Overrides
.Where(entry =>
!string.IsNullOrWhiteSpace(entry.ServiceName) &&
!string.IsNullOrWhiteSpace(entry.Method) &&
!string.IsNullOrWhiteSpace(entry.Path))
.Select(entry => new RouterClaimsOverrideEntry
{
ServiceName = entry.ServiceName.Trim(),
Method = entry.Method.Trim().ToUpperInvariant(),
Path = NormalizeOverridePath(entry.Path),
RequiringClaims = entry.RequiringClaims
.Where(claim => !string.IsNullOrWhiteSpace(claim.Type))
.Select(claim => new RouterClaimRequirementEntry
{
Type = claim.Type.Trim(),
Value = string.IsNullOrWhiteSpace(claim.Value) ? null : claim.Value.Trim()
})
.Distinct()
.OrderBy(claim => claim.Type, StringComparer.Ordinal)
.ThenBy(claim => claim.Value, StringComparer.Ordinal)
.ToList()
})
.GroupBy(
entry => $"{entry.ServiceName.ToLowerInvariant()}|{entry.Method}|{entry.Path}",
StringComparer.Ordinal)
.Select(group =>
{
var first = group.First();
return new RouterClaimsOverrideEntry
{
ServiceName = first.ServiceName,
Method = first.Method,
Path = first.Path,
RequiringClaims = group
.SelectMany(entry => entry.RequiringClaims)
.Distinct()
.OrderBy(claim => claim.Type, StringComparer.Ordinal)
.ThenBy(claim => claim.Value, StringComparer.Ordinal)
.ToList()
};
})
.OrderBy(entry => entry.ServiceName, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.Method, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.Path, StringComparer.OrdinalIgnoreCase)
.ToList();
return new RouterClaimsOverridesResponse
{
Overrides = overrides
};
}
static string NormalizeOverridePath(string rawPath)
{
var path = rawPath.Trim();
if (!path.StartsWith('/'))
{
path = "/" + path;
}
path = path.TrimEnd('/');
return string.IsNullOrEmpty(path) ? "/" : path;
}
sealed class RouterClaimsOverridesResponse
{
public List<RouterClaimsOverrideEntry> Overrides { get; init; } = [];
}
sealed class RouterClaimsOverrideEntry
{
public string ServiceName { get; init; } = string.Empty;
public string Method { get; init; } = "GET";
public string Path { get; init; } = "/";
public List<RouterClaimRequirementEntry> RequiringClaims { get; init; } = [];
}
sealed record RouterClaimRequirementEntry
{
public string Type { get; init; } = string.Empty;
public string? Value { get; init; }
}

View File

@@ -36,9 +36,16 @@
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Pages\*.html" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\StellaOps.Api.OpenApi\authority\openapi.yaml" Link="OpenApi\authority.yaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<PropertyGroup Label="StellaOpsReleaseVersion">
<Version>1.0.0-alpha1</Version>
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
</PropertyGroup>
</Project>

View File

@@ -78,9 +78,24 @@ ON CONFLICT (tenant_id, name) DO NOTHING;
INSERT INTO authority.clients (id, client_id, display_name, description, enabled, redirect_uris, allowed_scopes, allowed_grant_types, require_client_secret, require_pkce)
VALUES
('demo-client-ui', 'stellaops-console', 'Stella Ops Console', 'Web UI application', true,
ARRAY['https://stella-ops.local/callback', 'https://stella-ops.local/silent-renew'],
ARRAY['openid', 'profile', 'email', 'stellaops.api'],
('demo-client-ui', 'stella-ops-ui', 'Stella Ops Console', 'Web UI application', true,
ARRAY['https://stella-ops.local/auth/callback', 'https://stella-ops.local/auth/silent-refresh'],
ARRAY['openid', 'profile', 'email', 'offline_access',
'ui.read', 'ui.admin',
'authority:tenants.read', 'authority:users.read', 'authority:roles.read',
'authority:clients.read', 'authority:tokens.read', 'authority:branding.read',
'authority.audit.read',
'graph:read', 'sbom:read', 'scanner:read',
'policy:read', 'policy:simulate', 'policy:author', 'policy:review', 'policy:approve',
'orch:read', 'analytics.read', 'advisory:read', 'vex:read',
'exceptions:read', 'exceptions:approve', 'aoc:verify', 'findings:read',
'release:read', 'scheduler:read', 'scheduler:operate',
'notify.viewer', 'notify.operator', 'notify.admin', 'notify.escalate',
'evidence:read',
'export.viewer', 'export.operator', 'export.admin',
'vuln:view', 'vuln:investigate', 'vuln:operate', 'vuln:audit',
'platform.context.read', 'platform.context.write',
'doctor:run', 'doctor:admin'],
ARRAY['authorization_code', 'refresh_token'],
false, true),
('demo-client-cli', 'stellaops-cli', 'Stella Ops CLI', 'Command-line client', true,