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:
master
2026-03-16 18:27:46 +02:00
parent 1acc87a25d
commit 4d8a48a05f
14 changed files with 482 additions and 142 deletions

View File

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