Fix critical journey blockers: audit endpoints, registry mock, topology auth
Fix #20 — Audit log empty: Wire app.MapAuditEndpoints() in JobEngine Program.cs. The endpoint file existed but was never registered, so /api/v1/jobengine/audit returned 404 and the Timeline unified aggregation service got 0 events. Fix #22 — Registry search returns mock data: Replace the catchError() synthetic mock fallback in searchImages() with an empty array return. The release wizard will now show "no results" instead of fabricating fake "payment-service" with "sha256:payment..." digests. getImageDigests() returns an empty-tags placeholder on failure. Fix #13 — Topology wizard 401 (identity envelope passthrough): Add TryAuthenticateFromIdentityEnvelope() to Concelier's JwtBearer OnMessageReceived handler. When no JWT bearer token is present (stripped by gateway's IdentityHeaderPolicyMiddleware on ReverseProxy routes), the handler reads X-StellaOps-Identity-Envelope + signature headers, verifies the HMAC-SHA256 signature using the shared signing key, and populates ClaimsPrincipal with subject/tenant/scopes/roles from the envelope. This enables ReverseProxy routes to Concelier topology endpoints to authenticate the same way Microservice/Valkey routes do. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,7 @@ using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Provenance;
|
||||
using StellaOps.Localization;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.Router.Common.Identity;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
@@ -846,6 +847,15 @@ if (authorityConfigured)
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
// No JWT token — check for gateway identity envelope (ReverseProxy passthrough)
|
||||
if (TryAuthenticateFromIdentityEnvelope(context.HttpContext, logger))
|
||||
{
|
||||
// Envelope authentication succeeded — skip JWT validation
|
||||
context.NoResult();
|
||||
context.Success();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
logger.LogWarning("JWT token missing from request to {Path}", context.HttpContext.Request.Path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -3602,6 +3612,91 @@ static (Advisory Advisory, ImmutableArray<string> Aliases, string Fingerprint) C
|
||||
return (advisory, aliases, fingerprint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to authenticate an HTTP request using the gateway's identity envelope headers.
|
||||
/// Used for ReverseProxy routes where the gateway strips the JWT Authorization header and
|
||||
/// attaches a signed identity envelope instead.
|
||||
/// </summary>
|
||||
static bool TryAuthenticateFromIdentityEnvelope(HttpContext httpContext, Microsoft.Extensions.Logging.ILogger logger)
|
||||
{
|
||||
var headers = httpContext.Request.Headers;
|
||||
|
||||
if (!headers.TryGetValue("X-StellaOps-Identity-Envelope", out var envelopePayload) ||
|
||||
!headers.TryGetValue("X-StellaOps-Identity-Envelope-Signature", out var envelopeSignature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var signingKey = httpContext.RequestServices.GetService<IConfiguration>()
|
||||
?.GetValue<string>("STELLAOPS_IDENTITY_ENVELOPE_SIGNING_KEY")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_IDENTITY_ENVELOPE_SIGNING_KEY");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signingKey))
|
||||
{
|
||||
logger.LogWarning("Identity envelope received but signing key not configured");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!GatewayIdentityEnvelopeCodec.TryVerify(
|
||||
envelopePayload.ToString(),
|
||||
envelopeSignature.ToString(),
|
||||
signingKey,
|
||||
out var envelope) || envelope is null)
|
||||
{
|
||||
logger.LogWarning("Identity envelope signature verification failed for {Path}", httpContext.Request.Path);
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var skew = TimeSpan.FromMinutes(5);
|
||||
|
||||
if (envelope.IssuedAtUtc - skew > now || envelope.ExpiresAtUtc + skew < now)
|
||||
{
|
||||
logger.LogWarning("Identity envelope expired or not yet valid for {Path}", httpContext.Request.Path);
|
||||
return false;
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, envelope.Subject),
|
||||
new("sub", envelope.Subject)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(envelope.Tenant))
|
||||
{
|
||||
claims.Add(new Claim("stellaops:tenant", envelope.Tenant));
|
||||
claims.Add(new Claim("tenant", envelope.Tenant));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(envelope.Project))
|
||||
{
|
||||
claims.Add(new Claim("stellaops:project", envelope.Project));
|
||||
claims.Add(new Claim("project", envelope.Project));
|
||||
}
|
||||
|
||||
foreach (var scope in envelope.Scopes.Where(s => !string.IsNullOrWhiteSpace(s)))
|
||||
{
|
||||
claims.Add(new Claim("scope", scope));
|
||||
}
|
||||
|
||||
foreach (var role in envelope.Roles.Where(r => !string.IsNullOrWhiteSpace(r)))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
}
|
||||
|
||||
httpContext.User = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
claims,
|
||||
authenticationType: "StellaRouterEnvelope",
|
||||
nameType: ClaimTypes.NameIdentifier,
|
||||
roleType: ClaimTypes.Role));
|
||||
|
||||
logger.LogInformation("Authenticated via identity envelope for {Path}: subject={Subject} tenant={Tenant} scopes={Scopes}",
|
||||
httpContext.Request.Path, envelope.Subject, envelope.Tenant, string.Join(' ', envelope.Scopes.Take(5)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static ImmutableArray<string> BuildAliasQuery(Advisory advisory)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -200,6 +200,7 @@ app.MapReleaseEndpoints();
|
||||
app.MapApprovalEndpoints();
|
||||
app.MapReleaseDashboardEndpoints();
|
||||
app.MapReleaseControlV2Endpoints();
|
||||
app.MapAuditEndpoints();
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
@@ -379,17 +379,7 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
|
||||
}
|
||||
|
||||
return this.http.get<RegistryImage[]>('/api/registry/images/search', { params: { q: query } }).pipe(
|
||||
catchError(() =>
|
||||
of([
|
||||
{
|
||||
name: `${query}-service`,
|
||||
repository: `registry.internal/${query}-service`,
|
||||
tags: ['latest'],
|
||||
digests: [{ tag: 'latest', digest: `sha256:${query}1234567890abcdef`, pushedAt: new Date().toISOString() }],
|
||||
lastPushed: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
),
|
||||
catchError(() => of([])),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -399,9 +389,9 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
|
||||
of({
|
||||
name: repository.split('/').at(-1) ?? repository,
|
||||
repository,
|
||||
tags: ['latest'],
|
||||
digests: [{ tag: 'latest', digest: 'sha256:mockdigest', pushedAt: new Date().toISOString() }],
|
||||
lastPushed: new Date().toISOString(),
|
||||
tags: [],
|
||||
digests: [],
|
||||
lastPushed: '',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user