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,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 |

View File

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

View 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`

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

View File

@@ -118,6 +118,7 @@ if (app.Environment.IsDevelopment())
app.UseStellaOpsCors();
app.UseStellaOpsLocalization();
app.UseIdentityEnvelopeAuthentication();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();

View File

@@ -149,6 +149,7 @@ if (app.Environment.IsDevelopment())
app.UseStellaOpsCors();
app.UseStellaOpsLocalization();
app.UseIdentityEnvelopeAuthentication();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();

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

View File

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

View File

@@ -105,6 +105,7 @@ if (app.Environment.IsDevelopment())
app.UseStellaOpsCors();
app.UseStellaOpsLocalization();
app.UseIdentityEnvelopeAuthentication();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {}