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:
master
2026-03-16 09:24:04 +02:00
parent 4e07f7bd72
commit ed6cd76c62
3 changed files with 100 additions and 14 deletions

View File

@@ -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);

View File

@@ -200,6 +200,7 @@ app.MapReleaseEndpoints();
app.MapApprovalEndpoints();
app.MapReleaseDashboardEndpoints();
app.MapReleaseControlV2Endpoints();
app.MapAuditEndpoints();
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerEnabled);

View File

@@ -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: '',
}),
),
);