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,22 @@
|
||||
# Sprint 20260316-007 — Deep Journey Remaining Fixes
|
||||
|
||||
## Topic & Scope
|
||||
- Fix the 4 remaining UX issues found during deep journey testing (J-05 through J-08).
|
||||
- Trust & Signing empty state, Notifications empty state, Topology validate skip, VEX export visibility.
|
||||
- Working directory: `src/Web/StellaOps.Web/`.
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### S7-T01 - Trust & Signing empty state guidance
|
||||
Status: TODO
|
||||
### S7-T02 - Notifications empty state guidance
|
||||
Status: TODO
|
||||
### S7-T03 - Topology wizard validate step skip
|
||||
Status: TODO
|
||||
### S7-T04 - VEX export button visibility
|
||||
Status: TODO
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-16 | Sprint created from deep journey findings J-05 to J-08. | Developer |
|
||||
@@ -0,0 +1,29 @@
|
||||
# Sprint 20260316-008 — Identity Envelope Shared Middleware (Architecture)
|
||||
|
||||
## Topic & Scope
|
||||
- Extract identity envelope pre-auth middleware from Concelier into shared `StellaOps.Router.AspNet` extension.
|
||||
- Apply to all 5 services: Concelier (refactor), Scanner, JobEngine, Timeline, Integrations.
|
||||
- This unblocks scan submit (J-04) and all future ReverseProxy-routed authenticated endpoints.
|
||||
- Working directory: `src/Router/__Libraries/StellaOps.Router.AspNet/`, `src/*/Program.cs`.
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### S8-T01 - Create shared middleware extension
|
||||
Status: TODO
|
||||
### S8-T02 - Refactor Concelier to use shared extension
|
||||
Status: TODO
|
||||
### S8-T03 - Add to Scanner
|
||||
Status: TODO
|
||||
### S8-T04 - Add to JobEngine
|
||||
Status: TODO
|
||||
### S8-T05 - Add to Timeline
|
||||
Status: TODO
|
||||
### S8-T06 - Add to Integrations
|
||||
Status: TODO
|
||||
### S8-T07 - Document the pattern
|
||||
Status: TODO
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-16 | Sprint created from architecture finding. | Developer |
|
||||
113
docs/modules/router/IDENTITY_ENVELOPE_MIDDLEWARE.md
Normal file
113
docs/modules/router/IDENTITY_ENVELOPE_MIDDLEWARE.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Identity Envelope Middleware
|
||||
|
||||
## What
|
||||
|
||||
`UseIdentityEnvelopeAuthentication()` is a shared ASP.NET Core middleware that reads
|
||||
gateway-signed identity envelope headers and hydrates `HttpContext.User` with the
|
||||
enclosed claims. It lives in `StellaOps.Router.AspNet` and is consumed by every
|
||||
downstream microservice that receives proxied requests from the Router gateway.
|
||||
|
||||
## Why
|
||||
|
||||
When the Router gateway forwards a request via YARP ReverseProxy, it strips the
|
||||
original `Authorization: Bearer <jwt>` header. Instead, it signs the authenticated
|
||||
user's identity into two custom headers:
|
||||
|
||||
- `X-StellaOps-Identity-Envelope` -- Base64URL-encoded JSON payload
|
||||
- `X-StellaOps-Identity-Envelope-Signature` -- HMAC-SHA256 signature
|
||||
|
||||
Downstream services need a way to recover the caller's identity from these headers
|
||||
without each service re-implementing the same verification logic. The shared
|
||||
middleware eliminates duplication and ensures consistent claim mapping everywhere.
|
||||
|
||||
## How to use
|
||||
|
||||
In the service's `Program.cs`, register the middleware **before** `UseAuthentication()`:
|
||||
|
||||
```csharp
|
||||
using StellaOps.Router.AspNet;
|
||||
|
||||
// ...
|
||||
|
||||
app.UseIdentityEnvelopeAuthentication(); // <-- must come first
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
```
|
||||
|
||||
The service's `.csproj` must reference `StellaOps.Router.AspNet` (which transitively
|
||||
brings in `StellaOps.Router.Common` containing the codec).
|
||||
|
||||
## Configuration
|
||||
|
||||
The middleware reads the HMAC-SHA256 signing key from one of two sources (first wins):
|
||||
|
||||
| Source | Key |
|
||||
|---|---|
|
||||
| `IConfiguration` | `Router:IdentityEnvelopeSigningKey` |
|
||||
| Environment variable | `STELLAOPS_IDENTITY_ENVELOPE_SIGNING_KEY` |
|
||||
|
||||
In the docker-compose environment the key is injected via `x-router-microservice-defaults`
|
||||
so all services share the same value automatically.
|
||||
|
||||
## Claim mapping
|
||||
|
||||
When a valid, non-expired envelope is verified, the middleware creates a
|
||||
`ClaimsPrincipal` with authentication type `StellaRouterEnvelope` and the following
|
||||
claims:
|
||||
|
||||
| Claim type | Source |
|
||||
|---|---|
|
||||
| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` | `envelope.Subject` |
|
||||
| `sub` | `envelope.Subject` |
|
||||
| `stellaops:tenant` | `envelope.Tenant` |
|
||||
| `tenant` | `envelope.Tenant` |
|
||||
| `stellaops:project` | `envelope.Project` |
|
||||
| `project` | `envelope.Project` |
|
||||
| `scope` (one per scope) | `envelope.Scopes` |
|
||||
| `http://schemas.microsoft.com/ws/2008/06/identity/claims/role` (one per role) | `envelope.Roles` |
|
||||
|
||||
## Architecture flow
|
||||
|
||||
```
|
||||
Browser
|
||||
|
|
||||
| Authorization: Bearer <jwt>
|
||||
v
|
||||
Gateway (StellaOps.Gateway.WebService)
|
||||
| 1. Validates JWT
|
||||
| 2. Signs identity into envelope headers (HMAC-SHA256)
|
||||
| 3. Strips Authorization header
|
||||
| 4. Forwards via YARP ReverseProxy
|
||||
v
|
||||
Downstream Service (Scanner, JobEngine, Timeline, Integrations, Concelier, ...)
|
||||
| UseIdentityEnvelopeAuthentication()
|
||||
| 1. Reads X-StellaOps-Identity-Envelope + Signature headers
|
||||
| 2. Verifies HMAC-SHA256 signature
|
||||
| 3. Checks clock skew (5 min tolerance)
|
||||
| 4. Hydrates HttpContext.User
|
||||
v
|
||||
UseAuthentication() / UseAuthorization()
|
||||
(standard ASP.NET pipeline continues with the hydrated principal)
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
The middleware never throws. All failures (missing headers, missing key, bad signature,
|
||||
expired envelope) are logged at Warning level under the `StellaOps.IdentityEnvelope`
|
||||
logger category. The request continues unauthenticated, allowing the standard JWT
|
||||
bearer handler to attempt authentication normally.
|
||||
|
||||
## Adopted services
|
||||
|
||||
| Service | File |
|
||||
|---|---|
|
||||
| Concelier | `src/Concelier/StellaOps.Concelier.WebService/Program.cs` |
|
||||
| Scanner | `src/Scanner/StellaOps.Scanner.WebService/Program.cs` |
|
||||
| JobEngine | `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Program.cs` |
|
||||
| Timeline | `src/Timeline/StellaOps.Timeline.WebService/Program.cs` |
|
||||
| Integrations | `src/Integrations/StellaOps.Integrations.WebService/Program.cs` |
|
||||
|
||||
## Source
|
||||
|
||||
- Middleware extension: `src/Router/__Libraries/StellaOps.Router.AspNet/IdentityEnvelopeMiddlewareExtensions.cs`
|
||||
- Envelope model and codec: `src/Router/__Libraries/StellaOps.Router.Common/Identity/GatewayIdentityEnvelope.cs`
|
||||
@@ -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);
|
||||
|
||||
@@ -118,6 +118,7 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseStellaOpsLocalization();
|
||||
app.UseIdentityEnvelopeAuthentication();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseStellaOpsTenantMiddleware();
|
||||
|
||||
@@ -149,6 +149,7 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseStellaOpsLocalization();
|
||||
app.UseIdentityEnvelopeAuthentication();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseStellaOpsTenantMiddleware();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -750,6 +750,7 @@ app.UseExceptionHandler(errorApp =>
|
||||
// Even in anonymous mode, endpoints use RequireAuthorization() which needs the middleware
|
||||
app.UseStellaOpsCors();
|
||||
app.UseStellaOpsLocalization();
|
||||
app.UseIdentityEnvelopeAuthentication();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseStellaOpsTenantMiddleware();
|
||||
|
||||
@@ -105,6 +105,7 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseStellaOpsLocalization();
|
||||
app.UseIdentityEnvelopeAuthentication();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseStellaOpsTenantMiddleware();
|
||||
|
||||
@@ -84,6 +84,28 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup Guidance (shown when no channels and no rules exist) -->
|
||||
@if (!loading() && channels().length === 0 && rules().length === 0) {
|
||||
<div class="setup-guidance">
|
||||
<h2 class="setup-guidance__title">Get started with notifications</h2>
|
||||
<p class="setup-guidance__desc">Follow these three steps to start receiving alerts for findings, gate decisions, and freshness events.</p>
|
||||
<ol class="setup-guidance__steps">
|
||||
<li>
|
||||
<strong>Create a channel</strong> (Slack, webhook, or email)
|
||||
<br><button class="btn-link" (click)="activeTab.set('channels')">Go to Channels tab</button>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Create a notification rule</strong> (trigger on findings, gates, or freshness)
|
||||
<br><button class="btn-link" (click)="activeTab.set('rules')">Go to Rules tab</button>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Test your channel</strong> to verify delivery
|
||||
<br><a routerLink="simulator" class="btn-link">Go to Simulator</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button class="tab" [class.active]="activeTab() === 'channels'" (click)="activeTab.set('channels')">
|
||||
@@ -518,6 +540,21 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
|
||||
.status-acknowledged { background: var(--color-status-warning-bg); color: var(--color-status-warning); }
|
||||
.status-resolved { background: var(--color-status-success-border); color: var(--color-status-success-text); }
|
||||
|
||||
.setup-guidance {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px dashed var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
.setup-guidance__title { margin: 0 0 0.35rem; font-size: 1.1rem; }
|
||||
.setup-guidance__desc { margin: 0 0 1rem; color: var(--color-text-secondary); font-size: 0.875rem; }
|
||||
.setup-guidance__steps { margin: 0; padding-left: 1.25rem; display: grid; gap: 0.85rem; }
|
||||
.setup-guidance__steps li { line-height: 1.6; }
|
||||
.setup-guidance__steps strong { color: var(--color-text-primary); }
|
||||
.btn-link { background: none; border: none; color: var(--color-status-info-text); cursor: pointer; padding: 0; font-size: 0.85rem; font-weight: var(--font-weight-semibold); text-decoration: underline; }
|
||||
a.btn-link { text-decoration: underline; }
|
||||
|
||||
.empty-state { text-align: center; padding: 3rem; color: var(--color-text-secondary); }
|
||||
.loading { text-align: center; padding: 2rem; color: var(--color-text-secondary); }
|
||||
.error-banner { background: var(--color-status-error-bg); color: var(--color-status-error-text); padding: 1rem; border-radius: var(--radius-sm); margin-top: 1rem; }
|
||||
|
||||
@@ -546,10 +546,28 @@ import {
|
||||
</button>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<p>Validation has not been run yet.</p>
|
||||
<button type="button" class="btn btn--primary btn--sm" (click)="runValidation()">
|
||||
Run Validation
|
||||
</button>
|
||||
@if (validationFailed()) {
|
||||
<p>Validation requires deployed infrastructure. You can skip validation and complete setup.</p>
|
||||
<div class="empty-state__actions">
|
||||
<button type="button" class="btn btn--primary btn--sm" (click)="runValidation()">
|
||||
Retry Validation
|
||||
</button>
|
||||
<button type="button" class="btn btn--secondary btn--sm" (click)="skipValidation()">
|
||||
Skip Validation
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<p>Validation has not been run yet.</p>
|
||||
<button type="button" class="btn btn--primary btn--sm" (click)="runValidation()">
|
||||
Run Validation
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (wizard.validateSkipped()) {
|
||||
<div class="skip-notice">
|
||||
Validation was skipped. You can run it later from the topology dashboard.
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -848,6 +866,23 @@ import {
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.skip-notice {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-status-warning-border, #e6c200);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-status-warning-bg, #fff8e1);
|
||||
color: var(--color-status-warning-text, #7a6100);
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.skip-agent-link {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
@@ -1379,6 +1414,7 @@ export class TopologyWizardComponent implements OnInit, OnDestroy {
|
||||
readonly environmentsLoading = signal(false);
|
||||
readonly bindingsLoading = signal(false);
|
||||
readonly validationLoading = signal(false);
|
||||
readonly validationFailed = signal(false);
|
||||
|
||||
// Region creation form
|
||||
readonly newRegionName = signal('');
|
||||
@@ -1591,6 +1627,7 @@ export class TopologyWizardComponent implements OnInit, OnDestroy {
|
||||
if (!targetId) return;
|
||||
|
||||
this.validationLoading.set(true);
|
||||
this.validationFailed.set(false);
|
||||
const sub = this.wizard.validateTarget(targetId).subscribe({
|
||||
next: (report) => {
|
||||
this.wizard.readinessReport.set(report);
|
||||
@@ -1598,9 +1635,14 @@ export class TopologyWizardComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.wizard.error.set(err instanceof Error ? err.message : 'Validation failed.');
|
||||
this.validationFailed.set(true);
|
||||
this.validationLoading.set(false);
|
||||
},
|
||||
});
|
||||
this.subscriptions.push(sub);
|
||||
}
|
||||
|
||||
skipValidation(): void {
|
||||
this.wizard.validateSkipped.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ export class TopologyWizardService {
|
||||
readonly createdTarget = signal<Target | null>(null);
|
||||
readonly selectedAgent = signal<Agent | null>(null);
|
||||
readonly agentSkipped = signal(false);
|
||||
readonly validateSkipped = signal(false);
|
||||
readonly resolvedBindings = signal<ResolvedBindings | null>(null);
|
||||
readonly readinessReport = signal<ReadinessReport | null>(null);
|
||||
readonly loading = signal(false);
|
||||
@@ -107,7 +108,7 @@ export class TopologyWizardService {
|
||||
case 'target': return this.createdTarget() !== null;
|
||||
case 'agent': return this.selectedAgent() !== null || this.agentSkipped();
|
||||
case 'infrastructure': return true;
|
||||
case 'validate': return this.readinessReport()?.isReady === true;
|
||||
case 'validate': return this.readinessReport()?.isReady === true || this.validateSkipped();
|
||||
default: return false;
|
||||
}
|
||||
});
|
||||
@@ -144,6 +145,7 @@ export class TopologyWizardService {
|
||||
this.createdTarget.set(null);
|
||||
this.selectedAgent.set(null);
|
||||
this.agentSkipped.set(false);
|
||||
this.validateSkipped.set(false);
|
||||
this.resolvedBindings.set(null);
|
||||
this.readinessReport.set(null);
|
||||
this.error.set(null);
|
||||
|
||||
@@ -96,6 +96,10 @@ interface PlatformListResponse<T> {
|
||||
Generate PDF
|
||||
</button>
|
||||
</div>
|
||||
<p class="vex-export-note">
|
||||
VEX decision exports are available on the
|
||||
<a class="vex-export-note__link" (click)="activeTab.set('vex')">VEX Ledger tab</a>.
|
||||
</p>
|
||||
<app-security-risk-overview></app-security-risk-overview>
|
||||
</section>
|
||||
}
|
||||
@@ -241,8 +245,27 @@ interface PlatformListResponse<T> {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.vex-export-note {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.vex-export-note__link {
|
||||
color: var(--color-brand-primary);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vex-export-note__link:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.tabs, .export-toolbar, .evidence-explainer, .export-center-link {
|
||||
.tabs, .export-toolbar, .evidence-explainer, .export-center-link, .vex-export-note {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,15 @@ import { RouterLink } from '@angular/router';
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="trust-overview__empty-state">
|
||||
<div class="trust-overview__empty-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
</div>
|
||||
<p class="trust-overview__empty-title">No signing keys configured</p>
|
||||
<p class="trust-overview__empty-desc">Generate a signing key to enable attestation signing on releases.</p>
|
||||
<a routerLink="keys" class="trust-overview__empty-action">Go to Signing Keys</a>
|
||||
</div>
|
||||
|
||||
<div class="trust-overview__grid">
|
||||
<article class="trust-overview__card">
|
||||
<h3>Signing Keys</h3>
|
||||
@@ -98,6 +107,53 @@ import { RouterLink } from '@angular/router';
|
||||
text-decoration: none;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.trust-overview__empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 2rem 1.5rem;
|
||||
border: 1px dashed var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.trust-overview__empty-icon {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.trust-overview__empty-title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.trust-overview__empty-desc {
|
||||
margin: 0 0 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.trust-overview__empty-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-brand-primary);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.85rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.trust-overview__empty-action:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class TrustOverviewComponent {}
|
||||
|
||||
Reference in New Issue
Block a user