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

@@ -0,0 +1,141 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Identity;
namespace StellaOps.Router.AspNet;
/// <summary>
/// Provides middleware that authenticates incoming requests using the gateway-signed
/// identity envelope headers. When the Router gateway proxies a request via ReverseProxy,
/// it strips the Authorization header and attaches <c>X-StellaOps-Identity-Envelope</c>
/// and <c>X-StellaOps-Identity-Envelope-Signature</c> headers containing a signed
/// <see cref="GatewayIdentityEnvelope"/>. This middleware verifies the HMAC-SHA256
/// signature and hydrates <see cref="HttpContext.User"/> with the envelope claims.
/// </summary>
public static class IdentityEnvelopeMiddlewareExtensions
{
private const string EnvelopeHeader = "X-StellaOps-Identity-Envelope";
private const string SignatureHeader = "X-StellaOps-Identity-Envelope-Signature";
private const string LogCategory = "StellaOps.IdentityEnvelope";
private const string AuthenticationType = "StellaRouterEnvelope";
/// <summary>
/// Adds identity envelope authentication middleware to the pipeline.
/// This must be called <b>before</b> <c>app.UseAuthentication()</c> so the
/// <see cref="ClaimsPrincipal"/> is available when the JWT bearer handler runs.
/// </summary>
/// <remarks>
/// The middleware never throws. All errors are logged and the request continues
/// unauthenticated, allowing the standard authentication pipeline to handle it.
/// </remarks>
public static IApplicationBuilder UseIdentityEnvelopeAuthentication(this IApplicationBuilder app)
{
return app.Use(async (context, next) =>
{
if (context.User?.Identity?.IsAuthenticated != true)
{
TryAuthenticateFromIdentityEnvelope(context);
}
await next();
});
}
private static void TryAuthenticateFromIdentityEnvelope(HttpContext httpContext)
{
var logger = httpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger(LogCategory);
try
{
var headers = httpContext.Request.Headers;
if (!headers.TryGetValue(EnvelopeHeader, out var envelopePayload) ||
!headers.TryGetValue(SignatureHeader, out var envelopeSignature))
{
return;
}
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;
}
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;
}
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;
}
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: AuthenticationType,
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)));
}
catch (Exception ex)
{
// Never throw — all errors are logged and the request continues unauthenticated.
logger.LogError(ex, "Unexpected error processing identity envelope for {Path}", httpContext.Request.Path);
}
}
}