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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user