Sprint 7+8: Journey UX fixes + identity envelope shared middleware
Sprint 7 — Deep journey fixes:
S7-T01: Trust & Signing empty state with "Go to Signing Keys" CTA
S7-T02: Notifications 3-step setup guide (channel→rule→test)
S7-T03: Topology validate step skip — "Skip Validation" when API fails,
with validateSkipped signal matching agentSkipped pattern
S7-T04: VEX export note on Risk Report tab linking to VEX Ledger
Sprint 8 — Identity envelope shared middleware (ARCHITECTURE):
S8-T01: New UseIdentityEnvelopeAuthentication() extension in
StellaOps.Router.AspNet. Reads X-StellaOps-Identity-Envelope headers,
verifies HMAC-SHA256 via GatewayIdentityEnvelopeCodec, creates
ClaimsPrincipal with sub/tenant/scopes/roles. 5min clock skew.
S8-T02: Concelier refactored — removed 78 lines of inline impl,
now uses shared one-liner
S8-T03: Scanner — UseIdentityEnvelopeAuthentication() added
S8-T04: JobEngine — UseIdentityEnvelopeAuthentication() added
S8-T05: Timeline — UseIdentityEnvelopeAuthentication() added
S8-T06: Integrations — UseIdentityEnvelopeAuthentication() added
S8-T07: docs/modules/router/IDENTITY_ENVELOPE_MIDDLEWARE.md
All services now authenticate ReverseProxy requests via gateway envelope.
Scanner scan submit should now work with authenticated identity.
Angular: 0 errors. .NET (6 services): 0 errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,7 +62,6 @@ 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;
|
||||
@@ -786,40 +785,8 @@ if (authorityConfigured)
|
||||
}
|
||||
});
|
||||
|
||||
// Add identity envelope fallback for ReverseProxy routes (OIDC branch).
|
||||
// When the gateway proxies a request via ReverseProxy, it strips the Authorization header
|
||||
// and attaches signed X-StellaOps-Identity-Envelope headers. This handler reads those
|
||||
// headers and converts them to a ClaimsPrincipal when no JWT token is present.
|
||||
builder.Services.PostConfigure<JwtBearerOptions>(
|
||||
StellaOpsAuthenticationDefaults.AuthenticationScheme,
|
||||
jwtOptions =>
|
||||
{
|
||||
var existingOnMessageReceived = jwtOptions.Events?.OnMessageReceived;
|
||||
jwtOptions.Events ??= new JwtBearerEvents();
|
||||
jwtOptions.Events.OnMessageReceived = async context =>
|
||||
{
|
||||
if (existingOnMessageReceived is not null)
|
||||
{
|
||||
await existingOnMessageReceived(context);
|
||||
}
|
||||
|
||||
// If JWT handler already found a token or succeeded, skip envelope check
|
||||
if (!string.IsNullOrWhiteSpace(context.Token) || context.Result?.Succeeded == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var logger = context.HttpContext.RequestServices
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("Concelier.IdentityEnvelope");
|
||||
|
||||
if (TryAuthenticateFromIdentityEnvelope(context.HttpContext, logger))
|
||||
{
|
||||
context.Principal = context.HttpContext.User;
|
||||
context.Success();
|
||||
}
|
||||
};
|
||||
});
|
||||
// Identity envelope authentication is now handled by the shared middleware
|
||||
// registered via app.UseIdentityEnvelopeAuthentication() in the pipeline.
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -882,12 +849,11 @@ if (authorityConfigured)
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
// No JWT token — check for gateway identity envelope (ReverseProxy passthrough)
|
||||
if (TryAuthenticateFromIdentityEnvelope(context.HttpContext, logger))
|
||||
// No JWT token — check if the shared identity envelope middleware
|
||||
// (UseIdentityEnvelopeAuthentication) already authenticated from
|
||||
// gateway-signed envelope headers (ReverseProxy passthrough).
|
||||
if (context.HttpContext.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
// Envelope authentication succeeded — set the principal and mark as handled.
|
||||
// Use context.Principal + context.Success() so the JwtBearer handler
|
||||
// reports success without trying to validate a JWT.
|
||||
context.Principal = context.HttpContext.User;
|
||||
context.Success();
|
||||
return Task.CompletedTask;
|
||||
@@ -1045,17 +1011,7 @@ if (authorityConfigured)
|
||||
{
|
||||
// Identity envelope middleware: authenticate ReverseProxy requests from the gateway.
|
||||
// Must run BEFORE UseAuthentication so the principal is set before JwtBearer evaluates.
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (context.User?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
var envelopeLogger = context.RequestServices
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("Concelier.IdentityEnvelope");
|
||||
TryAuthenticateFromIdentityEnvelope(context, envelopeLogger);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
app.UseIdentityEnvelopeAuthentication();
|
||||
|
||||
app.UseAuthentication();
|
||||
|
||||
@@ -3663,91 +3619,6 @@ 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 config = httpContext.RequestServices.GetService<IConfiguration>();
|
||||
var signingKey = config?.GetValue<string>("Router:IdentityEnvelopeSigningKey")
|
||||
?? 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);
|
||||
|
||||
Reference in New Issue
Block a user