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.Provenance;
|
||||||
using StellaOps.Localization;
|
using StellaOps.Localization;
|
||||||
using StellaOps.Router.AspNet;
|
using StellaOps.Router.AspNet;
|
||||||
|
using StellaOps.Router.Common.Identity;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
@@ -846,6 +847,15 @@ if (authorityConfigured)
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
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);
|
logger.LogWarning("JWT token missing from request to {Path}", context.HttpContext.Request.Path);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -3602,6 +3612,91 @@ static (Advisory Advisory, ImmutableArray<string> Aliases, string Fingerprint) C
|
|||||||
return (advisory, aliases, fingerprint);
|
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)
|
static ImmutableArray<string> BuildAliasQuery(Advisory advisory)
|
||||||
{
|
{
|
||||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ app.MapReleaseEndpoints();
|
|||||||
app.MapApprovalEndpoints();
|
app.MapApprovalEndpoints();
|
||||||
app.MapReleaseDashboardEndpoints();
|
app.MapReleaseDashboardEndpoints();
|
||||||
app.MapReleaseControlV2Endpoints();
|
app.MapReleaseControlV2Endpoints();
|
||||||
|
app.MapAuditEndpoints();
|
||||||
|
|
||||||
// Refresh Router endpoint cache
|
// Refresh Router endpoint cache
|
||||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
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(
|
return this.http.get<RegistryImage[]>('/api/registry/images/search', { params: { q: query } }).pipe(
|
||||||
catchError(() =>
|
catchError(() => of([])),
|
||||||
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(),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,9 +389,9 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
|
|||||||
of({
|
of({
|
||||||
name: repository.split('/').at(-1) ?? repository,
|
name: repository.split('/').at(-1) ?? repository,
|
||||||
repository,
|
repository,
|
||||||
tags: ['latest'],
|
tags: [],
|
||||||
digests: [{ tag: 'latest', digest: 'sha256:mockdigest', pushedAt: new Date().toISOString() }],
|
digests: [],
|
||||||
lastPushed: new Date().toISOString(),
|
lastPushed: '',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user