diff --git a/src/Concelier/StellaOps.Concelier.WebService/Program.cs b/src/Concelier/StellaOps.Concelier.WebService/Program.cs index 72a9a7468..699493caf 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Program.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Program.cs @@ -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 Aliases, string Fingerprint) C return (advisory, aliases, fingerprint); } +/// +/// 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. +/// +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() + ?.GetValue("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 + { + 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 BuildAliasQuery(Advisory advisory) { var set = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Program.cs b/src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Program.cs index c41ed096a..7be4a5d64 100644 --- a/src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Program.cs +++ b/src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Program.cs @@ -200,6 +200,7 @@ app.MapReleaseEndpoints(); app.MapApprovalEndpoints(); app.MapReleaseDashboardEndpoints(); app.MapReleaseControlV2Endpoints(); +app.MapAuditEndpoints(); // Refresh Router endpoint cache app.TryRefreshStellaRouterEndpoints(routerEnabled); diff --git a/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts b/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts index 2cef16506..f45a3b3fd 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts @@ -379,17 +379,7 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi { } return this.http.get('/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: '', }), ), );