up tests and theme

This commit is contained in:
master
2026-02-02 08:57:29 +02:00
parent a53edd1e48
commit 817ffc7251
200 changed files with 30378 additions and 30287 deletions

View File

@@ -33,6 +33,7 @@ public sealed class EvidenceLockerIntegrationTests : IDisposable
public EvidenceLockerIntegrationTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = factory;
_factory.ResetTestState();
_client = _factory.CreateClient();
}

View File

@@ -50,6 +50,18 @@ public sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<
public TestTimelinePublisher TimelinePublisher => Services.GetRequiredService<TestTimelinePublisher>();
/// <summary>
/// Resets all singleton test doubles to prevent accumulated state from
/// leaking memory across test classes sharing this factory instance.
/// Call from each test class constructor.
/// </summary>
public void ResetTestState()
{
Repository.Reset();
ObjectStore.Reset();
TimelinePublisher.Reset();
}
private static SigningKeyMaterialOptions GenerateKeyMaterial()
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
@@ -164,6 +176,12 @@ public sealed class TestTimelinePublisher : IEvidenceTimelinePublisher
public List<string> PublishedEvents { get; } = new();
public List<string> IncidentEvents { get; } = new();
public void Reset()
{
PublishedEvents.Clear();
IncidentEvents.Clear();
}
public Task PublishBundleSealedAsync(
EvidenceBundleSignature signature,
EvidenceBundleManifest manifest,
@@ -196,6 +214,12 @@ public sealed class TestEvidenceObjectStore : IEvidenceObjectStore
public void SeedExisting(string storageKey) => _preExisting.Add(storageKey);
public void Reset()
{
_objects.Clear();
_preExisting.Clear();
}
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
{
using var memory = new MemoryStream();
@@ -235,6 +259,13 @@ public sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
public bool HoldConflict { get; set; }
public void Reset()
{
_signatures.Clear();
_bundles.Clear();
HoldConflict = false;
}
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
{
_bundles[(bundle.Id.Value, bundle.TenantId.Value)] = bundle;

View File

@@ -38,6 +38,7 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
public EvidenceLockerWebServiceContractTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = factory;
_factory.ResetTestState();
_client = factory.CreateClient();
}
@@ -322,7 +323,7 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceCreate);
var listener = new ActivityListener
using var listener = new ActivityListener
{
ShouldListenTo = source => source.Name.Contains("StellaOps", StringComparison.OrdinalIgnoreCase),
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
@@ -359,8 +360,6 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
.FirstOrDefault(e => e.Contains(bundleId!, StringComparison.Ordinal));
timelineEvent.Should().NotBeNull($"expected a timeline event containing bundleId {bundleId}");
timelineEvent.Should().Contain(bundleId!);
listener.Dispose();
}
[Trait("Category", TestCategories.Integration)]

View File

@@ -34,6 +34,7 @@ public sealed class EvidenceLockerWebServiceTests : IDisposable
public EvidenceLockerWebServiceTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = factory;
_factory.ResetTestState();
_client = factory.CreateClient();
}

View File

@@ -37,6 +37,7 @@ public sealed class EvidenceReindexIntegrationTests : IDisposable
public EvidenceReindexIntegrationTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = factory;
_factory.ResetTestState();
_client = _factory.CreateClient();
}

View File

@@ -20,19 +20,18 @@ namespace StellaOps.EvidenceLocker.Tests;
/// <summary>
/// Integration tests for export API endpoints.
/// Uses the shared EvidenceLockerWebApplicationFactory (via Collection fixture)
/// instead of raw WebApplicationFactory&lt;Program&gt; to avoid loading real
/// infrastructure services (database, auth, background services) which causes
/// the test process to hang and consume excessive memory.
/// Uses a single derived WebApplicationFactory for the entire class (via IClassFixture)
/// to avoid creating a new TestServer per test, which previously leaked memory.
/// </summary>
[Collection(EvidenceLockerTestCollection.Name)]
public sealed class ExportEndpointsTests
public sealed class ExportEndpointsTests : IClassFixture<ExportEndpointsTests.ExportTestFixture>, IDisposable
{
private readonly EvidenceLockerWebApplicationFactory _factory;
private readonly ExportTestFixture _fixture;
private readonly HttpClient _client;
public ExportEndpointsTests(EvidenceLockerWebApplicationFactory factory)
public ExportEndpointsTests(ExportTestFixture fixture)
{
_factory = factory;
_fixture = fixture;
_client = fixture.DerivedFactory.CreateClient();
}
[Fact]
@@ -52,11 +51,11 @@ public sealed class ExportEndpointsTests
EstimatedSize = 1024
});
using var scope = CreateClientWithMock(mockService.Object);
_fixture.CurrentMock = mockService.Object;
var request = new ExportTriggerRequest();
// Act
var response = await scope.Client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
var response = await _client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
// Assert
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
@@ -79,11 +78,11 @@ public sealed class ExportEndpointsTests
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportJobResult { IsNotFound = true });
using var scope = CreateClientWithMock(mockService.Object);
_fixture.CurrentMock = mockService.Object;
var request = new ExportTriggerRequest();
// Act
var response = await scope.Client.PostAsJsonAsync("/api/v1/bundles/nonexistent/export", request);
var response = await _client.PostAsJsonAsync("/api/v1/bundles/nonexistent/export", request);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
@@ -104,10 +103,10 @@ public sealed class ExportEndpointsTests
CompletedAt = DateTimeOffset.Parse("2026-01-07T12:00:00Z")
});
using var scope = CreateClientWithMock(mockService.Object);
_fixture.CurrentMock = mockService.Object;
// Act
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -133,10 +132,10 @@ public sealed class ExportEndpointsTests
EstimatedTimeRemaining = "30s"
});
using var scope = CreateClientWithMock(mockService.Object);
_fixture.CurrentMock = mockService.Object;
// Act
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
// Assert
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
@@ -156,10 +155,10 @@ public sealed class ExportEndpointsTests
.Setup(s => s.GetExportStatusAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
.ReturnsAsync((ExportJobStatus?)null);
using var scope = CreateClientWithMock(mockService.Object);
_fixture.CurrentMock = mockService.Object;
// Act
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent");
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
@@ -181,10 +180,10 @@ public sealed class ExportEndpointsTests
FileName = "evidence-bundle-123.tar.gz"
});
using var scope = CreateClientWithMock(mockService.Object);
_fixture.CurrentMock = mockService.Object;
// Act
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -203,10 +202,10 @@ public sealed class ExportEndpointsTests
Status = ExportJobStatusEnum.Processing
});
using var scope = CreateClientWithMock(mockService.Object);
_fixture.CurrentMock = mockService.Object;
// Act
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
// Assert
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
@@ -221,10 +220,10 @@ public sealed class ExportEndpointsTests
.Setup(s => s.GetExportFileAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
.ReturnsAsync((ExportFileResult?)null);
using var scope = CreateClientWithMock(mockService.Object);
_fixture.CurrentMock = mockService.Object;
// Act
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent/download");
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent/download");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
@@ -248,7 +247,7 @@ public sealed class ExportEndpointsTests
Status = "pending"
});
using var scope = CreateClientWithMock(mockService.Object);
_fixture.CurrentMock = mockService.Object;
var request = new ExportTriggerRequest
{
CompressionLevel = 9,
@@ -257,7 +256,7 @@ public sealed class ExportEndpointsTests
};
// Act
await scope.Client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
await _client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
// Assert
Assert.NotNull(capturedRequest);
@@ -266,52 +265,76 @@ public sealed class ExportEndpointsTests
Assert.False(capturedRequest.IncludeRekorProofs);
}
/// <summary>
/// Wraps a derived WebApplicationFactory and HttpClient so both are disposed together.
/// Previously, WithWebHostBuilder() created a new factory per test that was never disposed,
/// leaking TestServer instances and consuming gigabytes of memory.
/// </summary>
/// <summary>
/// Wraps a derived WebApplicationFactory and HttpClient so both are disposed together.
/// Previously, WithWebHostBuilder() created a new factory per test that was never disposed,
/// leaking TestServer instances and consuming gigabytes of memory.
/// </summary>
private sealed class MockScope : IDisposable
public void Dispose()
{
private readonly WebApplicationFactory<EvidenceLockerProgram> _derivedFactory;
public HttpClient Client { get; }
_client.Dispose();
}
public MockScope(WebApplicationFactory<EvidenceLockerProgram> derivedFactory)
/// <summary>
/// Fixture that creates a single derived WebApplicationFactory with a swappable
/// IExportJobService mock. Tests set <see cref="CurrentMock"/> before each request
/// instead of creating a new factory per test. This eliminates 9 TestServer instances
/// that were previously leaking memory.
/// </summary>
public sealed class ExportTestFixture : IDisposable
{
/// <summary>
/// The current mock to delegate to. Set by each test before making requests.
/// </summary>
public IExportJobService CurrentMock { get; set; } = new Mock<IExportJobService>().Object;
public WebApplicationFactory<EvidenceLockerProgram> DerivedFactory { get; }
public ExportTestFixture()
{
_derivedFactory = derivedFactory;
Client = derivedFactory.CreateClient();
// Create ONE derived factory whose IExportJobService delegates to CurrentMock.
// This avoids creating a new TestServer per test.
var baseFactory = new EvidenceLockerWebApplicationFactory();
DerivedFactory = baseFactory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IExportJobService));
if (descriptor != null)
{
services.Remove(descriptor);
}
// Register a delegating wrapper that forwards to CurrentMock,
// allowing each test to swap the mock without a new factory.
services.AddSingleton<IExportJobService>(sp =>
new DelegatingExportJobService(this));
});
});
}
public void Dispose()
{
Client.Dispose();
_derivedFactory.Dispose();
DerivedFactory.Dispose();
}
}
private MockScope CreateClientWithMock(IExportJobService mockService)
/// <summary>
/// Thin delegate that forwards all calls to the fixture's current mock,
/// allowing per-test mock swapping without creating new WebApplicationFactory instances.
/// </summary>
private sealed class DelegatingExportJobService : IExportJobService
{
var derivedFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Remove existing registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IExportJobService));
if (descriptor != null)
{
services.Remove(descriptor);
}
private readonly ExportTestFixture _fixture;
// Add mock
services.AddSingleton(mockService);
});
});
return new MockScope(derivedFactory);
public DelegatingExportJobService(ExportTestFixture fixture)
{
_fixture = fixture;
}
public Task<ExportJobResult> CreateExportJobAsync(string bundleId, ExportTriggerRequest request, CancellationToken cancellationToken)
=> _fixture.CurrentMock.CreateExportJobAsync(bundleId, request, cancellationToken);
public Task<ExportJobStatus?> GetExportStatusAsync(string bundleId, string exportId, CancellationToken cancellationToken)
=> _fixture.CurrentMock.GetExportStatusAsync(bundleId, exportId, cancellationToken);
public Task<ExportFileResult?> GetExportFileAsync(string bundleId, string exportId, CancellationToken cancellationToken)
=> _fixture.CurrentMock.GetExportFileAsync(bundleId, exportId, cancellationToken);
}
}

View File

@@ -56,11 +56,16 @@ public sealed class PostgreSqlFixture : IAsyncLifetime
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// On Windows, try to open the Docker named pipe with a short timeout.
// File.Exists does not work for named pipes.
using var pipe = new System.IO.Pipes.NamedPipeClientStream(".", "docker_engine", System.IO.Pipes.PipeDirection.InOut, System.IO.Pipes.PipeOptions.None);
pipe.Connect(2000); // 2 second timeout
return true;
// Check if the Docker daemon is actually running by looking for its process.
// NamedPipeClientStream.Connect() hangs indefinitely when Docker Desktop is
// installed but not running (the pipe exists but nobody reads from it).
// Testcontainers' own Docker client also hangs in this scenario.
// Checking for a running process is instant and avoids the hang entirely.
var dockerProcesses = System.Diagnostics.Process.GetProcessesByName("com.docker.backend");
if (dockerProcesses.Length == 0)
dockerProcesses = System.Diagnostics.Process.GetProcessesByName("dockerd");
foreach (var p in dockerProcesses) p.Dispose();
return dockerProcesses.Length > 0;
}
// On Linux/macOS, check for the Docker socket

View File

@@ -1,174 +1,175 @@
/**
* App Component Styles
* Migrated to design system tokens
*/
:host {
display: block;
min-height: 100vh;
font-family: var(--font-family-base);
color: var(--color-text-primary);
background-color: var(--color-surface-secondary);
}
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.quickstart-banner {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
padding: var(--space-3) var(--space-6);
font-size: var(--font-size-sm);
border-bottom: 1px solid var(--color-status-warning-border);
a {
color: inherit;
font-weight: var(--font-weight-semibold);
text-decoration: underline;
}
}
.app-header {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-6);
background: var(--color-header-bg);
color: var(--color-header-text);
box-shadow: var(--shadow-md);
// Navigation takes remaining space
app-navigation-menu {
flex: 1;
display: flex;
justify-content: flex-start;
margin-left: var(--space-4);
}
}
.app-brand {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
letter-spacing: 0.02em;
color: inherit;
text-decoration: none;
flex-shrink: 0;
&:hover {
opacity: 0.9;
}
}
.app-auth {
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
.app-tenant {
font-size: var(--font-size-xs);
padding: var(--space-1) var(--space-2);
background-color: rgba(255, 255, 255, 0.1);
border-radius: var(--radius-xs);
color: var(--color-header-text-muted);
@media (max-width: 768px) {
display: none;
}
}
.app-fresh {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-0-5) var(--space-2-5);
border-radius: var(--radius-full);
font-size: 0.7rem;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.03em;
background-color: var(--color-fresh-active-bg);
color: var(--color-fresh-active-text);
@media (max-width: 768px) {
display: none;
}
&.app-fresh--stale {
background-color: var(--color-fresh-stale-bg);
color: var(--color-fresh-stale-text);
}
}
&__signin {
appearance: none;
border: none;
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-4);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
color: var(--color-surface-inverse);
background-color: rgba(248, 250, 252, 0.9);
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
background-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover,
&:focus-visible {
background-color: var(--color-accent-yellow);
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
}
}
.app-content {
flex: 1;
padding: var(--space-6) var(--space-6) var(--space-8);
max-width: 1200px;
width: 100%;
margin: 0 auto;
// Breadcrumb styling
app-breadcrumb {
display: block;
margin-bottom: var(--space-3);
}
}
// Page container with transition animations
.page-container {
animation: page-fade-in var(--motion-duration-slow) var(--motion-ease-spring);
}
@keyframes page-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Respect reduced motion preference
@media (prefers-reduced-motion: reduce) {
.page-container {
animation: none;
}
.app-auth__signin {
transition: none;
&:hover {
transform: none;
}
}
}
/**
* App Component Styles
* Migrated to design system tokens
*/
:host {
display: block;
min-height: 100vh;
font-family: var(--font-family-base);
color: var(--color-text-primary);
background-color: var(--color-surface-secondary);
}
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.quickstart-banner {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
padding: var(--space-3) var(--space-6);
font-size: var(--font-size-sm);
border-bottom: 1px solid var(--color-status-warning-border);
a {
color: inherit;
font-weight: var(--font-weight-semibold);
text-decoration: underline;
}
}
.app-header {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-6);
background: var(--color-header-bg);
color: var(--color-header-text);
box-shadow: var(--shadow-md);
backdrop-filter: blur(16px) saturate(1.2);
// Navigation takes remaining space
app-navigation-menu {
flex: 1;
display: flex;
justify-content: flex-start;
margin-left: var(--space-4);
}
}
.app-brand {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
letter-spacing: 0.02em;
color: inherit;
text-decoration: none;
flex-shrink: 0;
&:hover {
opacity: 0.9;
}
}
.app-auth {
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
.app-tenant {
font-size: var(--font-size-xs);
padding: var(--space-1) var(--space-2);
background-color: rgba(255, 255, 255, 0.1);
border-radius: var(--radius-xs);
color: var(--color-header-text-muted);
@media (max-width: 768px) {
display: none;
}
}
.app-fresh {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-0-5) var(--space-2-5);
border-radius: var(--radius-full);
font-size: 0.7rem;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.03em;
background-color: var(--color-fresh-active-bg);
color: var(--color-fresh-active-text);
@media (max-width: 768px) {
display: none;
}
&.app-fresh--stale {
background-color: var(--color-fresh-stale-bg);
color: var(--color-fresh-stale-text);
}
}
&__signin {
appearance: none;
border: none;
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-4);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
color: var(--color-surface-inverse);
background-color: rgba(248, 250, 252, 0.9);
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
background-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover,
&:focus-visible {
background-color: var(--color-accent-yellow);
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
}
}
.app-content {
flex: 1;
padding: var(--space-6) var(--space-6) var(--space-8);
max-width: 1200px;
width: 100%;
margin: 0 auto;
// Breadcrumb styling
app-breadcrumb {
display: block;
margin-bottom: var(--space-3);
}
}
// Page container with transition animations
.page-container {
animation: page-fade-in var(--motion-duration-slow) var(--motion-ease-spring);
}
@keyframes page-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Respect reduced motion preference
@media (prefers-reduced-motion: reduce) {
.page-container {
animation: none;
}
.app-auth__signin {
transition: none;
&:hover {
transform: none;
}
}
}

View File

@@ -1,54 +1,54 @@
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { of } from 'rxjs';
import { AppComponent } from './app.component';
import { AuthorityAuthService } from './core/auth/authority-auth.service';
import { AuthSessionStore } from './core/auth/auth-session.store';
import { AUTH_SERVICE, AuthService } from './core/auth';
import { ConsoleSessionStore } from './core/console/console-session.store';
import { AppConfigService } from './core/config/app-config.service';
import { PolicyPackStore } from './features/policy-studio/services/policy-pack.store';
class AuthorityAuthServiceStub {
beginLogin = jasmine.createSpy('beginLogin');
logout = jasmine.createSpy('logout');
}
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent, RouterTestingModule, HttpClientTestingModule],
providers: [
AuthSessionStore,
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
ConsoleSessionStore,
{ provide: AppConfigService, useValue: { config: { quickstartMode: false, apiBaseUrls: { authority: '', policy: '' } } } },
{
provide: PolicyPackStore,
useValue: {
getPacks: () =>
of([
{ id: 'pack-1', name: 'Pack One', description: '', version: '1.0', status: 'active', createdAt: '', modifiedAt: '', createdBy: '', modifiedBy: '', tags: [] },
]),
},
},
],
}).compileComponents();
});
it('creates the root component', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('renders a router outlet for child routes', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('router-outlet')).not.toBeNull();
});
});
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { of } from 'rxjs';
import { AppComponent } from './app.component';
import { AuthorityAuthService } from './core/auth/authority-auth.service';
import { AuthSessionStore } from './core/auth/auth-session.store';
import { AUTH_SERVICE, AuthService } from './core/auth';
import { ConsoleSessionStore } from './core/console/console-session.store';
import { AppConfigService } from './core/config/app-config.service';
import { PolicyPackStore } from './features/policy-studio/services/policy-pack.store';
class AuthorityAuthServiceStub {
beginLogin = jasmine.createSpy('beginLogin');
logout = jasmine.createSpy('logout');
}
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent, RouterTestingModule, HttpClientTestingModule],
providers: [
AuthSessionStore,
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
ConsoleSessionStore,
{ provide: AppConfigService, useValue: { config: { quickstartMode: false, apiBaseUrls: { authority: '', policy: '' } } } },
{
provide: PolicyPackStore,
useValue: {
getPacks: () =>
of([
{ id: 'pack-1', name: 'Pack One', description: '', version: '1.0', status: 'active', createdAt: '', modifiedAt: '', createdBy: '', modifiedBy: '', tags: [] },
]),
},
},
],
}).compileComponents();
});
it('creates the root component', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('renders a router outlet for child routes', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('router-outlet')).not.toBeNull();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,382 +1,382 @@
/**
* AOC (Authorization of Containers) models for dashboard metrics.
*/
export interface AocMetrics {
/** Pass/fail counts for the time window */
passCount: number;
failCount: number;
totalCount: number;
passRate: number;
/** Recent violations grouped by code */
recentViolations: AocViolationSummary[];
/** Ingest throughput metrics */
ingestThroughput: AocIngestThroughput;
/** Time window for these metrics */
timeWindow: {
start: string;
end: string;
durationMinutes: number;
};
}
export interface AocViolationSummary {
code: string;
description: string;
count: number;
severity: 'critical' | 'high' | 'medium' | 'low';
lastSeen: string;
}
export interface AocIngestThroughput {
/** Documents processed per minute */
docsPerMinute: number;
/** Average processing latency in milliseconds */
avgLatencyMs: number;
/** P95 latency in milliseconds */
p95LatencyMs: number;
/** Current queue depth */
queueDepth: number;
/** Error rate percentage */
errorRate: number;
}
export interface AocVerificationRequest {
tenantId: string;
since?: string;
limit?: number;
}
export interface AocVerificationResult {
verificationId: string;
status: 'passed' | 'failed' | 'partial';
checkedCount: number;
passedCount: number;
failedCount: number;
violations: AocViolationDetail[];
completedAt: string;
}
export interface AocViolationDetail {
documentId: string;
violationCode: string;
field?: string;
expected?: string;
actual?: string;
provenance?: AocProvenance;
}
export interface AocProvenance {
sourceId: string;
ingestedAt: string;
digest: string;
sourceType?: 'registry' | 'git' | 'upload' | 'api';
sourceUrl?: string;
submitter?: string;
}
export interface AocViolationGroup {
code: string;
description: string;
severity: 'critical' | 'high' | 'medium' | 'low';
violations: AocViolationDetail[];
affectedDocuments: number;
remediation?: string;
}
export interface AocDocumentView {
documentId: string;
documentType: string;
violations: AocViolationDetail[];
provenance: AocProvenance;
rawContent?: Record<string, unknown>;
highlightedFields: string[];
}
/**
* Violation severity levels.
*/
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low';
/**
* AOC source configuration.
*/
export interface AocSource {
id: string;
sourceId: string;
name: string;
type: 'registry' | 'git' | 'upload' | 'api';
url?: string;
enabled: boolean;
lastSync?: string;
status: 'healthy' | 'degraded' | 'offline';
}
/**
* Violation code definition.
*/
export interface AocViolationCode {
code: string;
description: string;
severity: ViolationSeverity;
category: string;
remediation?: string;
}
/**
* Dashboard summary data.
*/
export interface AocDashboardSummary {
/** Pass/fail metrics */
passFail: {
passCount: number;
failCount: number;
totalCount: number;
passRate: number;
trend?: 'improving' | 'degrading' | 'stable';
history?: { timestamp: string; value: number }[];
};
/** Recent violations */
recentViolations: AocViolationSummary[];
/** Ingest throughput */
throughput: AocIngestThroughput;
/** Throughput by tenant */
throughputByTenant: TenantThroughput[];
/** Configured sources */
sources: AocSource[];
/** Time window */
timeWindow: {
start: string;
end: string;
};
}
/**
* Tenant-level throughput metrics.
*/
export interface TenantThroughput {
tenantId: string;
tenantName?: string;
documentsIngested: number;
bytesIngested: number;
}
/**
* Field that caused a violation.
*/
export interface OffendingField {
path: string;
expected?: string;
actual?: string;
expectedValue?: string;
actualValue?: string;
reason: string;
suggestion?: string;
}
/**
* Detailed violation record for display.
*/
export interface ViolationDetail {
violationId: string;
documentType: string;
documentId: string;
severity: ViolationSeverity;
detectedAt: string;
offendingFields: OffendingField[];
provenance: ViolationProvenance;
suggestion?: string;
}
/**
* Provenance metadata for a violation.
*/
export interface ViolationProvenance {
sourceType: string;
sourceUri: string;
ingestedAt: string;
ingestedBy: string;
buildId?: string;
commitSha?: string;
pipelineUrl?: string;
}
// Type aliases for backwards compatibility
export type IngestThroughput = AocIngestThroughput;
export type VerificationRequest = AocVerificationRequest;
// =============================================================================
// Sprint 027: AOC Compliance Dashboard Extensions
// =============================================================================
// Guard violation types for AOC ingestion
export type GuardViolationReason =
| 'schema_invalid'
| 'untrusted_source'
| 'duplicate'
| 'malformed_timestamp'
| 'missing_required_fields'
| 'hash_mismatch'
| 'unknown';
export interface GuardViolation {
id: string;
timestamp: string;
source: string;
reason: GuardViolationReason;
message: string;
payloadSample?: string;
module: 'concelier' | 'excititor';
canRetry: boolean;
}
// Ingestion flow metrics
export interface IngestionSourceMetrics {
sourceId: string;
sourceName: string;
module: 'concelier' | 'excititor';
throughputPerMinute: number;
latencyP50Ms: number;
latencyP95Ms: number;
latencyP99Ms: number;
errorRate: number;
backlogDepth: number;
lastIngestionAt: string;
status: 'healthy' | 'degraded' | 'unhealthy';
}
export interface IngestionFlowSummary {
sources: IngestionSourceMetrics[];
totalThroughput: number;
avgLatencyP95Ms: number;
overallErrorRate: number;
lastUpdatedAt: string;
}
// Provenance chain types
export type ProvenanceStepType =
| 'source'
| 'advisory_raw'
| 'normalized'
| 'vex_decision'
| 'finding'
| 'policy_verdict'
| 'attestation';
export interface ProvenanceStep {
stepType: ProvenanceStepType;
label: string;
timestamp: string;
hash?: string;
linkedFromHash?: string;
status: 'valid' | 'warning' | 'error' | 'pending';
details: Record<string, unknown>;
errorMessage?: string;
}
export interface ProvenanceChain {
inputType: 'advisory_id' | 'finding_id' | 'cve_id';
inputValue: string;
steps: ProvenanceStep[];
isComplete: boolean;
validationErrors: string[];
validatedAt: string;
}
// AOC compliance metrics
export interface AocComplianceMetrics {
guardViolations: {
count: number;
percentage: number;
byReason: Record<string, number>;
trend: 'up' | 'down' | 'stable';
};
provenanceCompleteness: {
percentage: number;
recordsWithValidHash: number;
totalRecords: number;
trend: 'up' | 'down' | 'stable';
};
deduplicationRate: {
percentage: number;
duplicatesDetected: number;
totalIngested: number;
trend: 'up' | 'down' | 'stable';
};
ingestionLatency: {
p50Ms: number;
p95Ms: number;
p99Ms: number;
meetsSla: boolean;
slaTargetP95Ms: number;
};
supersedesDepth: {
maxDepth: number;
avgDepth: number;
distribution: { depth: number; count: number }[];
};
periodStart: string;
periodEnd: string;
}
// Compliance report
export type ComplianceReportFormat = 'csv' | 'json';
export interface ComplianceReportRequest {
startDate: string;
endDate: string;
sources?: string[];
format: ComplianceReportFormat;
includeViolationDetails: boolean;
}
export interface ComplianceReportSummary {
reportId: string;
generatedAt: string;
period: { start: string; end: string };
guardViolationSummary: {
total: number;
bySource: Record<string, number>;
byReason: Record<string, number>;
};
provenanceCompliance: {
percentage: number;
bySource: Record<string, number>;
};
deduplicationMetrics: {
rate: number;
bySource: Record<string, number>;
};
latencyMetrics: {
p50Ms: number;
p95Ms: number;
p99Ms: number;
bySource: Record<string, { p50: number; p95: number; p99: number }>;
};
}
// API response wrappers
export interface AocComplianceDashboardData {
metrics: AocComplianceMetrics;
recentViolations: GuardViolation[];
ingestionFlow: IngestionFlowSummary;
}
export interface GuardViolationsPagedResponse {
items: GuardViolation[];
totalCount: number;
page: number;
pageSize: number;
hasMore: boolean;
}
// Filter options
export interface AocDashboardFilters {
dateRange: { start: string; end: string };
sources?: string[];
modules?: ('concelier' | 'excititor')[];
violationReasons?: GuardViolationReason[];
}
/**
* AOC (Authorization of Containers) models for dashboard metrics.
*/
export interface AocMetrics {
/** Pass/fail counts for the time window */
passCount: number;
failCount: number;
totalCount: number;
passRate: number;
/** Recent violations grouped by code */
recentViolations: AocViolationSummary[];
/** Ingest throughput metrics */
ingestThroughput: AocIngestThroughput;
/** Time window for these metrics */
timeWindow: {
start: string;
end: string;
durationMinutes: number;
};
}
export interface AocViolationSummary {
code: string;
description: string;
count: number;
severity: 'critical' | 'high' | 'medium' | 'low';
lastSeen: string;
}
export interface AocIngestThroughput {
/** Documents processed per minute */
docsPerMinute: number;
/** Average processing latency in milliseconds */
avgLatencyMs: number;
/** P95 latency in milliseconds */
p95LatencyMs: number;
/** Current queue depth */
queueDepth: number;
/** Error rate percentage */
errorRate: number;
}
export interface AocVerificationRequest {
tenantId: string;
since?: string;
limit?: number;
}
export interface AocVerificationResult {
verificationId: string;
status: 'passed' | 'failed' | 'partial';
checkedCount: number;
passedCount: number;
failedCount: number;
violations: AocViolationDetail[];
completedAt: string;
}
export interface AocViolationDetail {
documentId: string;
violationCode: string;
field?: string;
expected?: string;
actual?: string;
provenance?: AocProvenance;
}
export interface AocProvenance {
sourceId: string;
ingestedAt: string;
digest: string;
sourceType?: 'registry' | 'git' | 'upload' | 'api';
sourceUrl?: string;
submitter?: string;
}
export interface AocViolationGroup {
code: string;
description: string;
severity: 'critical' | 'high' | 'medium' | 'low';
violations: AocViolationDetail[];
affectedDocuments: number;
remediation?: string;
}
export interface AocDocumentView {
documentId: string;
documentType: string;
violations: AocViolationDetail[];
provenance: AocProvenance;
rawContent?: Record<string, unknown>;
highlightedFields: string[];
}
/**
* Violation severity levels.
*/
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low';
/**
* AOC source configuration.
*/
export interface AocSource {
id: string;
sourceId: string;
name: string;
type: 'registry' | 'git' | 'upload' | 'api';
url?: string;
enabled: boolean;
lastSync?: string;
status: 'healthy' | 'degraded' | 'offline';
}
/**
* Violation code definition.
*/
export interface AocViolationCode {
code: string;
description: string;
severity: ViolationSeverity;
category: string;
remediation?: string;
}
/**
* Dashboard summary data.
*/
export interface AocDashboardSummary {
/** Pass/fail metrics */
passFail: {
passCount: number;
failCount: number;
totalCount: number;
passRate: number;
trend?: 'improving' | 'degrading' | 'stable';
history?: { timestamp: string; value: number }[];
};
/** Recent violations */
recentViolations: AocViolationSummary[];
/** Ingest throughput */
throughput: AocIngestThroughput;
/** Throughput by tenant */
throughputByTenant: TenantThroughput[];
/** Configured sources */
sources: AocSource[];
/** Time window */
timeWindow: {
start: string;
end: string;
};
}
/**
* Tenant-level throughput metrics.
*/
export interface TenantThroughput {
tenantId: string;
tenantName?: string;
documentsIngested: number;
bytesIngested: number;
}
/**
* Field that caused a violation.
*/
export interface OffendingField {
path: string;
expected?: string;
actual?: string;
expectedValue?: string;
actualValue?: string;
reason: string;
suggestion?: string;
}
/**
* Detailed violation record for display.
*/
export interface ViolationDetail {
violationId: string;
documentType: string;
documentId: string;
severity: ViolationSeverity;
detectedAt: string;
offendingFields: OffendingField[];
provenance: ViolationProvenance;
suggestion?: string;
}
/**
* Provenance metadata for a violation.
*/
export interface ViolationProvenance {
sourceType: string;
sourceUri: string;
ingestedAt: string;
ingestedBy: string;
buildId?: string;
commitSha?: string;
pipelineUrl?: string;
}
// Type aliases for backwards compatibility
export type IngestThroughput = AocIngestThroughput;
export type VerificationRequest = AocVerificationRequest;
// =============================================================================
// Sprint 027: AOC Compliance Dashboard Extensions
// =============================================================================
// Guard violation types for AOC ingestion
export type GuardViolationReason =
| 'schema_invalid'
| 'untrusted_source'
| 'duplicate'
| 'malformed_timestamp'
| 'missing_required_fields'
| 'hash_mismatch'
| 'unknown';
export interface GuardViolation {
id: string;
timestamp: string;
source: string;
reason: GuardViolationReason;
message: string;
payloadSample?: string;
module: 'concelier' | 'excititor';
canRetry: boolean;
}
// Ingestion flow metrics
export interface IngestionSourceMetrics {
sourceId: string;
sourceName: string;
module: 'concelier' | 'excititor';
throughputPerMinute: number;
latencyP50Ms: number;
latencyP95Ms: number;
latencyP99Ms: number;
errorRate: number;
backlogDepth: number;
lastIngestionAt: string;
status: 'healthy' | 'degraded' | 'unhealthy';
}
export interface IngestionFlowSummary {
sources: IngestionSourceMetrics[];
totalThroughput: number;
avgLatencyP95Ms: number;
overallErrorRate: number;
lastUpdatedAt: string;
}
// Provenance chain types
export type ProvenanceStepType =
| 'source'
| 'advisory_raw'
| 'normalized'
| 'vex_decision'
| 'finding'
| 'policy_verdict'
| 'attestation';
export interface ProvenanceStep {
stepType: ProvenanceStepType;
label: string;
timestamp: string;
hash?: string;
linkedFromHash?: string;
status: 'valid' | 'warning' | 'error' | 'pending';
details: Record<string, unknown>;
errorMessage?: string;
}
export interface ProvenanceChain {
inputType: 'advisory_id' | 'finding_id' | 'cve_id';
inputValue: string;
steps: ProvenanceStep[];
isComplete: boolean;
validationErrors: string[];
validatedAt: string;
}
// AOC compliance metrics
export interface AocComplianceMetrics {
guardViolations: {
count: number;
percentage: number;
byReason: Record<string, number>;
trend: 'up' | 'down' | 'stable';
};
provenanceCompleteness: {
percentage: number;
recordsWithValidHash: number;
totalRecords: number;
trend: 'up' | 'down' | 'stable';
};
deduplicationRate: {
percentage: number;
duplicatesDetected: number;
totalIngested: number;
trend: 'up' | 'down' | 'stable';
};
ingestionLatency: {
p50Ms: number;
p95Ms: number;
p99Ms: number;
meetsSla: boolean;
slaTargetP95Ms: number;
};
supersedesDepth: {
maxDepth: number;
avgDepth: number;
distribution: { depth: number; count: number }[];
};
periodStart: string;
periodEnd: string;
}
// Compliance report
export type ComplianceReportFormat = 'csv' | 'json';
export interface ComplianceReportRequest {
startDate: string;
endDate: string;
sources?: string[];
format: ComplianceReportFormat;
includeViolationDetails: boolean;
}
export interface ComplianceReportSummary {
reportId: string;
generatedAt: string;
period: { start: string; end: string };
guardViolationSummary: {
total: number;
bySource: Record<string, number>;
byReason: Record<string, number>;
};
provenanceCompliance: {
percentage: number;
bySource: Record<string, number>;
};
deduplicationMetrics: {
rate: number;
bySource: Record<string, number>;
};
latencyMetrics: {
p50Ms: number;
p95Ms: number;
p99Ms: number;
bySource: Record<string, { p50: number; p95: number; p99: number }>;
};
}
// API response wrappers
export interface AocComplianceDashboardData {
metrics: AocComplianceMetrics;
recentViolations: GuardViolation[];
ingestionFlow: IngestionFlowSummary;
}
export interface GuardViolationsPagedResponse {
items: GuardViolation[];
totalCount: number;
page: number;
pageSize: number;
hasMore: boolean;
}
// Filter options
export interface AocDashboardFilters {
dateRange: { start: string; end: string };
sources?: string[];
modules?: ('concelier' | 'excititor')[];
violationReasons?: GuardViolationReason[];
}

View File

@@ -1,113 +1,113 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
export interface AuthorityTenantViewDto {
readonly id: string;
readonly displayName: string;
readonly status: string;
readonly isolationMode: string;
readonly defaultRoles: readonly string[];
}
export interface TenantCatalogResponseDto {
readonly tenants: readonly AuthorityTenantViewDto[];
}
export interface ConsoleProfileDto {
readonly subjectId: string | null;
readonly username: string | null;
readonly displayName: string | null;
readonly tenant: string;
readonly sessionId: string | null;
readonly roles: readonly string[];
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly authenticationMethods: readonly string[];
readonly issuedAt: string | null;
readonly authenticationTime: string | null;
readonly expiresAt: string | null;
readonly freshAuth: boolean;
}
export interface ConsoleTokenIntrospectionDto {
readonly active: boolean;
readonly tenant: string;
readonly subject: string | null;
readonly clientId: string | null;
readonly tokenId: string | null;
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly issuedAt: string | null;
readonly authenticationTime: string | null;
readonly expiresAt: string | null;
readonly freshAuth: boolean;
}
export interface AuthorityConsoleApi {
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto>;
getProfile(tenantId?: string): Observable<ConsoleProfileDto>;
introspectToken(
tenantId?: string
): Observable<ConsoleTokenIntrospectionDto>;
}
export const AUTHORITY_CONSOLE_API = new InjectionToken<AuthorityConsoleApi>(
'AUTHORITY_CONSOLE_API'
);
export const AUTHORITY_CONSOLE_API_BASE_URL = new InjectionToken<string>(
'AUTHORITY_CONSOLE_API_BASE_URL'
);
@Injectable({
providedIn: 'root',
})
export class AuthorityConsoleApiHttpClient implements AuthorityConsoleApi {
constructor(
private readonly http: HttpClient,
@Inject(AUTHORITY_CONSOLE_API_BASE_URL) private readonly baseUrl: string,
private readonly authSession: AuthSessionStore
) {}
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto> {
return this.http.get<TenantCatalogResponseDto>(`${this.baseUrl}/tenants`, {
headers: this.buildHeaders(tenantId),
});
}
getProfile(tenantId?: string): Observable<ConsoleProfileDto> {
return this.http.get<ConsoleProfileDto>(`${this.baseUrl}/profile`, {
headers: this.buildHeaders(tenantId),
});
}
introspectToken(
tenantId?: string
): Observable<ConsoleTokenIntrospectionDto> {
return this.http.post<ConsoleTokenIntrospectionDto>(
`${this.baseUrl}/token/introspect`,
{},
{
headers: this.buildHeaders(tenantId),
}
);
}
private buildHeaders(tenantOverride?: string): HttpHeaders {
const tenantId =
(tenantOverride && tenantOverride.trim()) ||
this.authSession.getActiveTenantId();
if (!tenantId) {
throw new Error(
'AuthorityConsoleApiHttpClient requires an active tenant identifier.'
);
}
return new HttpHeaders({
'X-StellaOps-Tenant': tenantId,
});
}
}
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
export interface AuthorityTenantViewDto {
readonly id: string;
readonly displayName: string;
readonly status: string;
readonly isolationMode: string;
readonly defaultRoles: readonly string[];
}
export interface TenantCatalogResponseDto {
readonly tenants: readonly AuthorityTenantViewDto[];
}
export interface ConsoleProfileDto {
readonly subjectId: string | null;
readonly username: string | null;
readonly displayName: string | null;
readonly tenant: string;
readonly sessionId: string | null;
readonly roles: readonly string[];
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly authenticationMethods: readonly string[];
readonly issuedAt: string | null;
readonly authenticationTime: string | null;
readonly expiresAt: string | null;
readonly freshAuth: boolean;
}
export interface ConsoleTokenIntrospectionDto {
readonly active: boolean;
readonly tenant: string;
readonly subject: string | null;
readonly clientId: string | null;
readonly tokenId: string | null;
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly issuedAt: string | null;
readonly authenticationTime: string | null;
readonly expiresAt: string | null;
readonly freshAuth: boolean;
}
export interface AuthorityConsoleApi {
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto>;
getProfile(tenantId?: string): Observable<ConsoleProfileDto>;
introspectToken(
tenantId?: string
): Observable<ConsoleTokenIntrospectionDto>;
}
export const AUTHORITY_CONSOLE_API = new InjectionToken<AuthorityConsoleApi>(
'AUTHORITY_CONSOLE_API'
);
export const AUTHORITY_CONSOLE_API_BASE_URL = new InjectionToken<string>(
'AUTHORITY_CONSOLE_API_BASE_URL'
);
@Injectable({
providedIn: 'root',
})
export class AuthorityConsoleApiHttpClient implements AuthorityConsoleApi {
constructor(
private readonly http: HttpClient,
@Inject(AUTHORITY_CONSOLE_API_BASE_URL) private readonly baseUrl: string,
private readonly authSession: AuthSessionStore
) {}
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto> {
return this.http.get<TenantCatalogResponseDto>(`${this.baseUrl}/tenants`, {
headers: this.buildHeaders(tenantId),
});
}
getProfile(tenantId?: string): Observable<ConsoleProfileDto> {
return this.http.get<ConsoleProfileDto>(`${this.baseUrl}/profile`, {
headers: this.buildHeaders(tenantId),
});
}
introspectToken(
tenantId?: string
): Observable<ConsoleTokenIntrospectionDto> {
return this.http.post<ConsoleTokenIntrospectionDto>(
`${this.baseUrl}/token/introspect`,
{},
{
headers: this.buildHeaders(tenantId),
}
);
}
private buildHeaders(tenantOverride?: string): HttpHeaders {
const tenantId =
(tenantOverride && tenantOverride.trim()) ||
this.authSession.getActiveTenantId();
if (!tenantId) {
throw new Error(
'AuthorityConsoleApiHttpClient requires an active tenant identifier.'
);
}
return new HttpHeaders({
'X-StellaOps-Tenant': tenantId,
});
}
}

View File

@@ -1,51 +1,51 @@
import { HttpClient } from '@angular/common/http';
import {
Injectable,
InjectionToken,
inject,
} from '@angular/core';
import { Observable } from 'rxjs';
export interface TrivyDbSettingsDto {
publishFull: boolean;
publishDelta: boolean;
includeFull: boolean;
includeDelta: boolean;
}
export interface TrivyDbRunResponseDto {
exportId: string;
triggeredAt: string;
status?: string;
}
export const CONCELIER_EXPORTER_API_BASE_URL = new InjectionToken<string>(
'CONCELIER_EXPORTER_API_BASE_URL'
);
@Injectable({
providedIn: 'root',
})
export class ConcelierExporterClient {
private readonly http = inject(HttpClient);
private readonly baseUrl = inject(CONCELIER_EXPORTER_API_BASE_URL);
getTrivyDbSettings(): Observable<TrivyDbSettingsDto> {
return this.http.get<TrivyDbSettingsDto>(`${this.baseUrl}/settings`);
}
updateTrivyDbSettings(
settings: TrivyDbSettingsDto
): Observable<TrivyDbSettingsDto> {
return this.http.put<TrivyDbSettingsDto>(`${this.baseUrl}/settings`, settings);
}
runTrivyDbExport(
settings: TrivyDbSettingsDto
): Observable<TrivyDbRunResponseDto> {
return this.http.post<TrivyDbRunResponseDto>(`${this.baseUrl}/run`, {
trigger: 'ui',
parameters: settings,
});
}
}
import { HttpClient } from '@angular/common/http';
import {
Injectable,
InjectionToken,
inject,
} from '@angular/core';
import { Observable } from 'rxjs';
export interface TrivyDbSettingsDto {
publishFull: boolean;
publishDelta: boolean;
includeFull: boolean;
includeDelta: boolean;
}
export interface TrivyDbRunResponseDto {
exportId: string;
triggeredAt: string;
status?: string;
}
export const CONCELIER_EXPORTER_API_BASE_URL = new InjectionToken<string>(
'CONCELIER_EXPORTER_API_BASE_URL'
);
@Injectable({
providedIn: 'root',
})
export class ConcelierExporterClient {
private readonly http = inject(HttpClient);
private readonly baseUrl = inject(CONCELIER_EXPORTER_API_BASE_URL);
getTrivyDbSettings(): Observable<TrivyDbSettingsDto> {
return this.http.get<TrivyDbSettingsDto>(`${this.baseUrl}/settings`);
}
updateTrivyDbSettings(
settings: TrivyDbSettingsDto
): Observable<TrivyDbSettingsDto> {
return this.http.put<TrivyDbSettingsDto>(`${this.baseUrl}/settings`, settings);
}
runTrivyDbExport(
settings: TrivyDbSettingsDto
): Observable<TrivyDbRunResponseDto> {
return this.http.post<TrivyDbRunResponseDto>(`${this.baseUrl}/run`, {
trigger: 'ui',
parameters: settings,
});
}
}

View File

@@ -1,77 +1,77 @@
/**
* Determinism verification models for SBOM scan details.
*/
export interface DeterminismStatus {
/** Overall determinism status */
status: 'verified' | 'warning' | 'failed' | 'unknown';
/** Merkle root from _composition.json */
merkleRoot: string | null;
/** Whether Merkle root matches computed hash */
merkleConsistent: boolean;
/** Fragment hashes with verification status */
fragments: DeterminismFragment[];
/** Composition metadata */
composition: CompositionMeta | null;
/** Timestamp of verification */
verifiedAt: string;
/** Any issues found */
issues: DeterminismIssue[];
}
export interface DeterminismFragment {
/** Fragment identifier (e.g., layer digest) */
id: string;
/** Fragment type */
type: 'layer' | 'metadata' | 'attestation' | 'sbom';
/** Expected hash from composition */
expectedHash: string;
/** Computed hash */
computedHash: string;
/** Whether hashes match */
matches: boolean;
/** Size in bytes */
size: number;
}
export interface CompositionMeta {
/** Composition schema version */
schemaVersion: string;
/** Scanner version that produced this */
scannerVersion: string;
/** Build timestamp */
buildTimestamp: string;
/** Total fragments */
fragmentCount: number;
/** Composition file hash */
compositionHash: string;
}
export interface DeterminismIssue {
/** Issue severity */
severity: 'error' | 'warning' | 'info';
/** Issue code */
code: string;
/** Human-readable message */
message: string;
/** Affected fragment ID if applicable */
fragmentId?: string;
}
/**
* Determinism verification models for SBOM scan details.
*/
export interface DeterminismStatus {
/** Overall determinism status */
status: 'verified' | 'warning' | 'failed' | 'unknown';
/** Merkle root from _composition.json */
merkleRoot: string | null;
/** Whether Merkle root matches computed hash */
merkleConsistent: boolean;
/** Fragment hashes with verification status */
fragments: DeterminismFragment[];
/** Composition metadata */
composition: CompositionMeta | null;
/** Timestamp of verification */
verifiedAt: string;
/** Any issues found */
issues: DeterminismIssue[];
}
export interface DeterminismFragment {
/** Fragment identifier (e.g., layer digest) */
id: string;
/** Fragment type */
type: 'layer' | 'metadata' | 'attestation' | 'sbom';
/** Expected hash from composition */
expectedHash: string;
/** Computed hash */
computedHash: string;
/** Whether hashes match */
matches: boolean;
/** Size in bytes */
size: number;
}
export interface CompositionMeta {
/** Composition schema version */
schemaVersion: string;
/** Scanner version that produced this */
scannerVersion: string;
/** Build timestamp */
buildTimestamp: string;
/** Total fragments */
fragmentCount: number;
/** Composition file hash */
compositionHash: string;
}
export interface DeterminismIssue {
/** Issue severity */
severity: 'error' | 'warning' | 'info';
/** Issue code */
code: string;
/** Human-readable message */
message: string;
/** Affected fragment ID if applicable */
fragmentId?: string;
}

View File

@@ -1,95 +1,95 @@
/**
* Entropy analysis models for image security visualization.
*/
export interface EntropyAnalysis {
/** Image digest */
imageDigest: string;
/** Overall entropy score (0-10, higher = more suspicious) */
overallScore: number;
/** Risk level classification */
riskLevel: 'low' | 'medium' | 'high' | 'critical';
/** Per-layer entropy breakdown */
layers: LayerEntropy[];
/** Files with high entropy (potential secrets/malware) */
highEntropyFiles: HighEntropyFile[];
/** Detector hints for suspicious patterns */
detectorHints: DetectorHint[];
/** Analysis timestamp */
analyzedAt: string;
/** Link to raw entropy report */
reportUrl: string;
}
export interface LayerEntropy {
/** Layer digest */
digest: string;
/** Layer command (e.g., COPY, RUN) */
command: string;
/** Layer size in bytes */
size: number;
/** Average entropy for this layer (0-8 bits) */
avgEntropy: number;
/** Percentage of opaque bytes (high entropy) */
opaqueByteRatio: number;
/** Number of high-entropy files */
highEntropyFileCount: number;
/** Risk contribution to overall score */
riskContribution: number;
}
export interface HighEntropyFile {
/** File path in container */
path: string;
/** Layer where file was added */
layerDigest: string;
/** File size in bytes */
size: number;
/** File entropy (0-8 bits) */
entropy: number;
/** Classification */
classification: 'encrypted' | 'compressed' | 'binary' | 'suspicious' | 'unknown';
/** Why this file is flagged */
reason: string;
}
export interface DetectorHint {
/** Hint ID */
id: string;
/** Severity */
severity: 'critical' | 'high' | 'medium' | 'low';
/** Pattern type */
type: 'credential' | 'key' | 'token' | 'obfuscated' | 'packed' | 'crypto';
/** Human-readable description */
description: string;
/** Affected file paths */
affectedPaths: string[];
/** Confidence (0-100) */
confidence: number;
/** Remediation suggestion */
remediation: string;
}
/**
* Entropy analysis models for image security visualization.
*/
export interface EntropyAnalysis {
/** Image digest */
imageDigest: string;
/** Overall entropy score (0-10, higher = more suspicious) */
overallScore: number;
/** Risk level classification */
riskLevel: 'low' | 'medium' | 'high' | 'critical';
/** Per-layer entropy breakdown */
layers: LayerEntropy[];
/** Files with high entropy (potential secrets/malware) */
highEntropyFiles: HighEntropyFile[];
/** Detector hints for suspicious patterns */
detectorHints: DetectorHint[];
/** Analysis timestamp */
analyzedAt: string;
/** Link to raw entropy report */
reportUrl: string;
}
export interface LayerEntropy {
/** Layer digest */
digest: string;
/** Layer command (e.g., COPY, RUN) */
command: string;
/** Layer size in bytes */
size: number;
/** Average entropy for this layer (0-8 bits) */
avgEntropy: number;
/** Percentage of opaque bytes (high entropy) */
opaqueByteRatio: number;
/** Number of high-entropy files */
highEntropyFileCount: number;
/** Risk contribution to overall score */
riskContribution: number;
}
export interface HighEntropyFile {
/** File path in container */
path: string;
/** Layer where file was added */
layerDigest: string;
/** File size in bytes */
size: number;
/** File entropy (0-8 bits) */
entropy: number;
/** Classification */
classification: 'encrypted' | 'compressed' | 'binary' | 'suspicious' | 'unknown';
/** Why this file is flagged */
reason: string;
}
export interface DetectorHint {
/** Hint ID */
id: string;
/** Severity */
severity: 'critical' | 'high' | 'medium' | 'low';
/** Pattern type */
type: 'credential' | 'key' | 'token' | 'obfuscated' | 'packed' | 'crypto';
/** Human-readable description */
description: string;
/** Affected file paths */
affectedPaths: string[];
/** Confidence (0-100) */
confidence: number;
/** Remediation suggestion */
remediation: string;
}

View File

@@ -1,352 +1,352 @@
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
EvidenceData,
Linkset,
Observation,
PolicyEvidence,
} from './evidence.models';
export interface EvidenceApi {
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
getObservation(observationId: string): Observable<Observation>;
getLinkset(linksetId: string): Observable<Linkset>;
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
/**
* Export full evidence bundle as tar.gz or zip
* SPRINT_0341_0001_0001 - T14: One-click evidence export
*/
exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob>;
}
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
// Mock data for development
const MOCK_OBSERVATIONS: Observation[] = [
{
observationId: 'obs-ghsa-001',
tenantId: 'tenant-1',
source: 'ghsa',
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
title: 'Log4j Remote Code Execution (Log4Shell)',
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'maven',
ranges: [
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.0-beta9' },
{ fixed: '2.15.0' },
],
},
],
},
],
references: [
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
'https://logging.apache.org/log4j/2.x/security.html',
],
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
published: '2021-12-10T00:00:00Z',
modified: '2024-01-15T10:30:00Z',
provenance: {
sourceArtifactSha: 'sha256:abc123def456...',
fetchedAt: '2024-11-20T08:00:00Z',
ingestJobId: 'job-ghsa-2024-1120',
},
ingestedAt: '2024-11-20T08:05:00Z',
},
{
observationId: 'obs-nvd-001',
tenantId: 'tenant-1',
source: 'nvd',
advisoryId: 'CVE-2021-44228',
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'maven',
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
},
],
references: [
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
],
relationships: [
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
],
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
published: '2021-12-10T10:15:00Z',
modified: '2024-02-20T15:45:00Z',
provenance: {
sourceArtifactSha: 'sha256:def789ghi012...',
fetchedAt: '2024-11-20T08:10:00Z',
ingestJobId: 'job-nvd-2024-1120',
},
ingestedAt: '2024-11-20T08:15:00Z',
},
{
observationId: 'obs-osv-001',
tenantId: 'tenant-1',
source: 'osv',
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
title: 'Remote code injection in Log4j',
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'Maven',
ranges: [
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.0-beta9' },
{ fixed: '2.3.1' },
],
},
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.4' },
{ fixed: '2.12.2' },
],
},
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.13.0' },
{ fixed: '2.15.0' },
],
},
],
},
],
references: [
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
],
published: '2021-12-10T00:00:00Z',
modified: '2023-06-15T09:00:00Z',
provenance: {
sourceArtifactSha: 'sha256:ghi345jkl678...',
fetchedAt: '2024-11-20T08:20:00Z',
ingestJobId: 'job-osv-2024-1120',
},
ingestedAt: '2024-11-20T08:25:00Z',
},
];
const MOCK_LINKSET: Linkset = {
linksetId: 'ls-log4shell-001',
tenantId: 'tenant-1',
advisoryId: 'CVE-2021-44228',
source: 'aggregated',
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
normalized: {
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
},
confidence: 0.95,
conflicts: [
{
field: 'affected.ranges',
reason: 'Different fixed version ranges reported by sources',
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
sourceIds: ['ghsa', 'osv'],
},
{
field: 'weaknesses',
reason: 'Different CWE identifiers reported',
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
sourceIds: ['ghsa', 'nvd'],
},
],
createdAt: '2024-11-20T08:30:00Z',
builtByJobId: 'linkset-build-2024-1120',
provenance: {
observationHashes: [
'sha256:abc123...',
'sha256:def789...',
'sha256:ghi345...',
],
toolVersion: 'concelier-lnm-1.2.0',
policyHash: 'sha256:policy-hash-001',
},
};
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
policyId: 'pol-critical-vuln-001',
policyName: 'Critical Vulnerability Policy',
decision: 'block',
decidedAt: '2024-11-20T08:35:00Z',
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
rules: [
{
ruleId: 'rule-cvss-critical',
ruleName: 'Block Critical CVSS',
passed: false,
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
matchedItems: ['CVE-2021-44228'],
},
{
ruleId: 'rule-known-exploit',
ruleName: 'Known Exploit Check',
passed: false,
reason: 'Active exploitation reported by CISA',
matchedItems: ['KEV-2021-44228'],
},
{
ruleId: 'rule-fix-available',
ruleName: 'Fix Available',
passed: true,
reason: 'Fixed version 2.15.0+ available',
},
],
linksetIds: ['ls-log4shell-001'],
aocChain: [
{
attestationId: 'aoc-obs-ghsa-001',
type: 'observation',
hash: 'sha256:abc123def456...',
timestamp: '2024-11-20T08:05:00Z',
parentHash: undefined,
},
{
attestationId: 'aoc-obs-nvd-001',
type: 'observation',
hash: 'sha256:def789ghi012...',
timestamp: '2024-11-20T08:15:00Z',
parentHash: 'sha256:abc123def456...',
},
{
attestationId: 'aoc-obs-osv-001',
type: 'observation',
hash: 'sha256:ghi345jkl678...',
timestamp: '2024-11-20T08:25:00Z',
parentHash: 'sha256:def789ghi012...',
},
{
attestationId: 'aoc-ls-001',
type: 'linkset',
hash: 'sha256:linkset-hash-001...',
timestamp: '2024-11-20T08:30:00Z',
parentHash: 'sha256:ghi345jkl678...',
},
{
attestationId: 'aoc-policy-001',
type: 'policy',
hash: 'sha256:policy-decision-hash...',
timestamp: '2024-11-20T08:35:00Z',
signer: 'policy-engine-v1',
parentHash: 'sha256:linkset-hash-001...',
},
],
};
@Injectable({ providedIn: 'root' })
export class MockEvidenceApiService implements EvidenceApi {
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
// Filter observations related to the advisory
const observations = MOCK_OBSERVATIONS.filter(
(o) =>
o.advisoryId === advisoryId ||
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
);
const linkset = MOCK_LINKSET;
const policyEvidence = MOCK_POLICY_EVIDENCE;
const data: EvidenceData = {
advisoryId,
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
observations,
linkset,
policyEvidence,
hasConflicts: linkset.conflicts.length > 0,
conflictCount: linkset.conflicts.length,
};
return of(data).pipe(delay(300));
}
getObservation(observationId: string): Observable<Observation> {
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
if (!observation) {
throw new Error(`Observation not found: ${observationId}`);
}
return of(observation).pipe(delay(100));
}
getLinkset(linksetId: string): Observable<Linkset> {
if (linksetId === MOCK_LINKSET.linksetId) {
return of(MOCK_LINKSET).pipe(delay(100));
}
throw new Error(`Linkset not found: ${linksetId}`);
}
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
}
return of(null).pipe(delay(100));
}
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
let data: object;
if (type === 'observation') {
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
} else {
data = MOCK_LINKSET;
}
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
return of(blob).pipe(delay(100));
}
/**
* Export full evidence bundle as tar.gz or zip
* SPRINT_0341_0001_0001 - T14: One-click evidence export
*/
async exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob> {
// In mock implementation, return a JSON blob with all evidence data
const data = {
advisoryId,
exportedAt: new Date().toISOString(),
format,
observations: MOCK_OBSERVATIONS,
linkset: MOCK_LINKSET,
policyEvidence: MOCK_POLICY_EVIDENCE,
};
const json = JSON.stringify(data, null, 2);
const mimeType = format === 'tar.gz' ? 'application/gzip' : 'application/zip';
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
return new Blob([json], { type: mimeType });
}
}
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
EvidenceData,
Linkset,
Observation,
PolicyEvidence,
} from './evidence.models';
export interface EvidenceApi {
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
getObservation(observationId: string): Observable<Observation>;
getLinkset(linksetId: string): Observable<Linkset>;
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
/**
* Export full evidence bundle as tar.gz or zip
* SPRINT_0341_0001_0001 - T14: One-click evidence export
*/
exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob>;
}
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
// Mock data for development
const MOCK_OBSERVATIONS: Observation[] = [
{
observationId: 'obs-ghsa-001',
tenantId: 'tenant-1',
source: 'ghsa',
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
title: 'Log4j Remote Code Execution (Log4Shell)',
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'maven',
ranges: [
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.0-beta9' },
{ fixed: '2.15.0' },
],
},
],
},
],
references: [
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
'https://logging.apache.org/log4j/2.x/security.html',
],
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
published: '2021-12-10T00:00:00Z',
modified: '2024-01-15T10:30:00Z',
provenance: {
sourceArtifactSha: 'sha256:abc123def456...',
fetchedAt: '2024-11-20T08:00:00Z',
ingestJobId: 'job-ghsa-2024-1120',
},
ingestedAt: '2024-11-20T08:05:00Z',
},
{
observationId: 'obs-nvd-001',
tenantId: 'tenant-1',
source: 'nvd',
advisoryId: 'CVE-2021-44228',
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'maven',
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
},
],
references: [
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
],
relationships: [
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
],
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
published: '2021-12-10T10:15:00Z',
modified: '2024-02-20T15:45:00Z',
provenance: {
sourceArtifactSha: 'sha256:def789ghi012...',
fetchedAt: '2024-11-20T08:10:00Z',
ingestJobId: 'job-nvd-2024-1120',
},
ingestedAt: '2024-11-20T08:15:00Z',
},
{
observationId: 'obs-osv-001',
tenantId: 'tenant-1',
source: 'osv',
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
title: 'Remote code injection in Log4j',
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'Maven',
ranges: [
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.0-beta9' },
{ fixed: '2.3.1' },
],
},
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.4' },
{ fixed: '2.12.2' },
],
},
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.13.0' },
{ fixed: '2.15.0' },
],
},
],
},
],
references: [
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
],
published: '2021-12-10T00:00:00Z',
modified: '2023-06-15T09:00:00Z',
provenance: {
sourceArtifactSha: 'sha256:ghi345jkl678...',
fetchedAt: '2024-11-20T08:20:00Z',
ingestJobId: 'job-osv-2024-1120',
},
ingestedAt: '2024-11-20T08:25:00Z',
},
];
const MOCK_LINKSET: Linkset = {
linksetId: 'ls-log4shell-001',
tenantId: 'tenant-1',
advisoryId: 'CVE-2021-44228',
source: 'aggregated',
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
normalized: {
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
},
confidence: 0.95,
conflicts: [
{
field: 'affected.ranges',
reason: 'Different fixed version ranges reported by sources',
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
sourceIds: ['ghsa', 'osv'],
},
{
field: 'weaknesses',
reason: 'Different CWE identifiers reported',
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
sourceIds: ['ghsa', 'nvd'],
},
],
createdAt: '2024-11-20T08:30:00Z',
builtByJobId: 'linkset-build-2024-1120',
provenance: {
observationHashes: [
'sha256:abc123...',
'sha256:def789...',
'sha256:ghi345...',
],
toolVersion: 'concelier-lnm-1.2.0',
policyHash: 'sha256:policy-hash-001',
},
};
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
policyId: 'pol-critical-vuln-001',
policyName: 'Critical Vulnerability Policy',
decision: 'block',
decidedAt: '2024-11-20T08:35:00Z',
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
rules: [
{
ruleId: 'rule-cvss-critical',
ruleName: 'Block Critical CVSS',
passed: false,
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
matchedItems: ['CVE-2021-44228'],
},
{
ruleId: 'rule-known-exploit',
ruleName: 'Known Exploit Check',
passed: false,
reason: 'Active exploitation reported by CISA',
matchedItems: ['KEV-2021-44228'],
},
{
ruleId: 'rule-fix-available',
ruleName: 'Fix Available',
passed: true,
reason: 'Fixed version 2.15.0+ available',
},
],
linksetIds: ['ls-log4shell-001'],
aocChain: [
{
attestationId: 'aoc-obs-ghsa-001',
type: 'observation',
hash: 'sha256:abc123def456...',
timestamp: '2024-11-20T08:05:00Z',
parentHash: undefined,
},
{
attestationId: 'aoc-obs-nvd-001',
type: 'observation',
hash: 'sha256:def789ghi012...',
timestamp: '2024-11-20T08:15:00Z',
parentHash: 'sha256:abc123def456...',
},
{
attestationId: 'aoc-obs-osv-001',
type: 'observation',
hash: 'sha256:ghi345jkl678...',
timestamp: '2024-11-20T08:25:00Z',
parentHash: 'sha256:def789ghi012...',
},
{
attestationId: 'aoc-ls-001',
type: 'linkset',
hash: 'sha256:linkset-hash-001...',
timestamp: '2024-11-20T08:30:00Z',
parentHash: 'sha256:ghi345jkl678...',
},
{
attestationId: 'aoc-policy-001',
type: 'policy',
hash: 'sha256:policy-decision-hash...',
timestamp: '2024-11-20T08:35:00Z',
signer: 'policy-engine-v1',
parentHash: 'sha256:linkset-hash-001...',
},
],
};
@Injectable({ providedIn: 'root' })
export class MockEvidenceApiService implements EvidenceApi {
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
// Filter observations related to the advisory
const observations = MOCK_OBSERVATIONS.filter(
(o) =>
o.advisoryId === advisoryId ||
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
);
const linkset = MOCK_LINKSET;
const policyEvidence = MOCK_POLICY_EVIDENCE;
const data: EvidenceData = {
advisoryId,
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
observations,
linkset,
policyEvidence,
hasConflicts: linkset.conflicts.length > 0,
conflictCount: linkset.conflicts.length,
};
return of(data).pipe(delay(300));
}
getObservation(observationId: string): Observable<Observation> {
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
if (!observation) {
throw new Error(`Observation not found: ${observationId}`);
}
return of(observation).pipe(delay(100));
}
getLinkset(linksetId: string): Observable<Linkset> {
if (linksetId === MOCK_LINKSET.linksetId) {
return of(MOCK_LINKSET).pipe(delay(100));
}
throw new Error(`Linkset not found: ${linksetId}`);
}
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
}
return of(null).pipe(delay(100));
}
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
let data: object;
if (type === 'observation') {
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
} else {
data = MOCK_LINKSET;
}
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
return of(blob).pipe(delay(100));
}
/**
* Export full evidence bundle as tar.gz or zip
* SPRINT_0341_0001_0001 - T14: One-click evidence export
*/
async exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob> {
// In mock implementation, return a JSON blob with all evidence data
const data = {
advisoryId,
exportedAt: new Date().toISOString(),
format,
observations: MOCK_OBSERVATIONS,
linkset: MOCK_LINKSET,
policyEvidence: MOCK_POLICY_EVIDENCE,
};
const json = JSON.stringify(data, null, 2);
const mimeType = format === 'tar.gz' ? 'application/gzip' : 'application/zip';
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
return new Blob([json], { type: mimeType });
}
}

View File

@@ -1,355 +1,355 @@
/**
* Link-Not-Merge Evidence Models
* Based on docs/modules/concelier/link-not-merge-schema.md
*/
// Severity from advisory sources
export interface AdvisorySeverity {
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
readonly score: number;
readonly vector?: string;
}
// Affected package information
export interface AffectedPackage {
readonly purl: string;
readonly package?: string;
readonly versions?: readonly string[];
readonly ranges?: readonly VersionRange[];
readonly ecosystem?: string;
readonly cpe?: readonly string[];
}
export interface VersionRange {
readonly type: string;
readonly events: readonly VersionEvent[];
}
export interface VersionEvent {
readonly introduced?: string;
readonly fixed?: string;
readonly last_affected?: string;
}
// Relationship between advisories
export interface AdvisoryRelationship {
readonly type: string;
readonly source: string;
readonly target: string;
readonly provenance?: string;
}
// Provenance tracking
export interface ObservationProvenance {
readonly sourceArtifactSha: string;
readonly fetchedAt: string;
readonly ingestJobId?: string;
readonly signature?: Record<string, unknown>;
}
// Raw observation from a single source
export interface Observation {
readonly observationId: string;
readonly tenantId: string;
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
readonly advisoryId: string;
readonly title?: string;
readonly summary?: string;
readonly severities: readonly AdvisorySeverity[];
readonly affected: readonly AffectedPackage[];
readonly references?: readonly string[];
readonly scopes?: readonly string[];
readonly relationships?: readonly AdvisoryRelationship[];
readonly weaknesses?: readonly string[];
readonly published?: string;
readonly modified?: string;
readonly provenance: ObservationProvenance;
readonly ingestedAt: string;
}
// Conflict when sources disagree
export interface LinksetConflict {
readonly field: string;
readonly reason: string;
readonly values?: readonly string[];
readonly sourceIds?: readonly string[];
}
// Linkset provenance
export interface LinksetProvenance {
readonly observationHashes: readonly string[];
readonly toolVersion?: string;
readonly policyHash?: string;
}
// Normalized linkset aggregating multiple observations
export interface Linkset {
readonly linksetId: string;
readonly tenantId: string;
readonly advisoryId: string;
readonly source: string;
readonly observations: readonly string[]; // observation IDs
readonly normalized?: {
readonly purls?: readonly string[];
readonly versions?: readonly string[];
readonly ranges?: readonly VersionRange[];
readonly severities?: readonly AdvisorySeverity[];
};
readonly confidence?: number; // 0-1
readonly conflicts: readonly LinksetConflict[];
readonly createdAt: string;
readonly builtByJobId?: string;
readonly provenance?: LinksetProvenance;
// Artifact and verification fields (SPRINT_0341_0001_0001)
readonly artifactRef?: string; // e.g., registry.example.com/image:tag
readonly artifactDigest?: string; // e.g., sha256:abc123...
readonly sbomDigest?: string; // SBOM attestation digest
readonly rekorLogIndex?: number; // Rekor transparency log index
}
// Policy decision result
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
// Policy decision with evidence
export interface PolicyEvidence {
readonly policyId: string;
readonly policyName: string;
readonly decision: PolicyDecision;
readonly decidedAt: string;
readonly reason?: string;
readonly rules: readonly PolicyRuleResult[];
readonly linksetIds: readonly string[];
readonly aocChain?: AocChainEntry[];
// Decision verification fields (SPRINT_0341_0001_0001)
readonly decisionDigest?: string; // Hash of the decision for verification
readonly rekorLogIndex?: number; // Rekor log index if logged
}
export interface PolicyRuleResult {
readonly ruleId: string;
readonly ruleName: string;
readonly passed: boolean;
readonly reason?: string;
readonly matchedItems?: readonly string[];
// Confidence metadata (UI-POLICY-13-007)
readonly unknownConfidence?: number | null;
readonly confidenceBand?: string | null;
readonly unknownAgeDays?: number | null;
readonly sourceTrust?: string | null;
readonly reachability?: string | null;
readonly quietedBy?: string | null;
readonly quiet?: boolean | null;
}
// AOC (Attestation of Compliance) chain entry
export interface AocChainEntry {
readonly attestationId: string;
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
readonly hash: string;
readonly timestamp: string;
readonly signer?: string;
readonly parentHash?: string;
}
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
export type VexStatus =
| 'NOT_AFFECTED'
| 'UNDER_INVESTIGATION'
| 'AFFECTED_MITIGATED'
| 'AFFECTED_UNMITIGATED'
| 'FIXED';
export type VexJustificationType =
| 'CODE_NOT_PRESENT'
| 'CODE_NOT_REACHABLE'
| 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH'
| 'CONFIGURATION_NOT_AFFECTED'
| 'OS_NOT_AFFECTED'
| 'RUNTIME_MITIGATION_PRESENT'
| 'COMPENSATING_CONTROLS'
| 'ACCEPTED_BUSINESS_RISK'
| 'OTHER';
export interface VexSubjectRef {
readonly type: 'IMAGE' | 'REPO' | 'SBOM_COMPONENT' | 'OTHER';
readonly name: string;
readonly digest: Record<string, string>;
readonly sbomNodeId?: string;
}
export interface VexEvidenceRef {
readonly type: 'PR' | 'TICKET' | 'DOC' | 'COMMIT' | 'OTHER';
readonly title?: string;
readonly url: string;
}
export interface VexScope {
readonly environments?: readonly string[];
readonly projects?: readonly string[];
}
export interface VexValidFor {
readonly notBefore?: string;
readonly notAfter?: string;
}
export interface VexActorRef {
readonly id: string;
readonly displayName: string;
}
/**
* Signature metadata for signed VEX decisions.
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui (FE-RISK-005)
*/
export interface VexDecisionSignatureInfo {
/** Whether the decision is cryptographically signed */
readonly isSigned: boolean;
/** DSSE envelope digest (base64-encoded) */
readonly dsseDigest?: string;
/** Signature algorithm used (e.g., 'ecdsa-p256', 'rsa-sha256') */
readonly signatureAlgorithm?: string;
/** Key ID used for signing */
readonly signingKeyId?: string;
/** Signer identity (e.g., email, OIDC subject) */
readonly signerIdentity?: string;
/** Timestamp when signed (ISO-8601) */
readonly signedAt?: string;
/** Signature verification status */
readonly verificationStatus?: 'verified' | 'failed' | 'pending' | 'unknown';
/** Rekor transparency log entry if logged */
readonly rekorEntry?: VexRekorEntry;
}
/**
* Rekor transparency log entry for VEX decisions.
*/
export interface VexRekorEntry {
/** Rekor log index */
readonly logIndex: number;
/** Rekor log ID (tree hash) */
readonly logId?: string;
/** Entry UUID in Rekor */
readonly entryUuid?: string;
/** Time integrated into the log (ISO-8601) */
readonly integratedTime?: string;
/** URL to view/verify the entry */
readonly verifyUrl?: string;
}
export interface VexDecision {
readonly id: string;
readonly vulnerabilityId: string;
readonly subject: VexSubjectRef;
readonly status: VexStatus;
readonly justificationType: VexJustificationType;
readonly justificationText?: string;
readonly evidenceRefs?: readonly VexEvidenceRef[];
readonly scope?: VexScope;
readonly validFor?: VexValidFor;
readonly supersedesDecisionId?: string;
readonly createdBy: VexActorRef;
readonly createdAt: string;
readonly updatedAt?: string;
/** Signature metadata for signed decisions (FE-RISK-005) */
readonly signatureInfo?: VexDecisionSignatureInfo;
}
// VEX status summary for UI display
export interface VexStatusSummary {
readonly notAffected: number;
readonly underInvestigation: number;
readonly affectedMitigated: number;
readonly affectedUnmitigated: number;
readonly fixed: number;
readonly total: number;
}
// VEX conflict indicator
export interface VexConflict {
readonly vulnerabilityId: string;
readonly conflictingStatuses: readonly VexStatus[];
readonly decisionIds: readonly string[];
readonly reason: string;
}
// Evidence panel data combining all elements
export interface EvidenceData {
readonly advisoryId: string;
readonly title?: string;
readonly observations: readonly Observation[];
readonly linkset?: Linkset;
readonly policyEvidence?: PolicyEvidence;
readonly vexDecisions?: readonly VexDecision[];
readonly vexConflicts?: readonly VexConflict[];
readonly hasConflicts: boolean;
readonly conflictCount: number;
}
// Source metadata for display
export interface SourceInfo {
readonly sourceId: string;
readonly name: string;
readonly icon?: string;
readonly url?: string;
readonly lastUpdated?: string;
}
// Filter configuration for observations/linksets
export type SeverityBucket = 'critical' | 'high' | 'medium' | 'low' | 'all';
export interface ObservationFilters {
readonly sources: readonly string[]; // Filter by source IDs
readonly severityBucket: SeverityBucket; // Filter by severity level
readonly conflictOnly: boolean; // Show only observations with conflicts
readonly hasCvssVector: boolean | null; // null = all, true = has vector, false = no vector
}
export const DEFAULT_OBSERVATION_FILTERS: ObservationFilters = {
sources: [],
severityBucket: 'all',
conflictOnly: false,
hasCvssVector: null,
};
// Pagination configuration
export interface PaginationState {
readonly pageSize: number;
readonly currentPage: number;
readonly totalItems: number;
}
export const DEFAULT_PAGE_SIZE = 10;
export const SOURCE_INFO: Record<string, SourceInfo> = {
ghsa: {
sourceId: 'ghsa',
name: 'GitHub Security Advisories',
icon: 'github',
url: 'https://github.com/advisories',
},
nvd: {
sourceId: 'nvd',
name: 'National Vulnerability Database',
icon: 'database',
url: 'https://nvd.nist.gov',
},
'cert-bund': {
sourceId: 'cert-bund',
name: 'CERT-Bund',
icon: 'shield',
url: 'https://www.cert-bund.de',
},
osv: {
sourceId: 'osv',
name: 'Open Source Vulnerabilities',
icon: 'box',
url: 'https://osv.dev',
},
cve: {
sourceId: 'cve',
name: 'CVE Program',
icon: 'alert-triangle',
url: 'https://cve.mitre.org',
},
};
/**
* Link-Not-Merge Evidence Models
* Based on docs/modules/concelier/link-not-merge-schema.md
*/
// Severity from advisory sources
export interface AdvisorySeverity {
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
readonly score: number;
readonly vector?: string;
}
// Affected package information
export interface AffectedPackage {
readonly purl: string;
readonly package?: string;
readonly versions?: readonly string[];
readonly ranges?: readonly VersionRange[];
readonly ecosystem?: string;
readonly cpe?: readonly string[];
}
export interface VersionRange {
readonly type: string;
readonly events: readonly VersionEvent[];
}
export interface VersionEvent {
readonly introduced?: string;
readonly fixed?: string;
readonly last_affected?: string;
}
// Relationship between advisories
export interface AdvisoryRelationship {
readonly type: string;
readonly source: string;
readonly target: string;
readonly provenance?: string;
}
// Provenance tracking
export interface ObservationProvenance {
readonly sourceArtifactSha: string;
readonly fetchedAt: string;
readonly ingestJobId?: string;
readonly signature?: Record<string, unknown>;
}
// Raw observation from a single source
export interface Observation {
readonly observationId: string;
readonly tenantId: string;
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
readonly advisoryId: string;
readonly title?: string;
readonly summary?: string;
readonly severities: readonly AdvisorySeverity[];
readonly affected: readonly AffectedPackage[];
readonly references?: readonly string[];
readonly scopes?: readonly string[];
readonly relationships?: readonly AdvisoryRelationship[];
readonly weaknesses?: readonly string[];
readonly published?: string;
readonly modified?: string;
readonly provenance: ObservationProvenance;
readonly ingestedAt: string;
}
// Conflict when sources disagree
export interface LinksetConflict {
readonly field: string;
readonly reason: string;
readonly values?: readonly string[];
readonly sourceIds?: readonly string[];
}
// Linkset provenance
export interface LinksetProvenance {
readonly observationHashes: readonly string[];
readonly toolVersion?: string;
readonly policyHash?: string;
}
// Normalized linkset aggregating multiple observations
export interface Linkset {
readonly linksetId: string;
readonly tenantId: string;
readonly advisoryId: string;
readonly source: string;
readonly observations: readonly string[]; // observation IDs
readonly normalized?: {
readonly purls?: readonly string[];
readonly versions?: readonly string[];
readonly ranges?: readonly VersionRange[];
readonly severities?: readonly AdvisorySeverity[];
};
readonly confidence?: number; // 0-1
readonly conflicts: readonly LinksetConflict[];
readonly createdAt: string;
readonly builtByJobId?: string;
readonly provenance?: LinksetProvenance;
// Artifact and verification fields (SPRINT_0341_0001_0001)
readonly artifactRef?: string; // e.g., registry.example.com/image:tag
readonly artifactDigest?: string; // e.g., sha256:abc123...
readonly sbomDigest?: string; // SBOM attestation digest
readonly rekorLogIndex?: number; // Rekor transparency log index
}
// Policy decision result
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
// Policy decision with evidence
export interface PolicyEvidence {
readonly policyId: string;
readonly policyName: string;
readonly decision: PolicyDecision;
readonly decidedAt: string;
readonly reason?: string;
readonly rules: readonly PolicyRuleResult[];
readonly linksetIds: readonly string[];
readonly aocChain?: AocChainEntry[];
// Decision verification fields (SPRINT_0341_0001_0001)
readonly decisionDigest?: string; // Hash of the decision for verification
readonly rekorLogIndex?: number; // Rekor log index if logged
}
export interface PolicyRuleResult {
readonly ruleId: string;
readonly ruleName: string;
readonly passed: boolean;
readonly reason?: string;
readonly matchedItems?: readonly string[];
// Confidence metadata (UI-POLICY-13-007)
readonly unknownConfidence?: number | null;
readonly confidenceBand?: string | null;
readonly unknownAgeDays?: number | null;
readonly sourceTrust?: string | null;
readonly reachability?: string | null;
readonly quietedBy?: string | null;
readonly quiet?: boolean | null;
}
// AOC (Attestation of Compliance) chain entry
export interface AocChainEntry {
readonly attestationId: string;
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
readonly hash: string;
readonly timestamp: string;
readonly signer?: string;
readonly parentHash?: string;
}
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
export type VexStatus =
| 'NOT_AFFECTED'
| 'UNDER_INVESTIGATION'
| 'AFFECTED_MITIGATED'
| 'AFFECTED_UNMITIGATED'
| 'FIXED';
export type VexJustificationType =
| 'CODE_NOT_PRESENT'
| 'CODE_NOT_REACHABLE'
| 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH'
| 'CONFIGURATION_NOT_AFFECTED'
| 'OS_NOT_AFFECTED'
| 'RUNTIME_MITIGATION_PRESENT'
| 'COMPENSATING_CONTROLS'
| 'ACCEPTED_BUSINESS_RISK'
| 'OTHER';
export interface VexSubjectRef {
readonly type: 'IMAGE' | 'REPO' | 'SBOM_COMPONENT' | 'OTHER';
readonly name: string;
readonly digest: Record<string, string>;
readonly sbomNodeId?: string;
}
export interface VexEvidenceRef {
readonly type: 'PR' | 'TICKET' | 'DOC' | 'COMMIT' | 'OTHER';
readonly title?: string;
readonly url: string;
}
export interface VexScope {
readonly environments?: readonly string[];
readonly projects?: readonly string[];
}
export interface VexValidFor {
readonly notBefore?: string;
readonly notAfter?: string;
}
export interface VexActorRef {
readonly id: string;
readonly displayName: string;
}
/**
* Signature metadata for signed VEX decisions.
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui (FE-RISK-005)
*/
export interface VexDecisionSignatureInfo {
/** Whether the decision is cryptographically signed */
readonly isSigned: boolean;
/** DSSE envelope digest (base64-encoded) */
readonly dsseDigest?: string;
/** Signature algorithm used (e.g., 'ecdsa-p256', 'rsa-sha256') */
readonly signatureAlgorithm?: string;
/** Key ID used for signing */
readonly signingKeyId?: string;
/** Signer identity (e.g., email, OIDC subject) */
readonly signerIdentity?: string;
/** Timestamp when signed (ISO-8601) */
readonly signedAt?: string;
/** Signature verification status */
readonly verificationStatus?: 'verified' | 'failed' | 'pending' | 'unknown';
/** Rekor transparency log entry if logged */
readonly rekorEntry?: VexRekorEntry;
}
/**
* Rekor transparency log entry for VEX decisions.
*/
export interface VexRekorEntry {
/** Rekor log index */
readonly logIndex: number;
/** Rekor log ID (tree hash) */
readonly logId?: string;
/** Entry UUID in Rekor */
readonly entryUuid?: string;
/** Time integrated into the log (ISO-8601) */
readonly integratedTime?: string;
/** URL to view/verify the entry */
readonly verifyUrl?: string;
}
export interface VexDecision {
readonly id: string;
readonly vulnerabilityId: string;
readonly subject: VexSubjectRef;
readonly status: VexStatus;
readonly justificationType: VexJustificationType;
readonly justificationText?: string;
readonly evidenceRefs?: readonly VexEvidenceRef[];
readonly scope?: VexScope;
readonly validFor?: VexValidFor;
readonly supersedesDecisionId?: string;
readonly createdBy: VexActorRef;
readonly createdAt: string;
readonly updatedAt?: string;
/** Signature metadata for signed decisions (FE-RISK-005) */
readonly signatureInfo?: VexDecisionSignatureInfo;
}
// VEX status summary for UI display
export interface VexStatusSummary {
readonly notAffected: number;
readonly underInvestigation: number;
readonly affectedMitigated: number;
readonly affectedUnmitigated: number;
readonly fixed: number;
readonly total: number;
}
// VEX conflict indicator
export interface VexConflict {
readonly vulnerabilityId: string;
readonly conflictingStatuses: readonly VexStatus[];
readonly decisionIds: readonly string[];
readonly reason: string;
}
// Evidence panel data combining all elements
export interface EvidenceData {
readonly advisoryId: string;
readonly title?: string;
readonly observations: readonly Observation[];
readonly linkset?: Linkset;
readonly policyEvidence?: PolicyEvidence;
readonly vexDecisions?: readonly VexDecision[];
readonly vexConflicts?: readonly VexConflict[];
readonly hasConflicts: boolean;
readonly conflictCount: number;
}
// Source metadata for display
export interface SourceInfo {
readonly sourceId: string;
readonly name: string;
readonly icon?: string;
readonly url?: string;
readonly lastUpdated?: string;
}
// Filter configuration for observations/linksets
export type SeverityBucket = 'critical' | 'high' | 'medium' | 'low' | 'all';
export interface ObservationFilters {
readonly sources: readonly string[]; // Filter by source IDs
readonly severityBucket: SeverityBucket; // Filter by severity level
readonly conflictOnly: boolean; // Show only observations with conflicts
readonly hasCvssVector: boolean | null; // null = all, true = has vector, false = no vector
}
export const DEFAULT_OBSERVATION_FILTERS: ObservationFilters = {
sources: [],
severityBucket: 'all',
conflictOnly: false,
hasCvssVector: null,
};
// Pagination configuration
export interface PaginationState {
readonly pageSize: number;
readonly currentPage: number;
readonly totalItems: number;
}
export const DEFAULT_PAGE_SIZE = 10;
export const SOURCE_INFO: Record<string, SourceInfo> = {
ghsa: {
sourceId: 'ghsa',
name: 'GitHub Security Advisories',
icon: 'github',
url: 'https://github.com/advisories',
},
nvd: {
sourceId: 'nvd',
name: 'National Vulnerability Database',
icon: 'database',
url: 'https://nvd.nist.gov',
},
'cert-bund': {
sourceId: 'cert-bund',
name: 'CERT-Bund',
icon: 'shield',
url: 'https://www.cert-bund.de',
},
osv: {
sourceId: 'osv',
name: 'Open Source Vulnerabilities',
icon: 'box',
url: 'https://osv.dev',
},
cve: {
sourceId: 'cve',
name: 'CVE Program',
icon: 'alert-triangle',
url: 'https://cve.mitre.org',
},
};

View File

@@ -56,18 +56,18 @@ export class ExceptionApiHttpClient implements ExceptionApi {
if (options?.severity) {
params = params.set('severity', options.severity);
}
if (options?.search) {
params = params.set('search', options.search);
}
if (options?.sortBy) {
params = params.set('sortBy', options.sortBy);
}
if (options?.sortOrder) {
params = params.set('sortOrder', options.sortOrder);
}
if (options?.limit) {
params = params.set('limit', options.limit.toString());
}
if (options?.search) {
params = params.set('search', options.search);
}
if (options?.sortBy) {
params = params.set('sortBy', options.sortBy);
}
if (options?.sortOrder) {
params = params.set('sortOrder', options.sortOrder);
}
if (options?.limit) {
params = params.set('limit', options.limit.toString());
}
if (options?.continuationToken) {
params = params.set('continuationToken', options.continuationToken);
}
@@ -190,198 +190,198 @@ export class ExceptionApiHttpClient implements ExceptionApi {
return new HttpHeaders(headers);
}
}
/**
* Mock implementation for development and testing.
*/
@Injectable({ providedIn: 'root' })
/**
* Mock implementation for development and testing.
*/
@Injectable({ providedIn: 'root' })
export class MockExceptionApiService implements ExceptionApi {
private readonly mockExceptions: Exception[] = [
{
schemaVersion: '1.0',
exceptionId: 'exc-001',
tenantId: 'tenant-dev',
name: 'log4j-temporary-exception',
displayName: 'Log4j Temporary Exception',
description: 'Temporary exception for legacy Log4j usage in internal tooling',
status: 'approved',
severity: 'high',
scope: {
type: 'component',
componentPurls: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'],
vulnIds: ['CVE-2021-44228'],
},
justification: {
template: 'internal-only',
text: 'Internal-only service with no external exposure. Migration planned for Q1.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-03-31T23:59:59Z',
autoRenew: false,
},
approvals: [
{
approvalId: 'apr-001',
approvedBy: 'security-lead@example.com',
approvedAt: '2024-12-15T10:30:00Z',
comment: 'Approved with condition: migrate before Q2',
},
],
labels: { team: 'platform', priority: 'P2' },
createdBy: 'dev@example.com',
createdAt: '2024-12-10T09:00:00Z',
updatedBy: 'security-lead@example.com',
updatedAt: '2024-12-15T10:30:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-002',
tenantId: 'tenant-dev',
name: 'openssl-vuln-exception',
displayName: 'OpenSSL Vulnerability Exception',
status: 'pending_review',
severity: 'critical',
scope: {
type: 'asset',
assetIds: ['asset-nginx-prod'],
vulnIds: ['CVE-2024-0001'],
},
justification: {
template: 'compensating-control',
text: 'Compensating controls in place: WAF rules, network segmentation, monitoring.',
},
timebox: {
startDate: '2025-01-15T00:00:00Z',
endDate: '2025-02-15T23:59:59Z',
},
labels: { team: 'infrastructure' },
createdBy: 'ops@example.com',
createdAt: '2025-01-10T14:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-003',
tenantId: 'tenant-dev',
name: 'legacy-crypto-exception',
displayName: 'Legacy Crypto Library',
status: 'draft',
severity: 'medium',
scope: {
type: 'tenant',
tenantId: 'tenant-dev',
},
justification: {
text: 'Migration in progress. ETA: 2 weeks.',
},
timebox: {
startDate: '2025-01-20T00:00:00Z',
endDate: '2025-02-20T23:59:59Z',
},
createdBy: 'dev@example.com',
createdAt: '2025-01-18T11:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-004',
tenantId: 'tenant-dev',
name: 'expired-cert-exception',
displayName: 'Expired Certificate Exception',
status: 'expired',
severity: 'low',
scope: {
type: 'asset',
assetIds: ['asset-test-env'],
},
justification: {
text: 'Test environment only, not production facing.',
},
timebox: {
startDate: '2024-10-01T00:00:00Z',
endDate: '2024-12-31T23:59:59Z',
},
createdBy: 'qa@example.com',
createdAt: '2024-09-25T08:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-005',
tenantId: 'tenant-dev',
name: 'rejected-exception',
displayName: 'Rejected Risk Exception',
status: 'rejected',
severity: 'critical',
scope: {
type: 'global',
},
justification: {
text: 'Blanket exception for all critical vulns.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-12-31T23:59:59Z',
},
createdBy: 'dev@example.com',
createdAt: '2024-12-20T16:00:00Z',
},
];
private readonly mockExceptions: Exception[] = [
{
schemaVersion: '1.0',
exceptionId: 'exc-001',
tenantId: 'tenant-dev',
name: 'log4j-temporary-exception',
displayName: 'Log4j Temporary Exception',
description: 'Temporary exception for legacy Log4j usage in internal tooling',
status: 'approved',
severity: 'high',
scope: {
type: 'component',
componentPurls: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'],
vulnIds: ['CVE-2021-44228'],
},
justification: {
template: 'internal-only',
text: 'Internal-only service with no external exposure. Migration planned for Q1.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-03-31T23:59:59Z',
autoRenew: false,
},
approvals: [
{
approvalId: 'apr-001',
approvedBy: 'security-lead@example.com',
approvedAt: '2024-12-15T10:30:00Z',
comment: 'Approved with condition: migrate before Q2',
},
],
labels: { team: 'platform', priority: 'P2' },
createdBy: 'dev@example.com',
createdAt: '2024-12-10T09:00:00Z',
updatedBy: 'security-lead@example.com',
updatedAt: '2024-12-15T10:30:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-002',
tenantId: 'tenant-dev',
name: 'openssl-vuln-exception',
displayName: 'OpenSSL Vulnerability Exception',
status: 'pending_review',
severity: 'critical',
scope: {
type: 'asset',
assetIds: ['asset-nginx-prod'],
vulnIds: ['CVE-2024-0001'],
},
justification: {
template: 'compensating-control',
text: 'Compensating controls in place: WAF rules, network segmentation, monitoring.',
},
timebox: {
startDate: '2025-01-15T00:00:00Z',
endDate: '2025-02-15T23:59:59Z',
},
labels: { team: 'infrastructure' },
createdBy: 'ops@example.com',
createdAt: '2025-01-10T14:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-003',
tenantId: 'tenant-dev',
name: 'legacy-crypto-exception',
displayName: 'Legacy Crypto Library',
status: 'draft',
severity: 'medium',
scope: {
type: 'tenant',
tenantId: 'tenant-dev',
},
justification: {
text: 'Migration in progress. ETA: 2 weeks.',
},
timebox: {
startDate: '2025-01-20T00:00:00Z',
endDate: '2025-02-20T23:59:59Z',
},
createdBy: 'dev@example.com',
createdAt: '2025-01-18T11:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-004',
tenantId: 'tenant-dev',
name: 'expired-cert-exception',
displayName: 'Expired Certificate Exception',
status: 'expired',
severity: 'low',
scope: {
type: 'asset',
assetIds: ['asset-test-env'],
},
justification: {
text: 'Test environment only, not production facing.',
},
timebox: {
startDate: '2024-10-01T00:00:00Z',
endDate: '2024-12-31T23:59:59Z',
},
createdBy: 'qa@example.com',
createdAt: '2024-09-25T08:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-005',
tenantId: 'tenant-dev',
name: 'rejected-exception',
displayName: 'Rejected Risk Exception',
status: 'rejected',
severity: 'critical',
scope: {
type: 'global',
},
justification: {
text: 'Blanket exception for all critical vulns.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-12-31T23:59:59Z',
},
createdBy: 'dev@example.com',
createdAt: '2024-12-20T16:00:00Z',
},
];
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
let filtered = [...this.mockExceptions];
if (options?.status) {
filtered = filtered.filter((e) => e.status === options.status);
}
if (options?.severity) {
filtered = filtered.filter((e) => e.severity === options.severity);
}
if (options?.search) {
const searchLower = options.search.toLowerCase();
filtered = filtered.filter(
(e) =>
e.name.toLowerCase().includes(searchLower) ||
e.displayName?.toLowerCase().includes(searchLower) ||
e.description?.toLowerCase().includes(searchLower)
);
}
const sortBy = options?.sortBy ?? 'createdAt';
const sortOrder = options?.sortOrder ?? 'desc';
filtered.sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'severity':
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
comparison = severityOrder[a.severity] - severityOrder[b.severity];
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
case 'updatedAt':
comparison = (a.updatedAt ?? a.createdAt).localeCompare(b.updatedAt ?? b.createdAt);
break;
default:
comparison = a.createdAt.localeCompare(b.createdAt);
}
return sortOrder === 'asc' ? comparison : -comparison;
});
const limit = options?.limit ?? 20;
const items = filtered.slice(0, limit);
return new Observable((subscriber) => {
setTimeout(() => {
subscriber.next({
items,
count: filtered.length,
continuationToken: filtered.length > limit ? 'next-page-token' : null,
});
subscriber.complete();
}, 300);
});
let filtered = [...this.mockExceptions];
if (options?.status) {
filtered = filtered.filter((e) => e.status === options.status);
}
if (options?.severity) {
filtered = filtered.filter((e) => e.severity === options.severity);
}
if (options?.search) {
const searchLower = options.search.toLowerCase();
filtered = filtered.filter(
(e) =>
e.name.toLowerCase().includes(searchLower) ||
e.displayName?.toLowerCase().includes(searchLower) ||
e.description?.toLowerCase().includes(searchLower)
);
}
const sortBy = options?.sortBy ?? 'createdAt';
const sortOrder = options?.sortOrder ?? 'desc';
filtered.sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'severity':
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
comparison = severityOrder[a.severity] - severityOrder[b.severity];
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
case 'updatedAt':
comparison = (a.updatedAt ?? a.createdAt).localeCompare(b.updatedAt ?? b.createdAt);
break;
default:
comparison = a.createdAt.localeCompare(b.createdAt);
}
return sortOrder === 'asc' ? comparison : -comparison;
});
const limit = options?.limit ?? 20;
const items = filtered.slice(0, limit);
return new Observable((subscriber) => {
setTimeout(() => {
subscriber.next({
items,
count: filtered.length,
continuationToken: filtered.length > limit ? 'next-page-token' : null,
});
subscriber.complete();
}, 300);
});
}
getException(exceptionId: string): Observable<Exception> {
@@ -390,11 +390,11 @@ export class MockExceptionApiService implements ExceptionApi {
setTimeout(() => {
if (exception) {
subscriber.next(exception);
} else {
subscriber.error(new Error(`Exception ${exceptionId} not found`));
}
subscriber.complete();
}, 100);
} else {
subscriber.error(new Error(`Exception ${exceptionId} not found`));
}
subscriber.complete();
}, 100);
});
}
@@ -404,26 +404,26 @@ export class MockExceptionApiService implements ExceptionApi {
schemaVersion: '1.0',
exceptionId: `exc-${Math.random().toString(36).slice(2, 10)}`,
tenantId: 'tenant-dev',
name: exception.name ?? 'new-exception',
status: 'draft',
severity: exception.severity ?? 'medium',
scope: exception.scope ?? { type: 'tenant' },
justification: exception.justification ?? { text: '' },
timebox: exception.timebox ?? {
startDate: new Date().toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
},
createdBy: 'ui@stella-ops.local',
createdAt: new Date().toISOString(),
...exception,
} as Exception;
this.mockExceptions.push(newException);
setTimeout(() => {
subscriber.next(newException);
subscriber.complete();
}, 200);
name: exception.name ?? 'new-exception',
status: 'draft',
severity: exception.severity ?? 'medium',
scope: exception.scope ?? { type: 'tenant' },
justification: exception.justification ?? { text: '' },
timebox: exception.timebox ?? {
startDate: new Date().toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
},
createdBy: 'ui@stella-ops.local',
createdAt: new Date().toISOString(),
...exception,
} as Exception;
this.mockExceptions.push(newException);
setTimeout(() => {
subscriber.next(newException);
subscriber.complete();
}, 200);
});
}
@@ -433,20 +433,20 @@ export class MockExceptionApiService implements ExceptionApi {
if (index === -1) {
subscriber.error(new Error(`Exception ${exceptionId} not found`));
return;
}
const updated = {
...this.mockExceptions[index],
...updates,
updatedAt: new Date().toISOString(),
updatedBy: 'ui@stella-ops.local',
};
this.mockExceptions[index] = updated;
setTimeout(() => {
subscriber.next(updated);
subscriber.complete();
}, 200);
}
const updated = {
...this.mockExceptions[index],
...updates,
updatedAt: new Date().toISOString(),
updatedBy: 'ui@stella-ops.local',
};
this.mockExceptions[index] = updated;
setTimeout(() => {
subscriber.next(updated);
subscriber.complete();
}, 200);
});
}
@@ -456,51 +456,51 @@ export class MockExceptionApiService implements ExceptionApi {
if (index !== -1) {
this.mockExceptions.splice(index, 1);
}
setTimeout(() => {
subscriber.next();
subscriber.complete();
}, 200);
setTimeout(() => {
subscriber.next();
subscriber.complete();
}, 200);
});
}
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
return this.updateException(transition.exceptionId, {
status: transition.newStatus,
});
}
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
return this.updateException(transition.exceptionId, {
status: transition.newStatus,
});
}
getStats(): Observable<ExceptionStats> {
return new Observable((subscriber) => {
const byStatus: Record<string, number> = {
draft: 0,
pending_review: 0,
approved: 0,
rejected: 0,
expired: 0,
revoked: 0,
};
const bySeverity: Record<string, number> = {
critical: 0,
high: 0,
medium: 0,
low: 0,
};
this.mockExceptions.forEach((e) => {
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1;
});
setTimeout(() => {
subscriber.next({
total: this.mockExceptions.length,
byStatus: byStatus as Record<any, number>,
bySeverity: bySeverity as Record<any, number>,
expiringWithin7Days: 1,
pendingApproval: byStatus['pending_review'],
});
subscriber.complete();
}, 100);
});
}
}
rejected: 0,
expired: 0,
revoked: 0,
};
const bySeverity: Record<string, number> = {
critical: 0,
high: 0,
medium: 0,
low: 0,
};
this.mockExceptions.forEach((e) => {
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1;
});
setTimeout(() => {
subscriber.next({
total: this.mockExceptions.length,
byStatus: byStatus as Record<any, number>,
bySeverity: bySeverity as Record<any, number>,
expiringWithin7Days: 1,
pendingApproval: byStatus['pending_review'],
});
subscriber.complete();
}, 100);
});
}
}

View File

@@ -1,252 +1,252 @@
/**
* Exception management models for the Exception Center.
*/
export type ExceptionStatus =
| 'draft'
| 'pending_review'
| 'approved'
| 'rejected'
| 'expired'
| 'revoked';
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
export interface Exception {
/** Unique exception ID */
id: string;
/** Short title */
title: string;
/** Detailed justification */
justification: string;
/** Exception type */
type: ExceptionType;
/** Current status */
status: ExceptionStatus;
/** Severity being excepted */
severity: 'critical' | 'high' | 'medium' | 'low';
/** Scope definition */
scope: ExceptionScope;
/** Time constraints */
timebox: ExceptionTimebox;
/** Workflow history */
workflow: ExceptionWorkflow;
/** Audit trail */
auditLog: ExceptionAuditEntry[];
/** Associated findings/violations */
findings: string[];
/** Tags for filtering */
tags: string[];
/** Created timestamp */
createdAt: string;
/** Last updated timestamp */
updatedAt: string;
}
export interface ExceptionScope {
/** Affected images (glob patterns allowed) */
images?: string[];
/** Affected CVEs */
cves?: string[];
/** Affected packages */
packages?: string[];
/** Affected licenses */
licenses?: string[];
/** Affected policy rules */
policyRules?: string[];
/** Tenant scope */
tenantId?: string;
/** Environment scope */
environments?: string[];
}
export interface ExceptionTimebox {
/** Start date */
startsAt: string;
/** Expiration date */
expiresAt: string;
/** Remaining days */
remainingDays: number;
/** Is expired */
isExpired: boolean;
/** Warning threshold (days before expiry) */
warnDays: number;
/** Is in warning period */
isWarning: boolean;
}
export interface ExceptionWorkflow {
/** Current workflow state */
state: ExceptionStatus;
/** Requested by */
requestedBy: string;
/** Requested at */
requestedAt: string;
/** Approved by */
approvedBy?: string;
/** Approved at */
approvedAt?: string;
/** Revoked by */
revokedBy?: string;
/** Revoked at */
revokedAt?: string;
/** Revocation reason */
revocationReason?: string;
/** Required approvers */
requiredApprovers: string[];
/** Current approvals */
approvals: ExceptionApproval[];
}
export interface ExceptionApproval {
/** Approver identity */
approver: string;
/** Decision */
decision: 'approved' | 'rejected';
/** Timestamp */
at: string;
/** Optional comment */
comment?: string;
}
export interface ExceptionAuditEntry {
/** Entry ID */
id: string;
/** Action performed */
action: 'created' | 'submitted' | 'approved' | 'rejected' | 'activated' | 'expired' | 'revoked' | 'edited';
/** Actor */
actor: string;
/** Timestamp */
at: string;
/** Details */
details?: string;
/** Previous values (for edits) */
previousValues?: Record<string, unknown>;
/** New values (for edits) */
newValues?: Record<string, unknown>;
}
export interface ExceptionFilter {
status?: ExceptionStatus[];
type?: ExceptionType[];
severity?: string[];
search?: string;
tags?: string[];
expiringSoon?: boolean;
createdAfter?: string;
createdBefore?: string;
}
export interface ExceptionSortOption {
field: 'createdAt' | 'updatedAt' | 'expiresAt' | 'severity' | 'title';
direction: 'asc' | 'desc';
}
export interface ExceptionTransition {
from: ExceptionStatus;
to: ExceptionStatus;
action: string;
requiresApproval: boolean;
allowedRoles: string[];
}
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
{ from: 'draft', to: 'pending_review', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
{ from: 'pending_review', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
{ from: 'pending_review', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
{ from: 'pending_review', to: 'rejected', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
{ from: 'approved', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
];
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
{ status: 'pending_review', label: 'Pending Review', color: '#f59e0b' },
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
{ status: 'rejected', label: 'Rejected', color: '#f472b6' },
{ status: 'expired', label: 'Expired', color: '#6b7280' },
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
];
/**
* Exception ledger entry for timeline display.
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-12
*/
export interface ExceptionLedgerEntry {
/** Entry ID. */
id: string;
/** Exception ID. */
exceptionId: string;
/** Event type. */
eventType: 'created' | 'approved' | 'rejected' | 'expired' | 'revoked' | 'extended' | 'modified';
/** Event timestamp. */
timestamp: string;
/** Actor user ID. */
actorId: string;
/** Actor display name. */
actorName?: string;
/** Event details. */
details?: Record<string, unknown>;
/** Comment. */
comment?: string;
}
/**
* Exception summary for risk budget dashboard.
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-04
*/
export interface ExceptionSummary {
/** Total active exceptions. */
active: number;
/** Pending approval. */
pending: number;
/** Expiring within 7 days. */
expiringSoon: number;
/** Total risk points covered. */
riskPointsCovered: number;
/** Trace ID. */
traceId: string;
}
/**
* Exception management models for the Exception Center.
*/
export type ExceptionStatus =
| 'draft'
| 'pending_review'
| 'approved'
| 'rejected'
| 'expired'
| 'revoked';
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
export interface Exception {
/** Unique exception ID */
id: string;
/** Short title */
title: string;
/** Detailed justification */
justification: string;
/** Exception type */
type: ExceptionType;
/** Current status */
status: ExceptionStatus;
/** Severity being excepted */
severity: 'critical' | 'high' | 'medium' | 'low';
/** Scope definition */
scope: ExceptionScope;
/** Time constraints */
timebox: ExceptionTimebox;
/** Workflow history */
workflow: ExceptionWorkflow;
/** Audit trail */
auditLog: ExceptionAuditEntry[];
/** Associated findings/violations */
findings: string[];
/** Tags for filtering */
tags: string[];
/** Created timestamp */
createdAt: string;
/** Last updated timestamp */
updatedAt: string;
}
export interface ExceptionScope {
/** Affected images (glob patterns allowed) */
images?: string[];
/** Affected CVEs */
cves?: string[];
/** Affected packages */
packages?: string[];
/** Affected licenses */
licenses?: string[];
/** Affected policy rules */
policyRules?: string[];
/** Tenant scope */
tenantId?: string;
/** Environment scope */
environments?: string[];
}
export interface ExceptionTimebox {
/** Start date */
startsAt: string;
/** Expiration date */
expiresAt: string;
/** Remaining days */
remainingDays: number;
/** Is expired */
isExpired: boolean;
/** Warning threshold (days before expiry) */
warnDays: number;
/** Is in warning period */
isWarning: boolean;
}
export interface ExceptionWorkflow {
/** Current workflow state */
state: ExceptionStatus;
/** Requested by */
requestedBy: string;
/** Requested at */
requestedAt: string;
/** Approved by */
approvedBy?: string;
/** Approved at */
approvedAt?: string;
/** Revoked by */
revokedBy?: string;
/** Revoked at */
revokedAt?: string;
/** Revocation reason */
revocationReason?: string;
/** Required approvers */
requiredApprovers: string[];
/** Current approvals */
approvals: ExceptionApproval[];
}
export interface ExceptionApproval {
/** Approver identity */
approver: string;
/** Decision */
decision: 'approved' | 'rejected';
/** Timestamp */
at: string;
/** Optional comment */
comment?: string;
}
export interface ExceptionAuditEntry {
/** Entry ID */
id: string;
/** Action performed */
action: 'created' | 'submitted' | 'approved' | 'rejected' | 'activated' | 'expired' | 'revoked' | 'edited';
/** Actor */
actor: string;
/** Timestamp */
at: string;
/** Details */
details?: string;
/** Previous values (for edits) */
previousValues?: Record<string, unknown>;
/** New values (for edits) */
newValues?: Record<string, unknown>;
}
export interface ExceptionFilter {
status?: ExceptionStatus[];
type?: ExceptionType[];
severity?: string[];
search?: string;
tags?: string[];
expiringSoon?: boolean;
createdAfter?: string;
createdBefore?: string;
}
export interface ExceptionSortOption {
field: 'createdAt' | 'updatedAt' | 'expiresAt' | 'severity' | 'title';
direction: 'asc' | 'desc';
}
export interface ExceptionTransition {
from: ExceptionStatus;
to: ExceptionStatus;
action: string;
requiresApproval: boolean;
allowedRoles: string[];
}
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
{ from: 'draft', to: 'pending_review', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
{ from: 'pending_review', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
{ from: 'pending_review', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
{ from: 'pending_review', to: 'rejected', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
{ from: 'approved', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
];
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
{ status: 'pending_review', label: 'Pending Review', color: '#f59e0b' },
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
{ status: 'rejected', label: 'Rejected', color: '#f472b6' },
{ status: 'expired', label: 'Expired', color: '#6b7280' },
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
];
/**
* Exception ledger entry for timeline display.
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-12
*/
export interface ExceptionLedgerEntry {
/** Entry ID. */
id: string;
/** Exception ID. */
exceptionId: string;
/** Event type. */
eventType: 'created' | 'approved' | 'rejected' | 'expired' | 'revoked' | 'extended' | 'modified';
/** Event timestamp. */
timestamp: string;
/** Actor user ID. */
actorId: string;
/** Actor display name. */
actorName?: string;
/** Event details. */
details?: Record<string, unknown>;
/** Comment. */
comment?: string;
}
/**
* Exception summary for risk budget dashboard.
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-04
*/
export interface ExceptionSummary {
/** Total active exceptions. */
active: number;
/** Pending approval. */
pending: number;
/** Expiring within 7 days. */
expiringSoon: number;
/** Total risk points covered. */
riskPointsCovered: number;
/** Trace ID. */
traceId: string;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,419 +1,419 @@
export type NotifyChannelType =
| 'Slack'
| 'Teams'
| 'Email'
| 'Webhook'
| 'Custom';
export type ChannelHealthStatus = 'Healthy' | 'Degraded' | 'Unhealthy';
export type NotifyDeliveryStatus =
| 'Pending'
| 'Sent'
| 'Failed'
| 'Throttled'
| 'Digested'
| 'Dropped';
export type NotifyDeliveryAttemptStatus =
| 'Enqueued'
| 'Sending'
| 'Succeeded'
| 'Failed'
| 'Throttled'
| 'Skipped';
export type NotifyDeliveryFormat =
| 'Slack'
| 'Teams'
| 'Email'
| 'Webhook'
| 'Json';
export interface NotifyChannelLimits {
readonly concurrency?: number | null;
readonly requestsPerMinute?: number | null;
readonly timeout?: string | null;
readonly maxBatchSize?: number | null;
}
export interface NotifyChannelConfig {
readonly secretRef: string;
readonly target?: string;
readonly endpoint?: string;
readonly properties?: Record<string, string>;
readonly limits?: NotifyChannelLimits | null;
}
export interface NotifyChannel {
readonly schemaVersion?: string;
readonly channelId: string;
readonly tenantId: string;
readonly name: string;
readonly displayName?: string;
readonly description?: string;
readonly type: NotifyChannelType;
readonly enabled: boolean;
readonly config: NotifyChannelConfig;
readonly labels?: Record<string, string>;
readonly metadata?: Record<string, string>;
readonly createdBy?: string;
readonly createdAt?: string;
readonly updatedBy?: string;
readonly updatedAt?: string;
}
export interface NotifyRuleMatchVex {
readonly includeAcceptedJustifications?: boolean;
readonly includeRejectedJustifications?: boolean;
readonly includeUnknownJustifications?: boolean;
readonly justificationKinds?: readonly string[];
}
export interface NotifyRuleMatch {
readonly eventKinds?: readonly string[];
readonly namespaces?: readonly string[];
readonly repositories?: readonly string[];
readonly digests?: readonly string[];
readonly labels?: readonly string[];
readonly componentPurls?: readonly string[];
readonly minSeverity?: string | null;
readonly verdicts?: readonly string[];
readonly kevOnly?: boolean | null;
readonly vex?: NotifyRuleMatchVex | null;
}
export interface NotifyRuleAction {
readonly actionId: string;
readonly channel: string;
readonly template?: string;
readonly digest?: string;
readonly throttle?: string | null;
readonly locale?: string;
readonly enabled: boolean;
readonly metadata?: Record<string, string>;
}
export interface NotifyRule {
readonly schemaVersion?: string;
readonly ruleId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly enabled: boolean;
readonly match: NotifyRuleMatch;
readonly actions: readonly NotifyRuleAction[];
readonly labels?: Record<string, string>;
readonly metadata?: Record<string, string>;
readonly createdBy?: string;
readonly createdAt?: string;
readonly updatedBy?: string;
readonly updatedAt?: string;
}
export interface NotifyDeliveryAttempt {
readonly timestamp: string;
readonly status: NotifyDeliveryAttemptStatus;
readonly statusCode?: number;
readonly reason?: string;
}
export interface NotifyDeliveryRendered {
readonly channelType: NotifyChannelType;
readonly format: NotifyDeliveryFormat;
readonly target: string;
readonly title: string;
readonly body: string;
readonly summary?: string;
readonly textBody?: string;
readonly locale?: string;
readonly bodyHash?: string;
readonly attachments?: readonly string[];
}
export interface NotifyDelivery {
readonly deliveryId: string;
readonly tenantId: string;
readonly ruleId: string;
readonly actionId: string;
readonly eventId: string;
readonly kind: string;
readonly status: NotifyDeliveryStatus;
readonly statusReason?: string;
readonly rendered?: NotifyDeliveryRendered;
readonly attempts?: readonly NotifyDeliveryAttempt[];
readonly metadata?: Record<string, string>;
readonly createdAt: string;
readonly sentAt?: string;
readonly completedAt?: string;
}
export interface NotifyDeliveriesQueryOptions {
readonly status?: NotifyDeliveryStatus;
readonly since?: string;
readonly limit?: number;
readonly continuationToken?: string;
}
export interface NotifyDeliveriesResponse {
readonly items: readonly NotifyDelivery[];
readonly continuationToken?: string | null;
readonly count: number;
}
export interface ChannelHealthResponse {
readonly tenantId: string;
readonly channelId: string;
readonly status: ChannelHealthStatus;
readonly message?: string | null;
readonly checkedAt: string;
readonly traceId: string;
readonly metadata?: Record<string, string>;
}
export interface ChannelTestSendRequest {
readonly target?: string;
readonly templateId?: string;
readonly title?: string;
readonly summary?: string;
readonly body?: string;
readonly textBody?: string;
readonly locale?: string;
readonly metadata?: Record<string, string>;
readonly attachments?: readonly string[];
}
export interface ChannelTestSendResponse {
readonly tenantId: string;
readonly channelId: string;
readonly preview: NotifyDeliveryRendered;
readonly queuedAt: string;
readonly traceId: string;
readonly metadata?: Record<string, string>;
}
/**
* WEB-NOTIFY-39-001: Digest scheduling, quiet-hours, throttle management.
*/
/** Digest frequency. */
export type DigestFrequency = 'instant' | 'hourly' | 'daily' | 'weekly';
/** Digest schedule. */
export interface DigestSchedule {
readonly scheduleId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly frequency: DigestFrequency;
readonly timezone: string;
readonly hour?: number;
readonly dayOfWeek?: number;
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Digest schedules response. */
export interface DigestSchedulesResponse {
readonly items: readonly DigestSchedule[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Quiet hour window. */
export interface QuietHourWindow {
readonly timezone: string;
readonly days: readonly string[];
readonly start: string;
readonly end: string;
}
/** Quiet hour exemption. */
export interface QuietHourExemption {
readonly eventKinds: readonly string[];
readonly reason: string;
}
/** Quiet hours configuration. */
export interface QuietHours {
readonly quietHoursId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly windows: readonly QuietHourWindow[];
readonly exemptions?: readonly QuietHourExemption[];
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Quiet hours response. */
export interface QuietHoursResponse {
readonly items: readonly QuietHours[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Throttle configuration. */
export interface ThrottleConfig {
readonly throttleId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly windowSeconds: number;
readonly maxEvents: number;
readonly burstLimit?: number;
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Throttle configs response. */
export interface ThrottleConfigsResponse {
readonly items: readonly ThrottleConfig[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Simulation request. */
export interface NotifySimulationRequest {
readonly eventKind: string;
readonly payload: Record<string, unknown>;
readonly targetChannels?: readonly string[];
readonly dryRun: boolean;
}
/** Simulation result. */
export interface NotifySimulationResult {
readonly simulationId: string;
readonly matchedRules: readonly string[];
readonly wouldNotify: readonly {
readonly channelId: string;
readonly actionId: string;
readonly template: string;
readonly digest: DigestFrequency;
}[];
readonly throttled: boolean;
readonly quietHoursActive: boolean;
readonly traceId?: string;
}
/**
* WEB-NOTIFY-40-001: Escalation, localization, channel health, ack verification.
*/
/** Escalation policy. */
export interface EscalationPolicy {
readonly policyId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly levels: readonly EscalationLevel[];
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Escalation level. */
export interface EscalationLevel {
readonly level: number;
readonly delayMinutes: number;
readonly channels: readonly string[];
readonly notifyOnAck: boolean;
}
/** Escalation policies response. */
export interface EscalationPoliciesResponse {
readonly items: readonly EscalationPolicy[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Localization config. */
export interface LocalizationConfig {
readonly localeId: string;
readonly tenantId: string;
readonly locale: string;
readonly name: string;
readonly templates: Record<string, string>;
readonly dateFormat?: string;
readonly timeFormat?: string;
readonly timezone?: string;
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Localization configs response. */
export interface LocalizationConfigsResponse {
readonly items: readonly LocalizationConfig[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Incident for acknowledgment. */
export interface NotifyIncident {
readonly incidentId: string;
readonly tenantId: string;
readonly title: string;
readonly severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
readonly status: 'open' | 'acknowledged' | 'resolved' | 'closed';
readonly eventIds: readonly string[];
readonly escalationLevel?: number;
readonly escalationPolicyId?: string;
readonly assignee?: string;
readonly acknowledgedAt?: string;
readonly acknowledgedBy?: string;
readonly resolvedAt?: string;
readonly resolvedBy?: string;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Incidents response. */
export interface NotifyIncidentsResponse {
readonly items: readonly NotifyIncident[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Acknowledgment request. */
export interface AckRequest {
readonly ackToken: string;
readonly note?: string;
}
/** Acknowledgment response. */
export interface AckResponse {
readonly incidentId: string;
readonly acknowledged: boolean;
readonly acknowledgedAt: string;
readonly acknowledgedBy: string;
readonly traceId?: string;
}
/** Notify query options. */
export interface NotifyQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly traceId?: string;
}
/** Notify error codes. */
export type NotifyErrorCode =
| 'ERR_NOTIFY_CHANNEL_NOT_FOUND'
| 'ERR_NOTIFY_RULE_NOT_FOUND'
| 'ERR_NOTIFY_INVALID_CONFIG'
| 'ERR_NOTIFY_RATE_LIMIT'
| 'ERR_NOTIFY_ACK_INVALID'
| 'ERR_NOTIFY_ACK_EXPIRED';
export type NotifyChannelType =
| 'Slack'
| 'Teams'
| 'Email'
| 'Webhook'
| 'Custom';
export type ChannelHealthStatus = 'Healthy' | 'Degraded' | 'Unhealthy';
export type NotifyDeliveryStatus =
| 'Pending'
| 'Sent'
| 'Failed'
| 'Throttled'
| 'Digested'
| 'Dropped';
export type NotifyDeliveryAttemptStatus =
| 'Enqueued'
| 'Sending'
| 'Succeeded'
| 'Failed'
| 'Throttled'
| 'Skipped';
export type NotifyDeliveryFormat =
| 'Slack'
| 'Teams'
| 'Email'
| 'Webhook'
| 'Json';
export interface NotifyChannelLimits {
readonly concurrency?: number | null;
readonly requestsPerMinute?: number | null;
readonly timeout?: string | null;
readonly maxBatchSize?: number | null;
}
export interface NotifyChannelConfig {
readonly secretRef: string;
readonly target?: string;
readonly endpoint?: string;
readonly properties?: Record<string, string>;
readonly limits?: NotifyChannelLimits | null;
}
export interface NotifyChannel {
readonly schemaVersion?: string;
readonly channelId: string;
readonly tenantId: string;
readonly name: string;
readonly displayName?: string;
readonly description?: string;
readonly type: NotifyChannelType;
readonly enabled: boolean;
readonly config: NotifyChannelConfig;
readonly labels?: Record<string, string>;
readonly metadata?: Record<string, string>;
readonly createdBy?: string;
readonly createdAt?: string;
readonly updatedBy?: string;
readonly updatedAt?: string;
}
export interface NotifyRuleMatchVex {
readonly includeAcceptedJustifications?: boolean;
readonly includeRejectedJustifications?: boolean;
readonly includeUnknownJustifications?: boolean;
readonly justificationKinds?: readonly string[];
}
export interface NotifyRuleMatch {
readonly eventKinds?: readonly string[];
readonly namespaces?: readonly string[];
readonly repositories?: readonly string[];
readonly digests?: readonly string[];
readonly labels?: readonly string[];
readonly componentPurls?: readonly string[];
readonly minSeverity?: string | null;
readonly verdicts?: readonly string[];
readonly kevOnly?: boolean | null;
readonly vex?: NotifyRuleMatchVex | null;
}
export interface NotifyRuleAction {
readonly actionId: string;
readonly channel: string;
readonly template?: string;
readonly digest?: string;
readonly throttle?: string | null;
readonly locale?: string;
readonly enabled: boolean;
readonly metadata?: Record<string, string>;
}
export interface NotifyRule {
readonly schemaVersion?: string;
readonly ruleId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly enabled: boolean;
readonly match: NotifyRuleMatch;
readonly actions: readonly NotifyRuleAction[];
readonly labels?: Record<string, string>;
readonly metadata?: Record<string, string>;
readonly createdBy?: string;
readonly createdAt?: string;
readonly updatedBy?: string;
readonly updatedAt?: string;
}
export interface NotifyDeliveryAttempt {
readonly timestamp: string;
readonly status: NotifyDeliveryAttemptStatus;
readonly statusCode?: number;
readonly reason?: string;
}
export interface NotifyDeliveryRendered {
readonly channelType: NotifyChannelType;
readonly format: NotifyDeliveryFormat;
readonly target: string;
readonly title: string;
readonly body: string;
readonly summary?: string;
readonly textBody?: string;
readonly locale?: string;
readonly bodyHash?: string;
readonly attachments?: readonly string[];
}
export interface NotifyDelivery {
readonly deliveryId: string;
readonly tenantId: string;
readonly ruleId: string;
readonly actionId: string;
readonly eventId: string;
readonly kind: string;
readonly status: NotifyDeliveryStatus;
readonly statusReason?: string;
readonly rendered?: NotifyDeliveryRendered;
readonly attempts?: readonly NotifyDeliveryAttempt[];
readonly metadata?: Record<string, string>;
readonly createdAt: string;
readonly sentAt?: string;
readonly completedAt?: string;
}
export interface NotifyDeliveriesQueryOptions {
readonly status?: NotifyDeliveryStatus;
readonly since?: string;
readonly limit?: number;
readonly continuationToken?: string;
}
export interface NotifyDeliveriesResponse {
readonly items: readonly NotifyDelivery[];
readonly continuationToken?: string | null;
readonly count: number;
}
export interface ChannelHealthResponse {
readonly tenantId: string;
readonly channelId: string;
readonly status: ChannelHealthStatus;
readonly message?: string | null;
readonly checkedAt: string;
readonly traceId: string;
readonly metadata?: Record<string, string>;
}
export interface ChannelTestSendRequest {
readonly target?: string;
readonly templateId?: string;
readonly title?: string;
readonly summary?: string;
readonly body?: string;
readonly textBody?: string;
readonly locale?: string;
readonly metadata?: Record<string, string>;
readonly attachments?: readonly string[];
}
export interface ChannelTestSendResponse {
readonly tenantId: string;
readonly channelId: string;
readonly preview: NotifyDeliveryRendered;
readonly queuedAt: string;
readonly traceId: string;
readonly metadata?: Record<string, string>;
}
/**
* WEB-NOTIFY-39-001: Digest scheduling, quiet-hours, throttle management.
*/
/** Digest frequency. */
export type DigestFrequency = 'instant' | 'hourly' | 'daily' | 'weekly';
/** Digest schedule. */
export interface DigestSchedule {
readonly scheduleId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly frequency: DigestFrequency;
readonly timezone: string;
readonly hour?: number;
readonly dayOfWeek?: number;
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Digest schedules response. */
export interface DigestSchedulesResponse {
readonly items: readonly DigestSchedule[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Quiet hour window. */
export interface QuietHourWindow {
readonly timezone: string;
readonly days: readonly string[];
readonly start: string;
readonly end: string;
}
/** Quiet hour exemption. */
export interface QuietHourExemption {
readonly eventKinds: readonly string[];
readonly reason: string;
}
/** Quiet hours configuration. */
export interface QuietHours {
readonly quietHoursId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly windows: readonly QuietHourWindow[];
readonly exemptions?: readonly QuietHourExemption[];
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Quiet hours response. */
export interface QuietHoursResponse {
readonly items: readonly QuietHours[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Throttle configuration. */
export interface ThrottleConfig {
readonly throttleId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly windowSeconds: number;
readonly maxEvents: number;
readonly burstLimit?: number;
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Throttle configs response. */
export interface ThrottleConfigsResponse {
readonly items: readonly ThrottleConfig[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Simulation request. */
export interface NotifySimulationRequest {
readonly eventKind: string;
readonly payload: Record<string, unknown>;
readonly targetChannels?: readonly string[];
readonly dryRun: boolean;
}
/** Simulation result. */
export interface NotifySimulationResult {
readonly simulationId: string;
readonly matchedRules: readonly string[];
readonly wouldNotify: readonly {
readonly channelId: string;
readonly actionId: string;
readonly template: string;
readonly digest: DigestFrequency;
}[];
readonly throttled: boolean;
readonly quietHoursActive: boolean;
readonly traceId?: string;
}
/**
* WEB-NOTIFY-40-001: Escalation, localization, channel health, ack verification.
*/
/** Escalation policy. */
export interface EscalationPolicy {
readonly policyId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly levels: readonly EscalationLevel[];
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Escalation level. */
export interface EscalationLevel {
readonly level: number;
readonly delayMinutes: number;
readonly channels: readonly string[];
readonly notifyOnAck: boolean;
}
/** Escalation policies response. */
export interface EscalationPoliciesResponse {
readonly items: readonly EscalationPolicy[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Localization config. */
export interface LocalizationConfig {
readonly localeId: string;
readonly tenantId: string;
readonly locale: string;
readonly name: string;
readonly templates: Record<string, string>;
readonly dateFormat?: string;
readonly timeFormat?: string;
readonly timezone?: string;
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Localization configs response. */
export interface LocalizationConfigsResponse {
readonly items: readonly LocalizationConfig[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Incident for acknowledgment. */
export interface NotifyIncident {
readonly incidentId: string;
readonly tenantId: string;
readonly title: string;
readonly severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
readonly status: 'open' | 'acknowledged' | 'resolved' | 'closed';
readonly eventIds: readonly string[];
readonly escalationLevel?: number;
readonly escalationPolicyId?: string;
readonly assignee?: string;
readonly acknowledgedAt?: string;
readonly acknowledgedBy?: string;
readonly resolvedAt?: string;
readonly resolvedBy?: string;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Incidents response. */
export interface NotifyIncidentsResponse {
readonly items: readonly NotifyIncident[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Acknowledgment request. */
export interface AckRequest {
readonly ackToken: string;
readonly note?: string;
}
/** Acknowledgment response. */
export interface AckResponse {
readonly incidentId: string;
readonly acknowledged: boolean;
readonly acknowledgedAt: string;
readonly acknowledgedBy: string;
readonly traceId?: string;
}
/** Notify query options. */
export interface NotifyQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly traceId?: string;
}
/** Notify error codes. */
export type NotifyErrorCode =
| 'ERR_NOTIFY_CHANNEL_NOT_FOUND'
| 'ERR_NOTIFY_RULE_NOT_FOUND'
| 'ERR_NOTIFY_INVALID_CONFIG'
| 'ERR_NOTIFY_RATE_LIMIT'
| 'ERR_NOTIFY_ACK_INVALID'
| 'ERR_NOTIFY_ACK_EXPIRED';

View File

@@ -1,128 +1,128 @@
export interface PolicyPreviewRequestDto {
imageDigest: string;
findings: ReadonlyArray<PolicyPreviewFindingDto>;
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
policy?: PolicyPreviewPolicyDto;
}
export interface PolicyPreviewPolicyDto {
content?: string;
format?: string;
actor?: string;
description?: string;
}
export interface PolicyPreviewFindingDto {
id: string;
severity: string;
environment?: string;
source?: string;
vendor?: string;
license?: string;
image?: string;
repository?: string;
package?: string;
purl?: string;
cve?: string;
path?: string;
layerDigest?: string;
tags?: ReadonlyArray<string>;
}
export interface PolicyPreviewVerdictDto {
findingId: string;
status: string;
ruleName?: string | null;
ruleAction?: string | null;
notes?: string | null;
score?: number | null;
configVersion?: string | null;
inputs?: Readonly<Record<string, number>>;
quietedBy?: string | null;
quiet?: boolean | null;
unknownConfidence?: number | null;
confidenceBand?: string | null;
unknownAgeDays?: number | null;
sourceTrust?: string | null;
reachability?: string | null;
}
export interface PolicyPreviewDiffDto {
findingId: string;
baseline: PolicyPreviewVerdictDto;
projected: PolicyPreviewVerdictDto;
changed: boolean;
}
export interface PolicyPreviewIssueDto {
code: string;
message: string;
severity: string;
path: string;
}
export interface PolicyPreviewResponseDto {
success: boolean;
policyDigest: string;
revisionId?: string | null;
changed: number;
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
issues: ReadonlyArray<PolicyPreviewIssueDto>;
}
export interface PolicyPreviewSample {
previewRequest: PolicyPreviewRequestDto;
previewResponse: PolicyPreviewResponseDto;
}
export interface PolicyReportRequestDto {
imageDigest: string;
findings: ReadonlyArray<PolicyPreviewFindingDto>;
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
}
export interface PolicyReportResponseDto {
report: PolicyReportDocumentDto;
dsse?: DsseEnvelopeDto | null;
}
export interface PolicyReportDocumentDto {
reportId: string;
imageDigest: string;
generatedAt: string;
verdict: string;
policy: PolicyReportPolicyDto;
summary: PolicyReportSummaryDto;
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
issues: ReadonlyArray<PolicyPreviewIssueDto>;
}
export interface PolicyReportPolicyDto {
revisionId?: string | null;
digest?: string | null;
}
export interface PolicyReportSummaryDto {
total: number;
blocked: number;
warned: number;
ignored: number;
quieted: number;
}
export interface DsseEnvelopeDto {
payloadType: string;
payload: string;
signatures: ReadonlyArray<DsseSignatureDto>;
}
export interface DsseSignatureDto {
keyId: string;
algorithm: string;
signature: string;
}
export interface PolicyReportSample {
reportRequest: PolicyReportRequestDto;
reportResponse: PolicyReportResponseDto;
}
export interface PolicyPreviewRequestDto {
imageDigest: string;
findings: ReadonlyArray<PolicyPreviewFindingDto>;
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
policy?: PolicyPreviewPolicyDto;
}
export interface PolicyPreviewPolicyDto {
content?: string;
format?: string;
actor?: string;
description?: string;
}
export interface PolicyPreviewFindingDto {
id: string;
severity: string;
environment?: string;
source?: string;
vendor?: string;
license?: string;
image?: string;
repository?: string;
package?: string;
purl?: string;
cve?: string;
path?: string;
layerDigest?: string;
tags?: ReadonlyArray<string>;
}
export interface PolicyPreviewVerdictDto {
findingId: string;
status: string;
ruleName?: string | null;
ruleAction?: string | null;
notes?: string | null;
score?: number | null;
configVersion?: string | null;
inputs?: Readonly<Record<string, number>>;
quietedBy?: string | null;
quiet?: boolean | null;
unknownConfidence?: number | null;
confidenceBand?: string | null;
unknownAgeDays?: number | null;
sourceTrust?: string | null;
reachability?: string | null;
}
export interface PolicyPreviewDiffDto {
findingId: string;
baseline: PolicyPreviewVerdictDto;
projected: PolicyPreviewVerdictDto;
changed: boolean;
}
export interface PolicyPreviewIssueDto {
code: string;
message: string;
severity: string;
path: string;
}
export interface PolicyPreviewResponseDto {
success: boolean;
policyDigest: string;
revisionId?: string | null;
changed: number;
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
issues: ReadonlyArray<PolicyPreviewIssueDto>;
}
export interface PolicyPreviewSample {
previewRequest: PolicyPreviewRequestDto;
previewResponse: PolicyPreviewResponseDto;
}
export interface PolicyReportRequestDto {
imageDigest: string;
findings: ReadonlyArray<PolicyPreviewFindingDto>;
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
}
export interface PolicyReportResponseDto {
report: PolicyReportDocumentDto;
dsse?: DsseEnvelopeDto | null;
}
export interface PolicyReportDocumentDto {
reportId: string;
imageDigest: string;
generatedAt: string;
verdict: string;
policy: PolicyReportPolicyDto;
summary: PolicyReportSummaryDto;
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
issues: ReadonlyArray<PolicyPreviewIssueDto>;
}
export interface PolicyReportPolicyDto {
revisionId?: string | null;
digest?: string | null;
}
export interface PolicyReportSummaryDto {
total: number;
blocked: number;
warned: number;
ignored: number;
quieted: number;
}
export interface DsseEnvelopeDto {
payloadType: string;
payload: string;
signatures: ReadonlyArray<DsseSignatureDto>;
}
export interface DsseSignatureDto {
keyId: string;
algorithm: string;
signature: string;
}
export interface PolicyReportSample {
reportRequest: PolicyReportRequestDto;
reportResponse: PolicyReportResponseDto;
}

View File

@@ -1,163 +1,163 @@
/**
* Policy gate models for release flow indicators.
*/
export interface PolicyGateStatus {
/** Overall gate status */
status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
/** Policy evaluation ID */
evaluationId: string;
/** Target artifact (image, SBOM, etc.) */
targetRef: string;
/** Policy set that was evaluated */
policySetId: string;
/** Individual gate results */
gates: PolicyGate[];
/** Blocking issues preventing publish */
blockingIssues: PolicyBlockingIssue[];
/** Warning-level issues */
warnings: PolicyWarning[];
/** Remediation hints for failures */
remediationHints: PolicyRemediationHint[];
/** Evaluation timestamp */
evaluatedAt: string;
/** Can the artifact be published? */
canPublish: boolean;
/** Reason if publish is blocked */
blockReason?: string;
}
export interface PolicyGate {
/** Gate identifier */
gateId: string;
/** Human-readable name */
name: string;
/** Gate type */
type: 'determinism' | 'vulnerability' | 'license' | 'signature' | 'entropy' | 'custom';
/** Gate result */
result: 'passed' | 'failed' | 'warning' | 'skipped';
/** Is this gate required for publish? */
required: boolean;
/** Gate-specific details */
details?: Record<string, unknown>;
/** Evidence references */
evidenceRefs?: string[];
}
export interface PolicyBlockingIssue {
/** Issue code */
code: string;
/** Gate that produced this issue */
gateId: string;
/** Issue severity */
severity: 'critical' | 'high';
/** Issue description */
message: string;
/** Affected resource */
resource?: string;
}
export interface PolicyWarning {
/** Warning code */
code: string;
/** Gate that produced this warning */
gateId: string;
/** Warning message */
message: string;
/** Affected resource */
resource?: string;
}
export interface PolicyRemediationHint {
/** Which gate/issue this remediates */
forGate: string;
/** Which issue code */
forCode?: string;
/** Hint title */
title: string;
/** Step-by-step instructions */
steps: string[];
/** Documentation link */
docsUrl?: string;
/** CLI command to run */
cliCommand?: string;
/** Estimated effort */
effort?: 'trivial' | 'easy' | 'moderate' | 'complex';
}
export interface DeterminismGateDetails {
/** Merkle root consistency */
merkleRootConsistent: boolean;
/** Expected Merkle root */
expectedMerkleRoot?: string;
/** Computed Merkle root */
computedMerkleRoot?: string;
/** Fragment verification results */
fragmentResults: {
fragmentId: string;
expected: string;
computed: string;
match: boolean;
}[];
/** Composition file present */
compositionPresent: boolean;
/** Total fragments */
totalFragments: number;
/** Matching fragments */
matchingFragments: number;
}
export interface EntropyGateDetails {
/** Overall entropy score */
entropyScore: number;
/** Score threshold for warning */
warnThreshold: number;
/** Score threshold for block */
blockThreshold: number;
/** Action taken based on score */
action: 'allow' | 'warn' | 'block';
/** High entropy files count */
highEntropyFileCount: number;
/** Suspicious patterns detected */
suspiciousPatterns: string[];
}
/**
* Policy gate models for release flow indicators.
*/
export interface PolicyGateStatus {
/** Overall gate status */
status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
/** Policy evaluation ID */
evaluationId: string;
/** Target artifact (image, SBOM, etc.) */
targetRef: string;
/** Policy set that was evaluated */
policySetId: string;
/** Individual gate results */
gates: PolicyGate[];
/** Blocking issues preventing publish */
blockingIssues: PolicyBlockingIssue[];
/** Warning-level issues */
warnings: PolicyWarning[];
/** Remediation hints for failures */
remediationHints: PolicyRemediationHint[];
/** Evaluation timestamp */
evaluatedAt: string;
/** Can the artifact be published? */
canPublish: boolean;
/** Reason if publish is blocked */
blockReason?: string;
}
export interface PolicyGate {
/** Gate identifier */
gateId: string;
/** Human-readable name */
name: string;
/** Gate type */
type: 'determinism' | 'vulnerability' | 'license' | 'signature' | 'entropy' | 'custom';
/** Gate result */
result: 'passed' | 'failed' | 'warning' | 'skipped';
/** Is this gate required for publish? */
required: boolean;
/** Gate-specific details */
details?: Record<string, unknown>;
/** Evidence references */
evidenceRefs?: string[];
}
export interface PolicyBlockingIssue {
/** Issue code */
code: string;
/** Gate that produced this issue */
gateId: string;
/** Issue severity */
severity: 'critical' | 'high';
/** Issue description */
message: string;
/** Affected resource */
resource?: string;
}
export interface PolicyWarning {
/** Warning code */
code: string;
/** Gate that produced this warning */
gateId: string;
/** Warning message */
message: string;
/** Affected resource */
resource?: string;
}
export interface PolicyRemediationHint {
/** Which gate/issue this remediates */
forGate: string;
/** Which issue code */
forCode?: string;
/** Hint title */
title: string;
/** Step-by-step instructions */
steps: string[];
/** Documentation link */
docsUrl?: string;
/** CLI command to run */
cliCommand?: string;
/** Estimated effort */
effort?: 'trivial' | 'easy' | 'moderate' | 'complex';
}
export interface DeterminismGateDetails {
/** Merkle root consistency */
merkleRootConsistent: boolean;
/** Expected Merkle root */
expectedMerkleRoot?: string;
/** Computed Merkle root */
computedMerkleRoot?: string;
/** Fragment verification results */
fragmentResults: {
fragmentId: string;
expected: string;
computed: string;
match: boolean;
}[];
/** Composition file present */
compositionPresent: boolean;
/** Total fragments */
totalFragments: number;
/** Matching fragments */
matchingFragments: number;
}
export interface EntropyGateDetails {
/** Overall entropy score */
entropyScore: number;
/** Score threshold for warning */
warnThreshold: number;
/** Score threshold for block */
blockThreshold: number;
/** Action taken based on score */
action: 'allow' | 'warn' | 'block';
/** High entropy files count */
highEntropyFileCount: number;
/** Suspicious patterns detected */
suspiciousPatterns: string[];
}

View File

@@ -1,373 +1,373 @@
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
Release,
ReleaseArtifact,
PolicyEvaluation,
PolicyGateResult,
DeterminismGateDetails,
RemediationHint,
DeterminismFeatureFlags,
PolicyGateStatus,
} from './release.models';
/**
* Injection token for Release API client.
*/
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
/**
* Release API interface.
*/
export interface ReleaseApi {
getRelease(releaseId: string): Observable<Release>;
listReleases(): Observable<readonly Release[]>;
publishRelease(releaseId: string): Observable<Release>;
cancelRelease(releaseId: string): Observable<Release>;
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
}
// ============================================================================
// Mock Data Fixtures
// ============================================================================
const determinismPassingGate: PolicyGateResult = {
gateId: 'gate-det-001',
gateType: 'determinism',
name: 'SBOM Determinism',
status: 'passed',
message: 'Merkle root consistent. All fragment attestations verified.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: true,
evidence: {
type: 'determinism',
url: '/scans/scan-abc123?tab=determinism',
details: {
merkleRoot: 'sha256:a1b2c3d4e5f6...',
fragmentCount: 8,
verifiedFragments: 8,
},
},
};
const determinismFailingGate: PolicyGateResult = {
gateId: 'gate-det-002',
gateType: 'determinism',
name: 'SBOM Determinism',
status: 'failed',
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
evaluatedAt: '2025-11-27T09:30:00Z',
blockingPublish: true,
evidence: {
type: 'determinism',
url: '/scans/scan-def456?tab=determinism',
details: {
merkleRoot: 'sha256:f1e2d3c4b5a6...',
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
fragmentCount: 8,
verifiedFragments: 6,
failedFragments: [
'sha256:layer3digest...',
'sha256:layer5digest...',
],
},
},
remediation: {
gateType: 'determinism',
severity: 'critical',
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
steps: [
{
action: 'rebuild',
title: 'Rebuild with deterministic toolchain',
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
command: 'stella scan --deterministic --sign --push',
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
automated: false,
},
{
action: 'provide-provenance',
title: 'Provide provenance attestation',
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
documentationUrl: 'https://docs.stellaops.io/provenance',
automated: false,
},
{
action: 'sign-artifact',
title: 'Re-sign with valid key',
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
command: 'stella sign --artifact sha256:...',
automated: true,
},
{
action: 'request-exception',
title: 'Request policy exception',
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
automated: true,
},
],
estimatedEffort: '15-30 minutes',
exceptionAllowed: true,
},
};
const vulnerabilityPassingGate: PolicyGateResult = {
gateId: 'gate-vuln-001',
gateType: 'vulnerability',
name: 'Vulnerability Scan',
status: 'passed',
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
};
const entropyWarningGate: PolicyGateResult = {
gateId: 'gate-ent-001',
gateType: 'entropy',
name: 'Entropy Analysis',
status: 'warning',
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
remediation: {
gateType: 'entropy',
severity: 'medium',
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
steps: [
{
action: 'provide-provenance',
title: 'Provide source provenance',
description: 'Attach build provenance or source mappings for high-entropy binaries.',
automated: false,
},
],
estimatedEffort: '10 minutes',
exceptionAllowed: true,
},
};
const licensePassingGate: PolicyGateResult = {
gateId: 'gate-lic-001',
gateType: 'license',
name: 'License Compliance',
status: 'passed',
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
};
const signaturePassingGate: PolicyGateResult = {
gateId: 'gate-sig-001',
gateType: 'signature',
name: 'Signature Verification',
status: 'passed',
message: 'Image signature verified against tenant keyring.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: true,
};
const signatureFailingGate: PolicyGateResult = {
gateId: 'gate-sig-002',
gateType: 'signature',
name: 'Signature Verification',
status: 'failed',
message: 'No valid signature found. Image must be signed before release.',
evaluatedAt: '2025-11-27T09:30:00Z',
blockingPublish: true,
remediation: {
gateType: 'signature',
severity: 'critical',
summary: 'The image is not signed or the signature cannot be verified.',
steps: [
{
action: 'sign-artifact',
title: 'Sign the image',
description: 'Sign the image using your tenant signing key.',
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
automated: true,
},
],
estimatedEffort: '2 minutes',
exceptionAllowed: false,
},
};
// Artifacts with policy evaluations
const passingArtifact: ReleaseArtifact = {
artifactId: 'art-001',
name: 'api-service',
tag: 'v1.2.3',
digest: 'sha256:abc123def456789012345678901234567890abcdef',
size: 245_000_000,
createdAt: '2025-11-27T08:00:00Z',
registry: 'registry.stellaops.io/prod',
policyEvaluation: {
evaluationId: 'eval-001',
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
evaluatedAt: '2025-11-27T10:15:00Z',
overallStatus: 'passed',
gates: [
determinismPassingGate,
vulnerabilityPassingGate,
entropyWarningGate,
licensePassingGate,
signaturePassingGate,
],
blockingGates: [],
canPublish: true,
determinismDetails: {
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
merkleRootConsistent: true,
contentHash: 'sha256:content1234567890abcdef',
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
fragmentCount: 8,
verifiedFragments: 8,
},
},
};
const failingArtifact: ReleaseArtifact = {
artifactId: 'art-002',
name: 'worker-service',
tag: 'v1.2.3',
digest: 'sha256:def456abc789012345678901234567890fedcba98',
size: 312_000_000,
createdAt: '2025-11-27T07:45:00Z',
registry: 'registry.stellaops.io/prod',
policyEvaluation: {
evaluationId: 'eval-002',
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
evaluatedAt: '2025-11-27T09:30:00Z',
overallStatus: 'failed',
gates: [
determinismFailingGate,
vulnerabilityPassingGate,
licensePassingGate,
signatureFailingGate,
],
blockingGates: ['gate-det-002', 'gate-sig-002'],
canPublish: false,
determinismDetails: {
merkleRoot: 'sha256:f1e2d3c4b5a67890',
merkleRootConsistent: false,
contentHash: 'sha256:content9876543210',
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
fragmentCount: 8,
verifiedFragments: 6,
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
},
},
};
// Release fixtures
const passingRelease: Release = {
releaseId: 'rel-001',
name: 'Platform v1.2.3',
version: '1.2.3',
status: 'pending_approval',
createdAt: '2025-11-27T08:30:00Z',
createdBy: 'deploy-bot',
artifacts: [passingArtifact],
targetEnvironment: 'production',
notes: 'Feature release with API improvements and bug fixes.',
approvals: [
{
approvalId: 'apr-001',
approver: 'security-team',
decision: 'approved',
comment: 'Security review passed.',
decidedAt: '2025-11-27T09:00:00Z',
},
{
approvalId: 'apr-002',
approver: 'release-manager',
decision: 'pending',
},
],
};
const blockedRelease: Release = {
releaseId: 'rel-002',
name: 'Platform v1.2.4-rc1',
version: '1.2.4-rc1',
status: 'blocked',
createdAt: '2025-11-27T07:00:00Z',
createdBy: 'deploy-bot',
artifacts: [failingArtifact],
targetEnvironment: 'staging',
notes: 'Release candidate blocked due to policy gate failures.',
};
const mixedRelease: Release = {
releaseId: 'rel-003',
name: 'Platform v1.2.5',
version: '1.2.5',
status: 'blocked',
createdAt: '2025-11-27T06:00:00Z',
createdBy: 'ci-pipeline',
artifacts: [passingArtifact, failingArtifact],
targetEnvironment: 'production',
notes: 'Multi-artifact release with mixed policy results.',
};
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
const mockFeatureFlags: DeterminismFeatureFlags = {
enabled: true,
blockOnFailure: true,
warnOnly: false,
bypassRoles: ['security-admin', 'release-manager'],
requireApprovalForBypass: true,
};
// ============================================================================
// Mock API Implementation
// ============================================================================
@Injectable({ providedIn: 'root' })
export class MockReleaseApi implements ReleaseApi {
getRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
return of(release).pipe(delay(200));
}
listReleases(): Observable<readonly Release[]> {
return of(mockReleases).pipe(delay(300));
}
publishRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
// Simulate publish (would update status in real implementation)
return of({
...release,
status: 'published',
publishedAt: new Date().toISOString(),
} as Release).pipe(delay(500));
}
cancelRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
return of({
...release,
status: 'cancelled',
} as Release).pipe(delay(300));
}
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
return of(mockFeatureFlags).pipe(delay(100));
}
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
}
}
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
Release,
ReleaseArtifact,
PolicyEvaluation,
PolicyGateResult,
DeterminismGateDetails,
RemediationHint,
DeterminismFeatureFlags,
PolicyGateStatus,
} from './release.models';
/**
* Injection token for Release API client.
*/
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
/**
* Release API interface.
*/
export interface ReleaseApi {
getRelease(releaseId: string): Observable<Release>;
listReleases(): Observable<readonly Release[]>;
publishRelease(releaseId: string): Observable<Release>;
cancelRelease(releaseId: string): Observable<Release>;
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
}
// ============================================================================
// Mock Data Fixtures
// ============================================================================
const determinismPassingGate: PolicyGateResult = {
gateId: 'gate-det-001',
gateType: 'determinism',
name: 'SBOM Determinism',
status: 'passed',
message: 'Merkle root consistent. All fragment attestations verified.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: true,
evidence: {
type: 'determinism',
url: '/scans/scan-abc123?tab=determinism',
details: {
merkleRoot: 'sha256:a1b2c3d4e5f6...',
fragmentCount: 8,
verifiedFragments: 8,
},
},
};
const determinismFailingGate: PolicyGateResult = {
gateId: 'gate-det-002',
gateType: 'determinism',
name: 'SBOM Determinism',
status: 'failed',
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
evaluatedAt: '2025-11-27T09:30:00Z',
blockingPublish: true,
evidence: {
type: 'determinism',
url: '/scans/scan-def456?tab=determinism',
details: {
merkleRoot: 'sha256:f1e2d3c4b5a6...',
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
fragmentCount: 8,
verifiedFragments: 6,
failedFragments: [
'sha256:layer3digest...',
'sha256:layer5digest...',
],
},
},
remediation: {
gateType: 'determinism',
severity: 'critical',
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
steps: [
{
action: 'rebuild',
title: 'Rebuild with deterministic toolchain',
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
command: 'stella scan --deterministic --sign --push',
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
automated: false,
},
{
action: 'provide-provenance',
title: 'Provide provenance attestation',
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
documentationUrl: 'https://docs.stellaops.io/provenance',
automated: false,
},
{
action: 'sign-artifact',
title: 'Re-sign with valid key',
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
command: 'stella sign --artifact sha256:...',
automated: true,
},
{
action: 'request-exception',
title: 'Request policy exception',
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
automated: true,
},
],
estimatedEffort: '15-30 minutes',
exceptionAllowed: true,
},
};
const vulnerabilityPassingGate: PolicyGateResult = {
gateId: 'gate-vuln-001',
gateType: 'vulnerability',
name: 'Vulnerability Scan',
status: 'passed',
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
};
const entropyWarningGate: PolicyGateResult = {
gateId: 'gate-ent-001',
gateType: 'entropy',
name: 'Entropy Analysis',
status: 'warning',
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
remediation: {
gateType: 'entropy',
severity: 'medium',
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
steps: [
{
action: 'provide-provenance',
title: 'Provide source provenance',
description: 'Attach build provenance or source mappings for high-entropy binaries.',
automated: false,
},
],
estimatedEffort: '10 minutes',
exceptionAllowed: true,
},
};
const licensePassingGate: PolicyGateResult = {
gateId: 'gate-lic-001',
gateType: 'license',
name: 'License Compliance',
status: 'passed',
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
};
const signaturePassingGate: PolicyGateResult = {
gateId: 'gate-sig-001',
gateType: 'signature',
name: 'Signature Verification',
status: 'passed',
message: 'Image signature verified against tenant keyring.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: true,
};
const signatureFailingGate: PolicyGateResult = {
gateId: 'gate-sig-002',
gateType: 'signature',
name: 'Signature Verification',
status: 'failed',
message: 'No valid signature found. Image must be signed before release.',
evaluatedAt: '2025-11-27T09:30:00Z',
blockingPublish: true,
remediation: {
gateType: 'signature',
severity: 'critical',
summary: 'The image is not signed or the signature cannot be verified.',
steps: [
{
action: 'sign-artifact',
title: 'Sign the image',
description: 'Sign the image using your tenant signing key.',
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
automated: true,
},
],
estimatedEffort: '2 minutes',
exceptionAllowed: false,
},
};
// Artifacts with policy evaluations
const passingArtifact: ReleaseArtifact = {
artifactId: 'art-001',
name: 'api-service',
tag: 'v1.2.3',
digest: 'sha256:abc123def456789012345678901234567890abcdef',
size: 245_000_000,
createdAt: '2025-11-27T08:00:00Z',
registry: 'registry.stellaops.io/prod',
policyEvaluation: {
evaluationId: 'eval-001',
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
evaluatedAt: '2025-11-27T10:15:00Z',
overallStatus: 'passed',
gates: [
determinismPassingGate,
vulnerabilityPassingGate,
entropyWarningGate,
licensePassingGate,
signaturePassingGate,
],
blockingGates: [],
canPublish: true,
determinismDetails: {
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
merkleRootConsistent: true,
contentHash: 'sha256:content1234567890abcdef',
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
fragmentCount: 8,
verifiedFragments: 8,
},
},
};
const failingArtifact: ReleaseArtifact = {
artifactId: 'art-002',
name: 'worker-service',
tag: 'v1.2.3',
digest: 'sha256:def456abc789012345678901234567890fedcba98',
size: 312_000_000,
createdAt: '2025-11-27T07:45:00Z',
registry: 'registry.stellaops.io/prod',
policyEvaluation: {
evaluationId: 'eval-002',
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
evaluatedAt: '2025-11-27T09:30:00Z',
overallStatus: 'failed',
gates: [
determinismFailingGate,
vulnerabilityPassingGate,
licensePassingGate,
signatureFailingGate,
],
blockingGates: ['gate-det-002', 'gate-sig-002'],
canPublish: false,
determinismDetails: {
merkleRoot: 'sha256:f1e2d3c4b5a67890',
merkleRootConsistent: false,
contentHash: 'sha256:content9876543210',
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
fragmentCount: 8,
verifiedFragments: 6,
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
},
},
};
// Release fixtures
const passingRelease: Release = {
releaseId: 'rel-001',
name: 'Platform v1.2.3',
version: '1.2.3',
status: 'pending_approval',
createdAt: '2025-11-27T08:30:00Z',
createdBy: 'deploy-bot',
artifacts: [passingArtifact],
targetEnvironment: 'production',
notes: 'Feature release with API improvements and bug fixes.',
approvals: [
{
approvalId: 'apr-001',
approver: 'security-team',
decision: 'approved',
comment: 'Security review passed.',
decidedAt: '2025-11-27T09:00:00Z',
},
{
approvalId: 'apr-002',
approver: 'release-manager',
decision: 'pending',
},
],
};
const blockedRelease: Release = {
releaseId: 'rel-002',
name: 'Platform v1.2.4-rc1',
version: '1.2.4-rc1',
status: 'blocked',
createdAt: '2025-11-27T07:00:00Z',
createdBy: 'deploy-bot',
artifacts: [failingArtifact],
targetEnvironment: 'staging',
notes: 'Release candidate blocked due to policy gate failures.',
};
const mixedRelease: Release = {
releaseId: 'rel-003',
name: 'Platform v1.2.5',
version: '1.2.5',
status: 'blocked',
createdAt: '2025-11-27T06:00:00Z',
createdBy: 'ci-pipeline',
artifacts: [passingArtifact, failingArtifact],
targetEnvironment: 'production',
notes: 'Multi-artifact release with mixed policy results.',
};
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
const mockFeatureFlags: DeterminismFeatureFlags = {
enabled: true,
blockOnFailure: true,
warnOnly: false,
bypassRoles: ['security-admin', 'release-manager'],
requireApprovalForBypass: true,
};
// ============================================================================
// Mock API Implementation
// ============================================================================
@Injectable({ providedIn: 'root' })
export class MockReleaseApi implements ReleaseApi {
getRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
return of(release).pipe(delay(200));
}
listReleases(): Observable<readonly Release[]> {
return of(mockReleases).pipe(delay(300));
}
publishRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
// Simulate publish (would update status in real implementation)
return of({
...release,
status: 'published',
publishedAt: new Date().toISOString(),
} as Release).pipe(delay(500));
}
cancelRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
return of({
...release,
status: 'cancelled',
} as Release).pipe(delay(300));
}
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
return of(mockFeatureFlags).pipe(delay(100));
}
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
}
}

View File

@@ -1,161 +1,161 @@
/**
* Release and Policy Gate models for UI-POLICY-DET-01.
* Supports determinism-gated release flows with remediation hints.
*/
// Policy gate evaluation status
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
// Types of policy gates
export type PolicyGateType =
| 'determinism'
| 'vulnerability'
| 'license'
| 'entropy'
| 'signature'
| 'sbom-completeness'
| 'custom';
// Remediation action types
export type RemediationActionType =
| 'rebuild'
| 'provide-provenance'
| 'sign-artifact'
| 'update-dependency'
| 'request-exception'
| 'manual-review';
/**
* A single remediation step with optional automation support.
*/
export interface RemediationStep {
readonly action: RemediationActionType;
readonly title: string;
readonly description: string;
readonly command?: string; // Optional CLI command to run
readonly documentationUrl?: string;
readonly automated: boolean; // Can be triggered from UI
}
/**
* Remediation hints for a failed policy gate.
*/
export interface RemediationHint {
readonly gateType: PolicyGateType;
readonly severity: 'critical' | 'high' | 'medium' | 'low';
readonly summary: string;
readonly steps: readonly RemediationStep[];
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
readonly exceptionAllowed: boolean;
}
/**
* Individual policy gate evaluation result.
*/
export interface PolicyGateResult {
readonly gateId: string;
readonly gateType: PolicyGateType;
readonly name: string;
readonly status: PolicyGateStatus;
readonly message: string;
readonly evaluatedAt: string;
readonly blockingPublish: boolean;
readonly evidence?: {
readonly type: string;
readonly url?: string;
readonly details?: Record<string, unknown>;
};
readonly remediation?: RemediationHint;
}
/**
* Determinism-specific gate details.
*/
export interface DeterminismGateDetails {
readonly merkleRoot?: string;
readonly merkleRootConsistent: boolean;
readonly contentHash?: string;
readonly compositionManifestUri?: string;
readonly fragmentCount?: number;
readonly verifiedFragments?: number;
readonly failedFragments?: readonly string[]; // Layer digests that failed
}
/**
* Overall policy evaluation for a release artifact.
*/
export interface PolicyEvaluation {
readonly evaluationId: string;
readonly artifactDigest: string;
readonly evaluatedAt: string;
readonly overallStatus: PolicyGateStatus;
readonly gates: readonly PolicyGateResult[];
readonly blockingGates: readonly string[]; // Gate IDs that block publish
readonly canPublish: boolean;
readonly determinismDetails?: DeterminismGateDetails;
}
/**
* Release artifact with policy evaluation.
*/
export interface ReleaseArtifact {
readonly artifactId: string;
readonly name: string;
readonly tag: string;
readonly digest: string;
readonly size: number;
readonly createdAt: string;
readonly registry: string;
readonly policyEvaluation?: PolicyEvaluation;
}
/**
* Release workflow status.
*/
export type ReleaseStatus =
| 'draft'
| 'pending_approval'
| 'approved'
| 'publishing'
| 'published'
| 'blocked'
| 'cancelled';
/**
* Release with multiple artifacts and policy gates.
*/
export interface Release {
readonly releaseId: string;
readonly name: string;
readonly version: string;
readonly status: ReleaseStatus;
readonly createdAt: string;
readonly createdBy: string;
readonly artifacts: readonly ReleaseArtifact[];
readonly targetEnvironment: string;
readonly notes?: string;
readonly approvals?: readonly ReleaseApproval[];
readonly publishedAt?: string;
}
/**
* Release approval record.
*/
export interface ReleaseApproval {
readonly approvalId: string;
readonly approver: string;
readonly decision: 'approved' | 'rejected' | 'pending';
readonly comment?: string;
readonly decidedAt?: string;
}
/**
* Feature flag configuration for determinism blocking.
*/
export interface DeterminismFeatureFlags {
readonly enabled: boolean;
readonly blockOnFailure: boolean;
readonly warnOnly: boolean;
readonly bypassRoles?: readonly string[];
readonly requireApprovalForBypass: boolean;
}
/**
* Release and Policy Gate models for UI-POLICY-DET-01.
* Supports determinism-gated release flows with remediation hints.
*/
// Policy gate evaluation status
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
// Types of policy gates
export type PolicyGateType =
| 'determinism'
| 'vulnerability'
| 'license'
| 'entropy'
| 'signature'
| 'sbom-completeness'
| 'custom';
// Remediation action types
export type RemediationActionType =
| 'rebuild'
| 'provide-provenance'
| 'sign-artifact'
| 'update-dependency'
| 'request-exception'
| 'manual-review';
/**
* A single remediation step with optional automation support.
*/
export interface RemediationStep {
readonly action: RemediationActionType;
readonly title: string;
readonly description: string;
readonly command?: string; // Optional CLI command to run
readonly documentationUrl?: string;
readonly automated: boolean; // Can be triggered from UI
}
/**
* Remediation hints for a failed policy gate.
*/
export interface RemediationHint {
readonly gateType: PolicyGateType;
readonly severity: 'critical' | 'high' | 'medium' | 'low';
readonly summary: string;
readonly steps: readonly RemediationStep[];
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
readonly exceptionAllowed: boolean;
}
/**
* Individual policy gate evaluation result.
*/
export interface PolicyGateResult {
readonly gateId: string;
readonly gateType: PolicyGateType;
readonly name: string;
readonly status: PolicyGateStatus;
readonly message: string;
readonly evaluatedAt: string;
readonly blockingPublish: boolean;
readonly evidence?: {
readonly type: string;
readonly url?: string;
readonly details?: Record<string, unknown>;
};
readonly remediation?: RemediationHint;
}
/**
* Determinism-specific gate details.
*/
export interface DeterminismGateDetails {
readonly merkleRoot?: string;
readonly merkleRootConsistent: boolean;
readonly contentHash?: string;
readonly compositionManifestUri?: string;
readonly fragmentCount?: number;
readonly verifiedFragments?: number;
readonly failedFragments?: readonly string[]; // Layer digests that failed
}
/**
* Overall policy evaluation for a release artifact.
*/
export interface PolicyEvaluation {
readonly evaluationId: string;
readonly artifactDigest: string;
readonly evaluatedAt: string;
readonly overallStatus: PolicyGateStatus;
readonly gates: readonly PolicyGateResult[];
readonly blockingGates: readonly string[]; // Gate IDs that block publish
readonly canPublish: boolean;
readonly determinismDetails?: DeterminismGateDetails;
}
/**
* Release artifact with policy evaluation.
*/
export interface ReleaseArtifact {
readonly artifactId: string;
readonly name: string;
readonly tag: string;
readonly digest: string;
readonly size: number;
readonly createdAt: string;
readonly registry: string;
readonly policyEvaluation?: PolicyEvaluation;
}
/**
* Release workflow status.
*/
export type ReleaseStatus =
| 'draft'
| 'pending_approval'
| 'approved'
| 'publishing'
| 'published'
| 'blocked'
| 'cancelled';
/**
* Release with multiple artifacts and policy gates.
*/
export interface Release {
readonly releaseId: string;
readonly name: string;
readonly version: string;
readonly status: ReleaseStatus;
readonly createdAt: string;
readonly createdBy: string;
readonly artifacts: readonly ReleaseArtifact[];
readonly targetEnvironment: string;
readonly notes?: string;
readonly approvals?: readonly ReleaseApproval[];
readonly publishedAt?: string;
}
/**
* Release approval record.
*/
export interface ReleaseApproval {
readonly approvalId: string;
readonly approver: string;
readonly decision: 'approved' | 'rejected' | 'pending';
readonly comment?: string;
readonly decidedAt?: string;
}
/**
* Feature flag configuration for determinism blocking.
*/
export interface DeterminismFeatureFlags {
readonly enabled: boolean;
readonly blockOnFailure: boolean;
readonly warnOnly: boolean;
readonly bypassRoles?: readonly string[];
readonly requireApprovalForBypass: boolean;
}

View File

@@ -1,147 +1,147 @@
export type ScanAttestationStatusKind = 'verified' | 'pending' | 'failed';
export interface ScanAttestationStatus {
readonly uuid: string;
readonly status: ScanAttestationStatusKind;
readonly index?: number;
readonly logUrl?: string;
readonly checkedAt?: string;
readonly statusMessage?: string;
}
// Determinism models based on docs/modules/scanner/deterministic-sbom-compose.md
export type DeterminismStatus = 'verified' | 'pending' | 'failed' | 'unknown';
export interface FragmentAttestation {
readonly layerDigest: string;
readonly fragmentSha256: string;
readonly dsseEnvelopeSha256: string;
readonly dsseStatus: 'verified' | 'pending' | 'failed';
readonly verifiedAt?: string;
}
export interface CompositionManifest {
readonly compositionUri: string;
readonly merkleRoot: string;
readonly fragmentCount: number;
readonly fragments: readonly FragmentAttestation[];
readonly createdAt: string;
}
export interface DeterminismEvidence {
readonly status: DeterminismStatus;
readonly merkleRoot?: string;
readonly merkleRootConsistent: boolean;
readonly compositionManifest?: CompositionManifest;
readonly contentHash?: string;
readonly verifiedAt?: string;
readonly failureReason?: string;
readonly stellaProperties?: {
readonly 'stellaops:stella.contentHash'?: string;
readonly 'stellaops:composition.manifest'?: string;
readonly 'stellaops:merkle.root'?: string;
};
}
// Entropy analysis models based on docs/modules/scanner/entropy.md
export interface EntropyWindow {
readonly offset: number;
readonly length: number;
readonly entropy: number; // 0-8 bits/byte
}
export interface EntropyFile {
readonly path: string;
readonly size: number;
readonly opaqueBytes: number;
readonly opaqueRatio: number; // 0-1
readonly flags: readonly string[]; // e.g., 'stripped', 'section:.UPX0', 'no-symbols', 'packed'
readonly windows: readonly EntropyWindow[];
}
export interface EntropyLayerSummary {
readonly digest: string;
readonly opaqueBytes: number;
readonly totalBytes: number;
readonly opaqueRatio: number; // 0-1
readonly indicators: readonly string[]; // e.g., 'packed', 'no-symbols'
}
export interface EntropyReport {
readonly schema: string;
readonly generatedAt: string;
readonly imageDigest: string;
readonly layerDigest?: string;
readonly files: readonly EntropyFile[];
}
export interface EntropyLayerSummaryReport {
readonly schema: string;
readonly generatedAt: string;
readonly imageDigest: string;
readonly layers: readonly EntropyLayerSummary[];
readonly imageOpaqueRatio: number; // 0-1
readonly entropyPenalty: number; // 0-0.3
}
export interface EntropyEvidence {
readonly report?: EntropyReport;
readonly layerSummary?: EntropyLayerSummaryReport;
readonly downloadUrl?: string; // URL to entropy.report.json
}
// Binary Evidence models for SPRINT_20251226_014_BINIDX (SCANINT-17,18,19)
export type BinaryFixStatus = 'fixed' | 'vulnerable' | 'not_affected' | 'wontfix' | 'unknown';
export interface BinaryIdentity {
readonly format: 'elf' | 'pe' | 'macho';
readonly buildId?: string;
readonly fileSha256: string;
readonly architecture: string;
readonly binaryKey: string;
readonly path?: string;
}
export interface BinaryFixStatusInfo {
readonly state: BinaryFixStatus;
readonly fixedVersion?: string;
readonly method: 'changelog' | 'patch_analysis' | 'advisory';
readonly confidence: number;
}
export interface BinaryVulnMatch {
readonly cveId: string;
readonly method: 'buildid_catalog' | 'fingerprint_match' | 'range_match';
readonly confidence: number;
readonly vulnerablePurl: string;
readonly fixStatus?: BinaryFixStatusInfo;
readonly similarity?: number;
readonly matchedFunction?: string;
}
export interface BinaryFinding {
readonly identity: BinaryIdentity;
readonly layerDigest: string;
readonly matches: readonly BinaryVulnMatch[];
}
export interface BinaryEvidence {
readonly binaries: readonly BinaryFinding[];
readonly scanId: string;
readonly scannedAt: string;
readonly distro?: string;
readonly release?: string;
}
export interface ScanDetail {
readonly scanId: string;
readonly imageDigest: string;
readonly completedAt: string;
readonly attestation?: ScanAttestationStatus;
readonly determinism?: DeterminismEvidence;
readonly entropy?: EntropyEvidence;
readonly binaryEvidence?: BinaryEvidence;
}
export type ScanAttestationStatusKind = 'verified' | 'pending' | 'failed';
export interface ScanAttestationStatus {
readonly uuid: string;
readonly status: ScanAttestationStatusKind;
readonly index?: number;
readonly logUrl?: string;
readonly checkedAt?: string;
readonly statusMessage?: string;
}
// Determinism models based on docs/modules/scanner/deterministic-sbom-compose.md
export type DeterminismStatus = 'verified' | 'pending' | 'failed' | 'unknown';
export interface FragmentAttestation {
readonly layerDigest: string;
readonly fragmentSha256: string;
readonly dsseEnvelopeSha256: string;
readonly dsseStatus: 'verified' | 'pending' | 'failed';
readonly verifiedAt?: string;
}
export interface CompositionManifest {
readonly compositionUri: string;
readonly merkleRoot: string;
readonly fragmentCount: number;
readonly fragments: readonly FragmentAttestation[];
readonly createdAt: string;
}
export interface DeterminismEvidence {
readonly status: DeterminismStatus;
readonly merkleRoot?: string;
readonly merkleRootConsistent: boolean;
readonly compositionManifest?: CompositionManifest;
readonly contentHash?: string;
readonly verifiedAt?: string;
readonly failureReason?: string;
readonly stellaProperties?: {
readonly 'stellaops:stella.contentHash'?: string;
readonly 'stellaops:composition.manifest'?: string;
readonly 'stellaops:merkle.root'?: string;
};
}
// Entropy analysis models based on docs/modules/scanner/entropy.md
export interface EntropyWindow {
readonly offset: number;
readonly length: number;
readonly entropy: number; // 0-8 bits/byte
}
export interface EntropyFile {
readonly path: string;
readonly size: number;
readonly opaqueBytes: number;
readonly opaqueRatio: number; // 0-1
readonly flags: readonly string[]; // e.g., 'stripped', 'section:.UPX0', 'no-symbols', 'packed'
readonly windows: readonly EntropyWindow[];
}
export interface EntropyLayerSummary {
readonly digest: string;
readonly opaqueBytes: number;
readonly totalBytes: number;
readonly opaqueRatio: number; // 0-1
readonly indicators: readonly string[]; // e.g., 'packed', 'no-symbols'
}
export interface EntropyReport {
readonly schema: string;
readonly generatedAt: string;
readonly imageDigest: string;
readonly layerDigest?: string;
readonly files: readonly EntropyFile[];
}
export interface EntropyLayerSummaryReport {
readonly schema: string;
readonly generatedAt: string;
readonly imageDigest: string;
readonly layers: readonly EntropyLayerSummary[];
readonly imageOpaqueRatio: number; // 0-1
readonly entropyPenalty: number; // 0-0.3
}
export interface EntropyEvidence {
readonly report?: EntropyReport;
readonly layerSummary?: EntropyLayerSummaryReport;
readonly downloadUrl?: string; // URL to entropy.report.json
}
// Binary Evidence models for SPRINT_20251226_014_BINIDX (SCANINT-17,18,19)
export type BinaryFixStatus = 'fixed' | 'vulnerable' | 'not_affected' | 'wontfix' | 'unknown';
export interface BinaryIdentity {
readonly format: 'elf' | 'pe' | 'macho';
readonly buildId?: string;
readonly fileSha256: string;
readonly architecture: string;
readonly binaryKey: string;
readonly path?: string;
}
export interface BinaryFixStatusInfo {
readonly state: BinaryFixStatus;
readonly fixedVersion?: string;
readonly method: 'changelog' | 'patch_analysis' | 'advisory';
readonly confidence: number;
}
export interface BinaryVulnMatch {
readonly cveId: string;
readonly method: 'buildid_catalog' | 'fingerprint_match' | 'range_match';
readonly confidence: number;
readonly vulnerablePurl: string;
readonly fixStatus?: BinaryFixStatusInfo;
readonly similarity?: number;
readonly matchedFunction?: string;
}
export interface BinaryFinding {
readonly identity: BinaryIdentity;
readonly layerDigest: string;
readonly matches: readonly BinaryVulnMatch[];
}
export interface BinaryEvidence {
readonly binaries: readonly BinaryFinding[];
readonly scanId: string;
readonly scannedAt: string;
readonly distro?: string;
readonly release?: string;
}
export interface ScanDetail {
readonly scanId: string;
readonly imageDigest: string;
readonly completedAt: string;
readonly attestation?: ScanAttestationStatus;
readonly determinism?: DeterminismEvidence;
readonly entropy?: EntropyEvidence;
readonly binaryEvidence?: BinaryEvidence;
}

View File

@@ -1,267 +1,267 @@
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
Vulnerability,
VulnerabilitiesQueryOptions,
VulnerabilitiesResponse,
VulnerabilityStats,
VulnWorkflowRequest,
VulnWorkflowResponse,
VulnExportRequest,
VulnExportResponse,
} from './vulnerability.models';
/**
* Vulnerability API interface.
* Implements WEB-VULN-29-001 contract with tenant scoping and RBAC/ABAC enforcement.
*/
export interface VulnerabilityApi {
/** List vulnerabilities with filtering and pagination. */
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse>;
/** Get a single vulnerability by ID. */
getVulnerability(vulnId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<Vulnerability>;
/** Get vulnerability statistics. */
getStats(options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats>;
/** Submit a workflow action (ack, close, reopen, etc.). */
submitWorkflowAction(request: VulnWorkflowRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnWorkflowResponse>;
/** Request a vulnerability export. */
requestExport(request: VulnExportRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
/** Get export status by ID. */
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
}
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
Vulnerability,
VulnerabilitiesQueryOptions,
VulnerabilitiesResponse,
VulnerabilityStats,
VulnWorkflowRequest,
VulnWorkflowResponse,
VulnExportRequest,
VulnExportResponse,
} from './vulnerability.models';
/**
* Vulnerability API interface.
* Implements WEB-VULN-29-001 contract with tenant scoping and RBAC/ABAC enforcement.
*/
export interface VulnerabilityApi {
/** List vulnerabilities with filtering and pagination. */
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse>;
/** Get a single vulnerability by ID. */
getVulnerability(vulnId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<Vulnerability>;
/** Get vulnerability statistics. */
getStats(options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats>;
/** Submit a workflow action (ack, close, reopen, etc.). */
submitWorkflowAction(request: VulnWorkflowRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnWorkflowResponse>;
/** Request a vulnerability export. */
requestExport(request: VulnExportRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
/** Get export status by ID. */
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
}
export const VULNERABILITY_API = new InjectionToken<VulnerabilityApi>('VULNERABILITY_API');
const MOCK_VULNERABILITIES: Vulnerability[] = [
{
vulnId: 'vuln-001',
cveId: 'CVE-2021-44228',
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severity: 'critical',
cvssScore: 10.0,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
status: 'open',
publishedAt: '2021-12-10T00:00:00Z',
modifiedAt: '2024-06-27T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
name: 'log4j-core',
version: '2.14.1',
fixedVersion: '2.17.1',
assetIds: ['asset-web-prod', 'asset-api-prod'],
},
],
references: [
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
'https://logging.apache.org/log4j/2.x/security.html',
],
hasException: false,
},
{
vulnId: 'vuln-002',
cveId: 'CVE-2021-45046',
title: 'Log4j2 Thread Context Message Pattern DoS',
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
severity: 'critical',
cvssScore: 9.0,
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H',
status: 'excepted',
publishedAt: '2021-12-14T00:00:00Z',
modifiedAt: '2023-11-06T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0',
name: 'log4j-core',
version: '2.15.0',
fixedVersion: '2.17.1',
assetIds: ['asset-internal-001'],
},
],
hasException: true,
exceptionId: 'exc-test-001',
},
{
vulnId: 'vuln-003',
cveId: 'CVE-2023-44487',
title: 'HTTP/2 Rapid Reset Attack',
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
severity: 'high',
cvssScore: 7.5,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H',
status: 'in_progress',
publishedAt: '2023-10-10T00:00:00Z',
modifiedAt: '2024-05-01T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/golang.org/x/net@0.15.0',
name: 'golang.org/x/net',
version: '0.15.0',
fixedVersion: '0.17.0',
assetIds: ['asset-api-prod', 'asset-worker-prod'],
},
{
purl: 'pkg:npm/nghttp2@1.55.0',
name: 'nghttp2',
version: '1.55.0',
fixedVersion: '1.57.0',
assetIds: ['asset-web-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-004',
cveId: 'CVE-2024-21626',
title: 'runc container escape vulnerability',
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
severity: 'high',
cvssScore: 8.6,
cvssVector: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H',
status: 'fixed',
publishedAt: '2024-01-31T00:00:00Z',
modifiedAt: '2024-09-13T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/github.com/opencontainers/runc@1.1.10',
name: 'runc',
version: '1.1.10',
fixedVersion: '1.1.12',
assetIds: ['asset-builder-001'],
},
],
hasException: false,
},
{
vulnId: 'vuln-005',
cveId: 'CVE-2023-38545',
title: 'curl SOCKS5 heap buffer overflow',
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
severity: 'high',
cvssScore: 9.8,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
status: 'open',
publishedAt: '2023-10-11T00:00:00Z',
modifiedAt: '2024-06-10T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:deb/debian/curl@7.88.1-10',
name: 'curl',
version: '7.88.1-10',
fixedVersion: '8.4.0',
assetIds: ['asset-web-prod', 'asset-api-prod', 'asset-worker-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-006',
cveId: 'CVE-2022-22965',
title: 'Spring4Shell - Spring Framework RCE',
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
severity: 'critical',
cvssScore: 9.8,
status: 'wont_fix',
publishedAt: '2022-03-31T00:00:00Z',
modifiedAt: '2024-08-20T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.springframework/spring-beans@5.3.17',
name: 'spring-beans',
version: '5.3.17',
fixedVersion: '5.3.18',
assetIds: ['asset-legacy-001'],
},
],
hasException: true,
exceptionId: 'exc-legacy-spring',
},
{
vulnId: 'vuln-007',
cveId: 'CVE-2023-45853',
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
severity: 'medium',
cvssScore: 5.3,
status: 'open',
publishedAt: '2023-10-14T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:deb/debian/zlib@1.2.13',
name: 'zlib',
version: '1.2.13',
fixedVersion: '1.3.1',
assetIds: ['asset-web-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-008',
cveId: 'CVE-2024-0567',
title: 'GnuTLS certificate verification bypass',
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
severity: 'medium',
cvssScore: 5.9,
status: 'open',
publishedAt: '2024-01-16T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:rpm/fedora/gnutls@3.8.2',
name: 'gnutls',
version: '3.8.2',
fixedVersion: '3.8.3',
assetIds: ['asset-internal-001'],
},
],
hasException: false,
},
{
vulnId: 'vuln-009',
cveId: 'CVE-2023-5363',
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
severity: 'low',
cvssScore: 3.7,
status: 'fixed',
publishedAt: '2023-10-24T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:nuget/System.Security.Cryptography.Pkcs@7.0.2',
name: 'System.Security.Cryptography.Pkcs',
version: '7.0.2',
fixedVersion: '8.0.0',
assetIds: ['asset-api-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-010',
cveId: 'CVE-2024-24790',
title: 'Go net/netip ParseAddr stack exhaustion',
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
severity: 'low',
cvssScore: 4.0,
status: 'open',
publishedAt: '2024-06-05T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/stdlib@1.21.10',
name: 'go stdlib',
version: '1.21.10',
fixedVersion: '1.21.11',
assetIds: ['asset-api-prod'],
},
],
hasException: false,
{
vulnId: 'vuln-001',
cveId: 'CVE-2021-44228',
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severity: 'critical',
cvssScore: 10.0,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
status: 'open',
publishedAt: '2021-12-10T00:00:00Z',
modifiedAt: '2024-06-27T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
name: 'log4j-core',
version: '2.14.1',
fixedVersion: '2.17.1',
assetIds: ['asset-web-prod', 'asset-api-prod'],
},
],
references: [
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
'https://logging.apache.org/log4j/2.x/security.html',
],
hasException: false,
},
{
vulnId: 'vuln-002',
cveId: 'CVE-2021-45046',
title: 'Log4j2 Thread Context Message Pattern DoS',
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
severity: 'critical',
cvssScore: 9.0,
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H',
status: 'excepted',
publishedAt: '2021-12-14T00:00:00Z',
modifiedAt: '2023-11-06T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0',
name: 'log4j-core',
version: '2.15.0',
fixedVersion: '2.17.1',
assetIds: ['asset-internal-001'],
},
],
hasException: true,
exceptionId: 'exc-test-001',
},
{
vulnId: 'vuln-003',
cveId: 'CVE-2023-44487',
title: 'HTTP/2 Rapid Reset Attack',
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
severity: 'high',
cvssScore: 7.5,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H',
status: 'in_progress',
publishedAt: '2023-10-10T00:00:00Z',
modifiedAt: '2024-05-01T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/golang.org/x/net@0.15.0',
name: 'golang.org/x/net',
version: '0.15.0',
fixedVersion: '0.17.0',
assetIds: ['asset-api-prod', 'asset-worker-prod'],
},
{
purl: 'pkg:npm/nghttp2@1.55.0',
name: 'nghttp2',
version: '1.55.0',
fixedVersion: '1.57.0',
assetIds: ['asset-web-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-004',
cveId: 'CVE-2024-21626',
title: 'runc container escape vulnerability',
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
severity: 'high',
cvssScore: 8.6,
cvssVector: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H',
status: 'fixed',
publishedAt: '2024-01-31T00:00:00Z',
modifiedAt: '2024-09-13T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/github.com/opencontainers/runc@1.1.10',
name: 'runc',
version: '1.1.10',
fixedVersion: '1.1.12',
assetIds: ['asset-builder-001'],
},
],
hasException: false,
},
{
vulnId: 'vuln-005',
cveId: 'CVE-2023-38545',
title: 'curl SOCKS5 heap buffer overflow',
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
severity: 'high',
cvssScore: 9.8,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
status: 'open',
publishedAt: '2023-10-11T00:00:00Z',
modifiedAt: '2024-06-10T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:deb/debian/curl@7.88.1-10',
name: 'curl',
version: '7.88.1-10',
fixedVersion: '8.4.0',
assetIds: ['asset-web-prod', 'asset-api-prod', 'asset-worker-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-006',
cveId: 'CVE-2022-22965',
title: 'Spring4Shell - Spring Framework RCE',
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
severity: 'critical',
cvssScore: 9.8,
status: 'wont_fix',
publishedAt: '2022-03-31T00:00:00Z',
modifiedAt: '2024-08-20T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.springframework/spring-beans@5.3.17',
name: 'spring-beans',
version: '5.3.17',
fixedVersion: '5.3.18',
assetIds: ['asset-legacy-001'],
},
],
hasException: true,
exceptionId: 'exc-legacy-spring',
},
{
vulnId: 'vuln-007',
cveId: 'CVE-2023-45853',
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
severity: 'medium',
cvssScore: 5.3,
status: 'open',
publishedAt: '2023-10-14T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:deb/debian/zlib@1.2.13',
name: 'zlib',
version: '1.2.13',
fixedVersion: '1.3.1',
assetIds: ['asset-web-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-008',
cveId: 'CVE-2024-0567',
title: 'GnuTLS certificate verification bypass',
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
severity: 'medium',
cvssScore: 5.9,
status: 'open',
publishedAt: '2024-01-16T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:rpm/fedora/gnutls@3.8.2',
name: 'gnutls',
version: '3.8.2',
fixedVersion: '3.8.3',
assetIds: ['asset-internal-001'],
},
],
hasException: false,
},
{
vulnId: 'vuln-009',
cveId: 'CVE-2023-5363',
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
severity: 'low',
cvssScore: 3.7,
status: 'fixed',
publishedAt: '2023-10-24T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:nuget/System.Security.Cryptography.Pkcs@7.0.2',
name: 'System.Security.Cryptography.Pkcs',
version: '7.0.2',
fixedVersion: '8.0.0',
assetIds: ['asset-api-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-010',
cveId: 'CVE-2024-24790',
title: 'Go net/netip ParseAddr stack exhaustion',
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
severity: 'low',
cvssScore: 4.0,
status: 'open',
publishedAt: '2024-06-05T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/stdlib@1.21.10',
name: 'go stdlib',
version: '1.21.10',
fixedVersion: '1.21.11',
assetIds: ['asset-api-prod'],
},
],
hasException: false,
},
];
@@ -302,19 +302,19 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
let items = [...MOCK_VULNERABILITIES];
if (options?.severity && options.severity !== 'all') {
items = items.filter((v) => v.severity === options.severity);
}
if (options?.status && options.status !== 'all') {
items = items.filter((v) => v.status === options.status);
}
if (options?.hasException !== undefined) {
items = items.filter((v) => v.hasException === options.hasException);
}
if (options?.severity && options.severity !== 'all') {
items = items.filter((v) => v.severity === options.severity);
}
if (options?.status && options.status !== 'all') {
items = items.filter((v) => v.status === options.status);
}
if (options?.hasException !== undefined) {
items = items.filter((v) => v.hasException === options.hasException);
}
if (options?.search) {
const search = options.search.toLowerCase();
items = items.filter(
@@ -356,25 +356,25 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
etag: `"vuln-${vulnId}-etag"`,
}).pipe(delay(100));
}
getStats(_options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats> {
const vulns = MOCK_VULNERABILITIES;
const stats: VulnerabilityStats = {
total: vulns.length,
bySeverity: {
critical: vulns.filter((v) => v.severity === 'critical').length,
high: vulns.filter((v) => v.severity === 'high').length,
medium: vulns.filter((v) => v.severity === 'medium').length,
low: vulns.filter((v) => v.severity === 'low').length,
unknown: vulns.filter((v) => v.severity === 'unknown').length,
},
byStatus: {
open: vulns.filter((v) => v.status === 'open').length,
fixed: vulns.filter((v) => v.status === 'fixed').length,
wont_fix: vulns.filter((v) => v.status === 'wont_fix').length,
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
excepted: vulns.filter((v) => v.status === 'excepted').length,
},
total: vulns.length,
bySeverity: {
critical: vulns.filter((v) => v.severity === 'critical').length,
high: vulns.filter((v) => v.severity === 'high').length,
medium: vulns.filter((v) => v.severity === 'medium').length,
low: vulns.filter((v) => v.severity === 'low').length,
unknown: vulns.filter((v) => v.severity === 'unknown').length,
},
byStatus: {
open: vulns.filter((v) => v.status === 'open').length,
fixed: vulns.filter((v) => v.status === 'fixed').length,
wont_fix: vulns.filter((v) => v.status === 'wont_fix').length,
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
excepted: vulns.filter((v) => v.status === 'excepted').length,
},
withExceptions: vulns.filter((v) => v.hasException).length,
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
computedAt: MockVulnerabilityApiService.FixedNowIso,
@@ -409,24 +409,24 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
fileSize: 1024 * (request.includeComponents ? 50 : 20),
traceId,
};
this.mockExports.set(exportId, exportResponse);
return of(exportResponse).pipe(delay(500));
}
this.mockExports.set(exportId, exportResponse);
return of(exportResponse).pipe(delay(500));
}
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse> {
const traceId = options?.traceId ?? 'mock-trace-vuln-export-status';
const existing = this.mockExports.get(exportId);
if (existing) {
return of(existing).pipe(delay(100));
}
return of({
exportId,
status: 'failed' as const,
traceId,
error: { code: 'ERR_EXPORT_NOT_FOUND', message: 'Export not found' },
}).pipe(delay(100));
}
}
}
return of({
exportId,
status: 'failed' as const,
traceId,
error: { code: 'ERR_EXPORT_NOT_FOUND', message: 'Export not found' },
}).pipe(delay(100));
}
}

View File

@@ -1,208 +1,208 @@
export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
export type VulnerabilityStatus = 'open' | 'fixed' | 'wont_fix' | 'in_progress' | 'excepted';
/**
* Workflow action types for vulnerability lifecycle.
*/
export type VulnWorkflowAction = 'open' | 'ack' | 'close' | 'reopen' | 'export';
/**
* Actor types for workflow actions.
*/
export type VulnActorType = 'user' | 'service' | 'automation';
export interface Vulnerability {
readonly vulnId: string;
readonly cveId: string;
readonly title: string;
readonly description?: string;
readonly severity: VulnerabilitySeverity;
readonly cvssScore?: number;
readonly cvssVector?: string;
readonly status: VulnerabilityStatus;
readonly publishedAt?: string;
readonly modifiedAt?: string;
readonly affectedComponents: readonly AffectedComponent[];
readonly references?: readonly string[];
readonly hasException?: boolean;
readonly exceptionId?: string;
/** ETag for optimistic concurrency. */
readonly etag?: string;
/** Reachability score from signals integration. */
readonly reachabilityScore?: number;
/** Reachability status from signals. */
readonly reachabilityStatus?: 'reachable' | 'unreachable' | 'unknown';
}
export interface AffectedComponent {
readonly purl: string;
readonly name: string;
readonly version: string;
readonly fixedVersion?: string;
readonly assetIds: readonly string[];
}
export interface VulnerabilityStats {
readonly total: number;
readonly bySeverity: Record<VulnerabilitySeverity, number>;
readonly byStatus: Record<VulnerabilityStatus, number>;
readonly withExceptions: number;
readonly criticalOpen: number;
/** Last computation timestamp. */
readonly computedAt?: string;
/** Trace ID for the stats computation. */
readonly traceId?: string;
}
export interface VulnerabilitiesQueryOptions {
readonly severity?: VulnerabilitySeverity | 'all';
readonly status?: VulnerabilityStatus | 'all';
readonly search?: string;
readonly hasException?: boolean;
readonly limit?: number;
readonly offset?: number;
readonly page?: number;
readonly pageSize?: number;
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
/** Filter by reachability status. */
readonly reachability?: 'reachable' | 'unreachable' | 'unknown' | 'all';
/** Include reachability data in response. */
readonly includeReachability?: boolean;
}
export interface VulnerabilitiesResponse {
readonly items: readonly Vulnerability[];
readonly total: number;
readonly hasMore?: boolean;
readonly page?: number;
readonly pageSize?: number;
/** ETag for the response. */
readonly etag?: string;
/** Trace ID for the request. */
readonly traceId?: string;
}
/**
* Workflow action request for Findings Ledger integration.
* Implements WEB-VULN-29-002 contract.
*/
export interface VulnWorkflowRequest {
/** Workflow action type. */
readonly action: VulnWorkflowAction;
/** Finding/vulnerability ID. */
readonly findingId: string;
/** Reason code for the action. */
readonly reasonCode?: string;
/** Optional comment. */
readonly comment?: string;
/** Attachments for the action. */
readonly attachments?: readonly VulnWorkflowAttachment[];
/** Actor performing the action. */
readonly actor: VulnWorkflowActor;
/** Additional metadata. */
readonly metadata?: Record<string, unknown>;
}
/**
* Attachment for workflow actions.
*/
export interface VulnWorkflowAttachment {
readonly name: string;
readonly digest: string;
readonly contentType?: string;
readonly size?: number;
}
/**
* Actor for workflow actions.
*/
export interface VulnWorkflowActor {
readonly subject: string;
readonly type: VulnActorType;
readonly name?: string;
readonly email?: string;
}
/**
* Workflow action response from Findings Ledger.
*/
export interface VulnWorkflowResponse {
/** Action status. */
readonly status: 'accepted' | 'rejected' | 'pending';
/** Ledger event ID for correlation. */
readonly ledgerEventId: string;
/** ETag for optimistic concurrency. */
readonly etag: string;
/** Trace ID for the request. */
readonly traceId: string;
/** Correlation ID. */
readonly correlationId: string;
/** Error details if rejected. */
readonly error?: VulnWorkflowError;
}
/**
* Workflow error response.
*/
export interface VulnWorkflowError {
readonly code: string;
readonly message: string;
readonly details?: Record<string, unknown>;
}
/**
* Export request for vulnerability data.
*/
export interface VulnExportRequest {
/** Format for export. */
readonly format: 'csv' | 'json' | 'cyclonedx' | 'spdx';
/** Filter options. */
readonly filter?: VulnerabilitiesQueryOptions;
/** Include affected components. */
readonly includeComponents?: boolean;
/** Include reachability data. */
readonly includeReachability?: boolean;
/** Maximum records (for large exports). */
readonly limit?: number;
}
/**
* Export response with signed download URL.
*/
export interface VulnExportResponse {
/** Export job ID. */
readonly exportId: string;
/** Current status. */
readonly status: 'pending' | 'processing' | 'completed' | 'failed';
/** Signed download URL (when completed). */
readonly downloadUrl?: string;
/** URL expiration timestamp. */
readonly expiresAt?: string;
/** Record count. */
readonly recordCount?: number;
/** File size in bytes. */
readonly fileSize?: number;
/** Trace ID. */
readonly traceId: string;
/** Error if failed. */
readonly error?: VulnWorkflowError;
}
/**
* Request logging metadata for observability.
*/
export interface VulnRequestLog {
readonly requestId: string;
readonly traceId: string;
readonly tenantId: string;
readonly projectId?: string;
readonly operation: string;
readonly path: string;
readonly method: string;
readonly timestamp: string;
readonly durationMs?: number;
readonly statusCode?: number;
readonly error?: string;
}
export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
export type VulnerabilityStatus = 'open' | 'fixed' | 'wont_fix' | 'in_progress' | 'excepted';
/**
* Workflow action types for vulnerability lifecycle.
*/
export type VulnWorkflowAction = 'open' | 'ack' | 'close' | 'reopen' | 'export';
/**
* Actor types for workflow actions.
*/
export type VulnActorType = 'user' | 'service' | 'automation';
export interface Vulnerability {
readonly vulnId: string;
readonly cveId: string;
readonly title: string;
readonly description?: string;
readonly severity: VulnerabilitySeverity;
readonly cvssScore?: number;
readonly cvssVector?: string;
readonly status: VulnerabilityStatus;
readonly publishedAt?: string;
readonly modifiedAt?: string;
readonly affectedComponents: readonly AffectedComponent[];
readonly references?: readonly string[];
readonly hasException?: boolean;
readonly exceptionId?: string;
/** ETag for optimistic concurrency. */
readonly etag?: string;
/** Reachability score from signals integration. */
readonly reachabilityScore?: number;
/** Reachability status from signals. */
readonly reachabilityStatus?: 'reachable' | 'unreachable' | 'unknown';
}
export interface AffectedComponent {
readonly purl: string;
readonly name: string;
readonly version: string;
readonly fixedVersion?: string;
readonly assetIds: readonly string[];
}
export interface VulnerabilityStats {
readonly total: number;
readonly bySeverity: Record<VulnerabilitySeverity, number>;
readonly byStatus: Record<VulnerabilityStatus, number>;
readonly withExceptions: number;
readonly criticalOpen: number;
/** Last computation timestamp. */
readonly computedAt?: string;
/** Trace ID for the stats computation. */
readonly traceId?: string;
}
export interface VulnerabilitiesQueryOptions {
readonly severity?: VulnerabilitySeverity | 'all';
readonly status?: VulnerabilityStatus | 'all';
readonly search?: string;
readonly hasException?: boolean;
readonly limit?: number;
readonly offset?: number;
readonly page?: number;
readonly pageSize?: number;
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
/** Filter by reachability status. */
readonly reachability?: 'reachable' | 'unreachable' | 'unknown' | 'all';
/** Include reachability data in response. */
readonly includeReachability?: boolean;
}
export interface VulnerabilitiesResponse {
readonly items: readonly Vulnerability[];
readonly total: number;
readonly hasMore?: boolean;
readonly page?: number;
readonly pageSize?: number;
/** ETag for the response. */
readonly etag?: string;
/** Trace ID for the request. */
readonly traceId?: string;
}
/**
* Workflow action request for Findings Ledger integration.
* Implements WEB-VULN-29-002 contract.
*/
export interface VulnWorkflowRequest {
/** Workflow action type. */
readonly action: VulnWorkflowAction;
/** Finding/vulnerability ID. */
readonly findingId: string;
/** Reason code for the action. */
readonly reasonCode?: string;
/** Optional comment. */
readonly comment?: string;
/** Attachments for the action. */
readonly attachments?: readonly VulnWorkflowAttachment[];
/** Actor performing the action. */
readonly actor: VulnWorkflowActor;
/** Additional metadata. */
readonly metadata?: Record<string, unknown>;
}
/**
* Attachment for workflow actions.
*/
export interface VulnWorkflowAttachment {
readonly name: string;
readonly digest: string;
readonly contentType?: string;
readonly size?: number;
}
/**
* Actor for workflow actions.
*/
export interface VulnWorkflowActor {
readonly subject: string;
readonly type: VulnActorType;
readonly name?: string;
readonly email?: string;
}
/**
* Workflow action response from Findings Ledger.
*/
export interface VulnWorkflowResponse {
/** Action status. */
readonly status: 'accepted' | 'rejected' | 'pending';
/** Ledger event ID for correlation. */
readonly ledgerEventId: string;
/** ETag for optimistic concurrency. */
readonly etag: string;
/** Trace ID for the request. */
readonly traceId: string;
/** Correlation ID. */
readonly correlationId: string;
/** Error details if rejected. */
readonly error?: VulnWorkflowError;
}
/**
* Workflow error response.
*/
export interface VulnWorkflowError {
readonly code: string;
readonly message: string;
readonly details?: Record<string, unknown>;
}
/**
* Export request for vulnerability data.
*/
export interface VulnExportRequest {
/** Format for export. */
readonly format: 'csv' | 'json' | 'cyclonedx' | 'spdx';
/** Filter options. */
readonly filter?: VulnerabilitiesQueryOptions;
/** Include affected components. */
readonly includeComponents?: boolean;
/** Include reachability data. */
readonly includeReachability?: boolean;
/** Maximum records (for large exports). */
readonly limit?: number;
}
/**
* Export response with signed download URL.
*/
export interface VulnExportResponse {
/** Export job ID. */
readonly exportId: string;
/** Current status. */
readonly status: 'pending' | 'processing' | 'completed' | 'failed';
/** Signed download URL (when completed). */
readonly downloadUrl?: string;
/** URL expiration timestamp. */
readonly expiresAt?: string;
/** Record count. */
readonly recordCount?: number;
/** File size in bytes. */
readonly fileSize?: number;
/** Trace ID. */
readonly traceId: string;
/** Error if failed. */
readonly error?: VulnWorkflowError;
}
/**
* Request logging metadata for observability.
*/
export interface VulnRequestLog {
readonly requestId: string;
readonly traceId: string;
readonly tenantId: string;
readonly projectId?: string;
readonly operation: string;
readonly path: string;
readonly method: string;
readonly timestamp: string;
readonly durationMs?: number;
readonly statusCode?: number;
readonly error?: string;
}

View File

@@ -68,7 +68,7 @@ export const STEP_TYPES: StepTypeDefinition[] = [
label: 'Script',
description: 'Execute a custom script or command',
icon: 'code',
color: '#6366f1',
color: '#D4920A',
defaultConfig: { command: '', timeout: 300 },
},
{

View File

@@ -1,171 +1,171 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, firstValueFrom, from, throwError } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { AppConfigService } from '../config/app-config.service';
import { DpopService } from './dpop/dpop.service';
import { AuthorityAuthService } from './authority-auth.service';
const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
@Injectable()
export class AuthHttpInterceptor implements HttpInterceptor {
private excludedOrigins: Set<string> | null = null;
private tokenEndpoint: string | null = null;
private authorityResolved = false;
constructor(
private readonly auth: AuthorityAuthService,
private readonly config: AppConfigService,
private readonly dpop: DpopService
) {
// lazy resolve authority configuration in intercept to allow APP_INITIALIZER to run first
}
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
this.ensureAuthorityInfo();
if (request.headers.has('Authorization') || this.shouldSkip(request.url)) {
return next.handle(request);
}
return from(
this.auth.getAuthHeadersForRequest(
this.resolveAbsoluteUrl(request.url),
request.method
)
).pipe(
switchMap((headers) => {
if (!headers) {
return next.handle(request);
}
const authorizedRequest = request.clone({
setHeaders: {
Authorization: headers.authorization,
DPoP: headers.dpop,
},
headers: request.headers.set(RETRY_HEADER, '0'),
});
return next.handle(authorizedRequest);
}),
catchError((error: HttpErrorResponse) =>
this.handleError(request, error, next)
)
);
}
private handleError(
request: HttpRequest<unknown>,
error: HttpErrorResponse,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
if (error.status !== 401) {
return throwError(() => error);
}
const nonce = error.headers?.get('DPoP-Nonce');
if (!nonce) {
return throwError(() => error);
}
if (request.headers.get(RETRY_HEADER) === '1') {
return throwError(() => error);
}
return from(this.retryWithNonce(request, nonce, next)).pipe(
catchError(() => throwError(() => error))
);
}
private async retryWithNonce(
request: HttpRequest<unknown>,
nonce: string,
next: HttpHandler
): Promise<HttpEvent<unknown>> {
await this.dpop.setNonce(nonce);
const headers = await this.auth.getAuthHeadersForRequest(
this.resolveAbsoluteUrl(request.url),
request.method
);
if (!headers) {
throw new Error('Unable to refresh authorization headers after nonce.');
}
const retried = request.clone({
setHeaders: {
Authorization: headers.authorization,
DPoP: headers.dpop,
},
headers: request.headers.set(RETRY_HEADER, '1'),
});
return firstValueFrom(next.handle(retried));
}
private shouldSkip(url: string): boolean {
this.ensureAuthorityInfo();
const absolute = this.resolveAbsoluteUrl(url);
if (!absolute) {
return false;
}
try {
const resolved = new URL(absolute);
if (resolved.pathname.endsWith('/config.json')) {
return true;
}
if (this.tokenEndpoint && absolute.startsWith(this.tokenEndpoint)) {
return true;
}
const origin = resolved.origin;
return this.excludedOrigins?.has(origin) ?? false;
} catch {
return false;
}
}
private resolveAbsoluteUrl(url: string): string {
try {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
const base =
typeof window !== 'undefined' && window.location
? window.location.origin
: undefined;
return base ? new URL(url, base).toString() : url;
} catch {
return url;
}
}
private ensureAuthorityInfo(): void {
if (this.authorityResolved) {
return;
}
try {
const authority = this.config.authority;
this.tokenEndpoint = new URL(
authority.tokenEndpoint,
authority.issuer
).toString();
this.excludedOrigins = new Set<string>([
this.tokenEndpoint,
new URL(authority.authorizeEndpoint, authority.issuer).origin,
]);
this.authorityResolved = true;
} catch {
// Configuration not yet loaded; interceptor will retry on the next request.
}
}
}
import {
HttpErrorResponse,
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, firstValueFrom, from, throwError } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { AppConfigService } from '../config/app-config.service';
import { DpopService } from './dpop/dpop.service';
import { AuthorityAuthService } from './authority-auth.service';
const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
@Injectable()
export class AuthHttpInterceptor implements HttpInterceptor {
private excludedOrigins: Set<string> | null = null;
private tokenEndpoint: string | null = null;
private authorityResolved = false;
constructor(
private readonly auth: AuthorityAuthService,
private readonly config: AppConfigService,
private readonly dpop: DpopService
) {
// lazy resolve authority configuration in intercept to allow APP_INITIALIZER to run first
}
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
this.ensureAuthorityInfo();
if (request.headers.has('Authorization') || this.shouldSkip(request.url)) {
return next.handle(request);
}
return from(
this.auth.getAuthHeadersForRequest(
this.resolveAbsoluteUrl(request.url),
request.method
)
).pipe(
switchMap((headers) => {
if (!headers) {
return next.handle(request);
}
const authorizedRequest = request.clone({
setHeaders: {
Authorization: headers.authorization,
DPoP: headers.dpop,
},
headers: request.headers.set(RETRY_HEADER, '0'),
});
return next.handle(authorizedRequest);
}),
catchError((error: HttpErrorResponse) =>
this.handleError(request, error, next)
)
);
}
private handleError(
request: HttpRequest<unknown>,
error: HttpErrorResponse,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
if (error.status !== 401) {
return throwError(() => error);
}
const nonce = error.headers?.get('DPoP-Nonce');
if (!nonce) {
return throwError(() => error);
}
if (request.headers.get(RETRY_HEADER) === '1') {
return throwError(() => error);
}
return from(this.retryWithNonce(request, nonce, next)).pipe(
catchError(() => throwError(() => error))
);
}
private async retryWithNonce(
request: HttpRequest<unknown>,
nonce: string,
next: HttpHandler
): Promise<HttpEvent<unknown>> {
await this.dpop.setNonce(nonce);
const headers = await this.auth.getAuthHeadersForRequest(
this.resolveAbsoluteUrl(request.url),
request.method
);
if (!headers) {
throw new Error('Unable to refresh authorization headers after nonce.');
}
const retried = request.clone({
setHeaders: {
Authorization: headers.authorization,
DPoP: headers.dpop,
},
headers: request.headers.set(RETRY_HEADER, '1'),
});
return firstValueFrom(next.handle(retried));
}
private shouldSkip(url: string): boolean {
this.ensureAuthorityInfo();
const absolute = this.resolveAbsoluteUrl(url);
if (!absolute) {
return false;
}
try {
const resolved = new URL(absolute);
if (resolved.pathname.endsWith('/config.json')) {
return true;
}
if (this.tokenEndpoint && absolute.startsWith(this.tokenEndpoint)) {
return true;
}
const origin = resolved.origin;
return this.excludedOrigins?.has(origin) ?? false;
} catch {
return false;
}
}
private resolveAbsoluteUrl(url: string): string {
try {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
const base =
typeof window !== 'undefined' && window.location
? window.location.origin
: undefined;
return base ? new URL(url, base).toString() : url;
} catch {
return url;
}
}
private ensureAuthorityInfo(): void {
if (this.authorityResolved) {
return;
}
try {
const authority = this.config.authority;
this.tokenEndpoint = new URL(
authority.tokenEndpoint,
authority.issuer
).toString();
this.excludedOrigins = new Set<string>([
this.tokenEndpoint,
new URL(authority.authorizeEndpoint, authority.issuer).origin,
]);
this.authorityResolved = true;
} catch {
// Configuration not yet loaded; interceptor will retry on the next request.
}
}
}

View File

@@ -1,56 +1,56 @@
export interface AuthTokens {
readonly accessToken: string;
readonly expiresAtEpochMs: number;
readonly refreshToken?: string;
readonly tokenType: 'Bearer';
readonly scope: string;
}
export interface AuthIdentity {
readonly subject: string;
readonly name?: string;
readonly email?: string;
readonly roles: readonly string[];
readonly idToken?: string;
}
export interface AuthSession {
readonly tokens: AuthTokens;
readonly identity: AuthIdentity;
/**
* SHA-256 JWK thumbprint of the active DPoP key pair.
*/
readonly dpopKeyThumbprint: string;
readonly issuedAtEpochMs: number;
readonly tenantId: string | null;
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly authenticationTimeEpochMs: number | null;
readonly freshAuthActive: boolean;
readonly freshAuthExpiresAtEpochMs: number | null;
}
export interface PersistedSessionMetadata {
readonly subject: string;
readonly expiresAtEpochMs: number;
readonly issuedAtEpochMs: number;
readonly dpopKeyThumbprint: string;
readonly tenantId?: string | null;
}
export type AuthStatus =
| 'unauthenticated'
| 'authenticated'
| 'refreshing'
| 'loading';
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
export type AuthErrorReason =
| 'invalid_state'
| 'token_exchange_failed'
| 'refresh_failed'
| 'dpop_generation_failed'
| 'configuration_missing';
export interface AuthTokens {
readonly accessToken: string;
readonly expiresAtEpochMs: number;
readonly refreshToken?: string;
readonly tokenType: 'Bearer';
readonly scope: string;
}
export interface AuthIdentity {
readonly subject: string;
readonly name?: string;
readonly email?: string;
readonly roles: readonly string[];
readonly idToken?: string;
}
export interface AuthSession {
readonly tokens: AuthTokens;
readonly identity: AuthIdentity;
/**
* SHA-256 JWK thumbprint of the active DPoP key pair.
*/
readonly dpopKeyThumbprint: string;
readonly issuedAtEpochMs: number;
readonly tenantId: string | null;
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly authenticationTimeEpochMs: number | null;
readonly freshAuthActive: boolean;
readonly freshAuthExpiresAtEpochMs: number | null;
}
export interface PersistedSessionMetadata {
readonly subject: string;
readonly expiresAtEpochMs: number;
readonly issuedAtEpochMs: number;
readonly dpopKeyThumbprint: string;
readonly tenantId?: string | null;
}
export type AuthStatus =
| 'unauthenticated'
| 'authenticated'
| 'refreshing'
| 'loading';
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
export type AuthErrorReason =
| 'invalid_state'
| 'token_exchange_failed'
| 'refresh_failed'
| 'dpop_generation_failed'
| 'configuration_missing';

View File

@@ -1,55 +1,55 @@
import { TestBed } from '@angular/core/testing';
import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
import { AuthSessionStore } from './auth-session.store';
describe('AuthSessionStore', () => {
let store: AuthSessionStore;
beforeEach(() => {
sessionStorage.clear();
TestBed.configureTestingModule({
providers: [AuthSessionStore],
});
store = TestBed.inject(AuthSessionStore);
});
it('persists minimal metadata when session is set', () => {
const tokens: AuthTokens = {
accessToken: 'token-abc',
expiresAtEpochMs: Date.now() + 120_000,
refreshToken: 'refresh-xyz',
scope: 'openid ui.read',
tokenType: 'Bearer',
};
const session: AuthSession = {
tokens,
identity: {
subject: 'user-123',
name: 'Alex Operator',
roles: ['ui.read'],
},
dpopKeyThumbprint: 'thumbprint-1',
issuedAtEpochMs: Date.now(),
tenantId: 'tenant-default',
scopes: ['ui.read'],
audiences: ['console'],
authenticationTimeEpochMs: Date.now(),
freshAuthActive: true,
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
};
store.setSession(session);
const persisted = sessionStorage.getItem(SESSION_STORAGE_KEY);
expect(persisted).toBeTruthy();
const parsed = JSON.parse(persisted ?? '{}');
expect(parsed.subject).toBe('user-123');
expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
expect(parsed.tenantId).toBe('tenant-default');
store.clear();
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
});
});
import { TestBed } from '@angular/core/testing';
import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
import { AuthSessionStore } from './auth-session.store';
describe('AuthSessionStore', () => {
let store: AuthSessionStore;
beforeEach(() => {
sessionStorage.clear();
TestBed.configureTestingModule({
providers: [AuthSessionStore],
});
store = TestBed.inject(AuthSessionStore);
});
it('persists minimal metadata when session is set', () => {
const tokens: AuthTokens = {
accessToken: 'token-abc',
expiresAtEpochMs: Date.now() + 120_000,
refreshToken: 'refresh-xyz',
scope: 'openid ui.read',
tokenType: 'Bearer',
};
const session: AuthSession = {
tokens,
identity: {
subject: 'user-123',
name: 'Alex Operator',
roles: ['ui.read'],
},
dpopKeyThumbprint: 'thumbprint-1',
issuedAtEpochMs: Date.now(),
tenantId: 'tenant-default',
scopes: ['ui.read'],
audiences: ['console'],
authenticationTimeEpochMs: Date.now(),
freshAuthActive: true,
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
};
store.setSession(session);
const persisted = sessionStorage.getItem(SESSION_STORAGE_KEY);
expect(persisted).toBeTruthy();
const parsed = JSON.parse(persisted ?? '{}');
expect(parsed.subject).toBe('user-123');
expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
expect(parsed.tenantId).toBe('tenant-default');
store.clear();
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
});
});

View File

@@ -1,129 +1,129 @@
import { Injectable, computed, signal } from '@angular/core';
import {
AuthSession,
AuthStatus,
PersistedSessionMetadata,
SESSION_STORAGE_KEY,
} from './auth-session.model';
@Injectable({
providedIn: 'root',
})
export class AuthSessionStore {
private readonly sessionSignal = signal<AuthSession | null>(null);
private readonly statusSignal = signal<AuthStatus>('unauthenticated');
private readonly persistedSignal =
signal<PersistedSessionMetadata | null>(this.readPersistedMetadata());
readonly session = computed(() => this.sessionSignal());
readonly status = computed(() => this.statusSignal());
readonly identity = computed(() => this.sessionSignal()?.identity ?? null);
readonly subjectHint = computed(
() =>
this.sessionSignal()?.identity.subject ??
this.persistedSignal()?.subject ??
null
);
readonly expiresAtEpochMs = computed(
() => this.sessionSignal()?.tokens.expiresAtEpochMs ?? null
);
readonly isAuthenticated = computed(
() => this.sessionSignal() !== null && this.statusSignal() !== 'loading'
);
readonly tenantId = computed(
() =>
this.sessionSignal()?.tenantId ??
this.persistedSignal()?.tenantId ??
null
);
setStatus(status: AuthStatus): void {
this.statusSignal.set(status);
}
setSession(session: AuthSession | null): void {
this.sessionSignal.set(session);
if (!session) {
this.statusSignal.set('unauthenticated');
this.persistedSignal.set(null);
this.clearPersistedMetadata();
return;
}
this.statusSignal.set('authenticated');
const metadata: PersistedSessionMetadata = {
subject: session.identity.subject,
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
issuedAtEpochMs: session.issuedAtEpochMs,
dpopKeyThumbprint: session.dpopKeyThumbprint,
tenantId: session.tenantId,
};
this.persistedSignal.set(metadata);
this.persistMetadata(metadata);
}
clear(): void {
this.sessionSignal.set(null);
this.statusSignal.set('unauthenticated');
this.persistedSignal.set(null);
this.clearPersistedMetadata();
}
private readPersistedMetadata(): PersistedSessionMetadata | null {
if (typeof sessionStorage === 'undefined') {
return null;
}
try {
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PersistedSessionMetadata;
if (
typeof parsed.subject !== 'string' ||
typeof parsed.expiresAtEpochMs !== 'number' ||
typeof parsed.issuedAtEpochMs !== 'number' ||
typeof parsed.dpopKeyThumbprint !== 'string'
) {
return null;
}
const tenantId =
typeof parsed.tenantId === 'string'
? parsed.tenantId.trim() || null
: null;
return {
subject: parsed.subject,
expiresAtEpochMs: parsed.expiresAtEpochMs,
issuedAtEpochMs: parsed.issuedAtEpochMs,
dpopKeyThumbprint: parsed.dpopKeyThumbprint,
tenantId,
};
} catch {
return null;
}
}
private persistMetadata(metadata: PersistedSessionMetadata): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
}
private clearPersistedMetadata(): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.removeItem(SESSION_STORAGE_KEY);
}
getActiveTenantId(): string | null {
return this.tenantId();
}
}
import { Injectable, computed, signal } from '@angular/core';
import {
AuthSession,
AuthStatus,
PersistedSessionMetadata,
SESSION_STORAGE_KEY,
} from './auth-session.model';
@Injectable({
providedIn: 'root',
})
export class AuthSessionStore {
private readonly sessionSignal = signal<AuthSession | null>(null);
private readonly statusSignal = signal<AuthStatus>('unauthenticated');
private readonly persistedSignal =
signal<PersistedSessionMetadata | null>(this.readPersistedMetadata());
readonly session = computed(() => this.sessionSignal());
readonly status = computed(() => this.statusSignal());
readonly identity = computed(() => this.sessionSignal()?.identity ?? null);
readonly subjectHint = computed(
() =>
this.sessionSignal()?.identity.subject ??
this.persistedSignal()?.subject ??
null
);
readonly expiresAtEpochMs = computed(
() => this.sessionSignal()?.tokens.expiresAtEpochMs ?? null
);
readonly isAuthenticated = computed(
() => this.sessionSignal() !== null && this.statusSignal() !== 'loading'
);
readonly tenantId = computed(
() =>
this.sessionSignal()?.tenantId ??
this.persistedSignal()?.tenantId ??
null
);
setStatus(status: AuthStatus): void {
this.statusSignal.set(status);
}
setSession(session: AuthSession | null): void {
this.sessionSignal.set(session);
if (!session) {
this.statusSignal.set('unauthenticated');
this.persistedSignal.set(null);
this.clearPersistedMetadata();
return;
}
this.statusSignal.set('authenticated');
const metadata: PersistedSessionMetadata = {
subject: session.identity.subject,
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
issuedAtEpochMs: session.issuedAtEpochMs,
dpopKeyThumbprint: session.dpopKeyThumbprint,
tenantId: session.tenantId,
};
this.persistedSignal.set(metadata);
this.persistMetadata(metadata);
}
clear(): void {
this.sessionSignal.set(null);
this.statusSignal.set('unauthenticated');
this.persistedSignal.set(null);
this.clearPersistedMetadata();
}
private readPersistedMetadata(): PersistedSessionMetadata | null {
if (typeof sessionStorage === 'undefined') {
return null;
}
try {
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PersistedSessionMetadata;
if (
typeof parsed.subject !== 'string' ||
typeof parsed.expiresAtEpochMs !== 'number' ||
typeof parsed.issuedAtEpochMs !== 'number' ||
typeof parsed.dpopKeyThumbprint !== 'string'
) {
return null;
}
const tenantId =
typeof parsed.tenantId === 'string'
? parsed.tenantId.trim() || null
: null;
return {
subject: parsed.subject,
expiresAtEpochMs: parsed.expiresAtEpochMs,
issuedAtEpochMs: parsed.issuedAtEpochMs,
dpopKeyThumbprint: parsed.dpopKeyThumbprint,
tenantId,
};
} catch {
return null;
}
}
private persistMetadata(metadata: PersistedSessionMetadata): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
}
private clearPersistedMetadata(): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.removeItem(SESSION_STORAGE_KEY);
}
getActiveTenantId(): string | null {
return this.tenantId();
}
}

View File

@@ -1,45 +1,45 @@
import { Injectable } from '@angular/core';
const LOGIN_REQUEST_KEY = 'stellaops.auth.login.request';
export interface PendingLoginRequest {
readonly state: string;
readonly codeVerifier: string;
readonly createdAtEpochMs: number;
readonly returnUrl?: string;
readonly nonce?: string;
}
@Injectable({
providedIn: 'root',
})
export class AuthStorageService {
savePendingLogin(request: PendingLoginRequest): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.setItem(LOGIN_REQUEST_KEY, JSON.stringify(request));
}
consumePendingLogin(expectedState: string): PendingLoginRequest | null {
if (typeof sessionStorage === 'undefined') {
return null;
}
const raw = sessionStorage.getItem(LOGIN_REQUEST_KEY);
if (!raw) {
return null;
}
sessionStorage.removeItem(LOGIN_REQUEST_KEY);
try {
const request = JSON.parse(raw) as PendingLoginRequest;
if (request.state !== expectedState) {
return null;
}
return request;
} catch {
return null;
}
}
}
import { Injectable } from '@angular/core';
const LOGIN_REQUEST_KEY = 'stellaops.auth.login.request';
export interface PendingLoginRequest {
readonly state: string;
readonly codeVerifier: string;
readonly createdAtEpochMs: number;
readonly returnUrl?: string;
readonly nonce?: string;
}
@Injectable({
providedIn: 'root',
})
export class AuthStorageService {
savePendingLogin(request: PendingLoginRequest): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.setItem(LOGIN_REQUEST_KEY, JSON.stringify(request));
}
consumePendingLogin(expectedState: string): PendingLoginRequest | null {
if (typeof sessionStorage === 'undefined') {
return null;
}
const raw = sessionStorage.getItem(LOGIN_REQUEST_KEY);
if (!raw) {
return null;
}
sessionStorage.removeItem(LOGIN_REQUEST_KEY);
try {
const request = JSON.parse(raw) as PendingLoginRequest;
if (request.state !== expectedState) {
return null;
}
return request;
} catch {
return null;
}
}
}

View File

@@ -1,217 +1,217 @@
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
import {
StellaOpsScopes,
StellaOpsScope,
ScopeGroups,
hasScope,
hasAllScopes,
hasAnyScope,
} from './scopes';
/**
* User info from authentication.
*/
export interface AuthUser {
readonly id: string;
readonly email: string;
readonly name: string;
readonly tenantId: string;
readonly tenantName: string;
readonly roles: readonly string[];
readonly scopes: readonly StellaOpsScope[];
readonly picture?: string;
}
/**
* Injection token for Auth service.
*/
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
/**
* Auth service interface.
*/
export interface AuthService {
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
readonly user: ReturnType<typeof signal<AuthUser | null>>;
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
hasScope(scope: StellaOpsScope): boolean;
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
canViewGraph(): boolean;
canEditGraph(): boolean;
canExportGraph(): boolean;
canSimulate(): boolean;
// Orchestrator access (UI-ORCH-32-001)
canViewOrchestrator(): boolean;
canOperateOrchestrator(): boolean;
canManageOrchestratorQuotas(): boolean;
canInitiateBackfill(): boolean;
// Policy Studio access (UI-POLICY-20-003)
canViewPolicies(): boolean;
canAuthorPolicies(): boolean;
canEditPolicies(): boolean;
canReviewPolicies(): boolean;
canApprovePolicies(): boolean;
canOperatePolicies(): boolean;
canActivatePolicies(): boolean;
canSimulatePolicies(): boolean;
canPublishPolicies(): boolean;
canAuditPolicies(): boolean;
// Session management
logout?(): void;
}
// ============================================================================
// Mock Auth Service
// ============================================================================
const MOCK_USER: AuthUser = {
id: 'user-001',
email: 'developer@example.com',
name: 'Developer User',
tenantId: 'tenant-001',
tenantName: 'Acme Corp',
roles: ['developer', 'security-analyst'],
scopes: [
// Graph permissions
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.GRAPH_SIMULATE,
StellaOpsScopes.GRAPH_EXPORT,
// SBOM permissions
StellaOpsScopes.SBOM_READ,
// Policy permissions (Policy Studio - UI-POLICY-20-003)
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.POLICY_AUTHOR,
StellaOpsScopes.POLICY_EDIT,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_SUBMIT,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.POLICY_OPERATE,
StellaOpsScopes.POLICY_ACTIVATE,
StellaOpsScopes.POLICY_RUN,
StellaOpsScopes.POLICY_AUDIT,
// Scanner permissions
StellaOpsScopes.SCANNER_READ,
// Exception permissions
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.EXCEPTION_WRITE,
// Release permissions
StellaOpsScopes.RELEASE_READ,
// AOC permissions
StellaOpsScopes.AOC_READ,
// Orchestrator permissions (UI-ORCH-32-001)
StellaOpsScopes.ORCH_READ,
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
import {
StellaOpsScopes,
StellaOpsScope,
ScopeGroups,
hasScope,
hasAllScopes,
hasAnyScope,
} from './scopes';
/**
* User info from authentication.
*/
export interface AuthUser {
readonly id: string;
readonly email: string;
readonly name: string;
readonly tenantId: string;
readonly tenantName: string;
readonly roles: readonly string[];
readonly scopes: readonly StellaOpsScope[];
readonly picture?: string;
}
/**
* Injection token for Auth service.
*/
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
/**
* Auth service interface.
*/
export interface AuthService {
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
readonly user: ReturnType<typeof signal<AuthUser | null>>;
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
hasScope(scope: StellaOpsScope): boolean;
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
canViewGraph(): boolean;
canEditGraph(): boolean;
canExportGraph(): boolean;
canSimulate(): boolean;
// Orchestrator access (UI-ORCH-32-001)
canViewOrchestrator(): boolean;
canOperateOrchestrator(): boolean;
canManageOrchestratorQuotas(): boolean;
canInitiateBackfill(): boolean;
// Policy Studio access (UI-POLICY-20-003)
canViewPolicies(): boolean;
canAuthorPolicies(): boolean;
canEditPolicies(): boolean;
canReviewPolicies(): boolean;
canApprovePolicies(): boolean;
canOperatePolicies(): boolean;
canActivatePolicies(): boolean;
canSimulatePolicies(): boolean;
canPublishPolicies(): boolean;
canAuditPolicies(): boolean;
// Session management
logout?(): void;
}
// ============================================================================
// Mock Auth Service
// ============================================================================
const MOCK_USER: AuthUser = {
id: 'user-001',
email: 'developer@example.com',
name: 'Developer User',
tenantId: 'tenant-001',
tenantName: 'Acme Corp',
roles: ['developer', 'security-analyst'],
scopes: [
// Graph permissions
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.GRAPH_SIMULATE,
StellaOpsScopes.GRAPH_EXPORT,
// SBOM permissions
StellaOpsScopes.SBOM_READ,
// Policy permissions (Policy Studio - UI-POLICY-20-003)
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.POLICY_AUTHOR,
StellaOpsScopes.POLICY_EDIT,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_SUBMIT,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.POLICY_OPERATE,
StellaOpsScopes.POLICY_ACTIVATE,
StellaOpsScopes.POLICY_RUN,
StellaOpsScopes.POLICY_AUDIT,
// Scanner permissions
StellaOpsScopes.SCANNER_READ,
// Exception permissions
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.EXCEPTION_WRITE,
// Release permissions
StellaOpsScopes.RELEASE_READ,
// AOC permissions
StellaOpsScopes.AOC_READ,
// Orchestrator permissions (UI-ORCH-32-001)
StellaOpsScopes.ORCH_READ,
// UI permissions
StellaOpsScopes.UI_READ,
// Analytics permissions
StellaOpsScopes.ANALYTICS_READ,
],
};
@Injectable({ providedIn: 'root' })
export class MockAuthService implements AuthService {
readonly isAuthenticated = signal(true);
readonly user = signal<AuthUser | null>(MOCK_USER);
readonly scopes = computed(() => {
const u = this.user();
return u?.scopes ?? [];
});
hasScope(scope: StellaOpsScope): boolean {
return hasScope(this.scopes(), scope);
}
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
return hasAllScopes(this.scopes(), scopes);
}
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
return hasAnyScope(this.scopes(), scopes);
}
canViewGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_READ);
}
canEditGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
}
canExportGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
}
canSimulate(): boolean {
return this.hasAnyScope([
StellaOpsScopes.GRAPH_SIMULATE,
StellaOpsScopes.POLICY_SIMULATE,
]);
}
// Orchestrator access methods (UI-ORCH-32-001)
canViewOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_READ);
}
canOperateOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
}
canManageOrchestratorQuotas(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
}
canInitiateBackfill(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
}
// Policy Studio access methods (UI-POLICY-20-003)
canViewPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_READ);
}
canAuthorPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_AUTHOR);
}
canEditPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_EDIT);
}
canReviewPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_REVIEW);
}
canApprovePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_APPROVE);
}
canOperatePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_OPERATE);
}
canActivatePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_ACTIVATE);
}
canSimulatePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_SIMULATE);
}
canPublishPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_PUBLISH);
}
canAuditPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_AUDIT);
}
}
// Re-export scopes for convenience
export { StellaOpsScopes, ScopeGroups } from './scopes';
export type { StellaOpsScope } from './scopes';
@Injectable({ providedIn: 'root' })
export class MockAuthService implements AuthService {
readonly isAuthenticated = signal(true);
readonly user = signal<AuthUser | null>(MOCK_USER);
readonly scopes = computed(() => {
const u = this.user();
return u?.scopes ?? [];
});
hasScope(scope: StellaOpsScope): boolean {
return hasScope(this.scopes(), scope);
}
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
return hasAllScopes(this.scopes(), scopes);
}
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
return hasAnyScope(this.scopes(), scopes);
}
canViewGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_READ);
}
canEditGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
}
canExportGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
}
canSimulate(): boolean {
return this.hasAnyScope([
StellaOpsScopes.GRAPH_SIMULATE,
StellaOpsScopes.POLICY_SIMULATE,
]);
}
// Orchestrator access methods (UI-ORCH-32-001)
canViewOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_READ);
}
canOperateOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
}
canManageOrchestratorQuotas(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
}
canInitiateBackfill(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
}
// Policy Studio access methods (UI-POLICY-20-003)
canViewPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_READ);
}
canAuthorPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_AUTHOR);
}
canEditPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_EDIT);
}
canReviewPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_REVIEW);
}
canApprovePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_APPROVE);
}
canOperatePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_OPERATE);
}
canActivatePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_ACTIVATE);
}
canSimulatePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_SIMULATE);
}
canPublishPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_PUBLISH);
}
canAuditPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_AUDIT);
}
}
// Re-export scopes for convenience
export { StellaOpsScopes, ScopeGroups } from './scopes';
export type { StellaOpsScope } from './scopes';

View File

@@ -1,181 +1,181 @@
import { Injectable } from '@angular/core';
import { DPoPAlgorithm } from '../../config/app-config.model';
import { computeJwkThumbprint } from './jose-utilities';
const DB_NAME = 'stellaops-auth';
const STORE_NAME = 'dpopKeys';
const PRIMARY_KEY = 'primary';
const DB_VERSION = 1;
interface PersistedKeyPair {
readonly id: string;
readonly algorithm: DPoPAlgorithm;
readonly publicJwk: JsonWebKey;
readonly privateJwk: JsonWebKey;
readonly thumbprint: string;
readonly createdAtIso: string;
}
export interface LoadedDpopKeyPair {
readonly algorithm: DPoPAlgorithm;
readonly privateKey: CryptoKey;
readonly publicKey: CryptoKey;
readonly publicJwk: JsonWebKey;
readonly thumbprint: string;
}
@Injectable({
providedIn: 'root',
})
export class DpopKeyStore {
private dbPromise: Promise<IDBDatabase> | null = null;
async load(): Promise<LoadedDpopKeyPair | null> {
const record = await this.read();
if (!record) {
return null;
}
const [privateKey, publicKey] = await Promise.all([
crypto.subtle.importKey(
'jwk',
record.privateJwk,
this.toKeyAlgorithm(record.algorithm),
true,
['sign']
),
crypto.subtle.importKey(
'jwk',
record.publicJwk,
this.toKeyAlgorithm(record.algorithm),
true,
['verify']
),
]);
return {
algorithm: record.algorithm,
privateKey,
publicKey,
publicJwk: record.publicJwk,
thumbprint: record.thumbprint,
};
}
async save(
keyPair: CryptoKeyPair,
algorithm: DPoPAlgorithm
): Promise<LoadedDpopKeyPair> {
const [publicJwk, privateJwk] = await Promise.all([
crypto.subtle.exportKey('jwk', keyPair.publicKey),
crypto.subtle.exportKey('jwk', keyPair.privateKey),
]);
if (!publicJwk) {
throw new Error('Failed to export public JWK for DPoP key pair.');
}
const thumbprint = await computeJwkThumbprint(publicJwk);
const record: PersistedKeyPair = {
id: PRIMARY_KEY,
algorithm,
publicJwk,
privateJwk,
thumbprint,
createdAtIso: new Date().toISOString(),
};
await this.write(record);
return {
algorithm,
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
publicJwk,
thumbprint,
};
}
async clear(): Promise<void> {
const db = await this.openDb();
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
store.delete(PRIMARY_KEY)
);
}
async generate(algorithm: DPoPAlgorithm): Promise<LoadedDpopKeyPair> {
const algo = this.toKeyAlgorithm(algorithm);
const keyPair = await crypto.subtle.generateKey(algo, true, [
'sign',
'verify',
]);
const stored = await this.save(keyPair, algorithm);
return stored;
}
private async read(): Promise<PersistedKeyPair | null> {
const db = await this.openDb();
return transactionPromise(db, STORE_NAME, 'readonly', (store) =>
store.get(PRIMARY_KEY)
);
}
private async write(record: PersistedKeyPair): Promise<void> {
const db = await this.openDb();
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
store.put(record)
);
}
private toKeyAlgorithm(algorithm: DPoPAlgorithm): EcKeyImportParams {
switch (algorithm) {
case 'ES384':
return { name: 'ECDSA', namedCurve: 'P-384' };
case 'EdDSA':
throw new Error('EdDSA DPoP keys are not yet supported.');
case 'ES256':
default:
return { name: 'ECDSA', namedCurve: 'P-256' };
}
}
private async openDb(): Promise<IDBDatabase> {
if (typeof indexedDB === 'undefined') {
throw new Error('IndexedDB is not available for DPoP key persistence.');
}
if (!this.dbPromise) {
this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
return this.dbPromise;
}
}
function transactionPromise<T>(
db: IDBDatabase,
storeName: string,
mode: IDBTransactionMode,
executor: (store: IDBObjectStore) => IDBRequest<T>
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const transaction = db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const request = executor(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
transaction.onabort = () => reject(transaction.error);
});
}
import { Injectable } from '@angular/core';
import { DPoPAlgorithm } from '../../config/app-config.model';
import { computeJwkThumbprint } from './jose-utilities';
const DB_NAME = 'stellaops-auth';
const STORE_NAME = 'dpopKeys';
const PRIMARY_KEY = 'primary';
const DB_VERSION = 1;
interface PersistedKeyPair {
readonly id: string;
readonly algorithm: DPoPAlgorithm;
readonly publicJwk: JsonWebKey;
readonly privateJwk: JsonWebKey;
readonly thumbprint: string;
readonly createdAtIso: string;
}
export interface LoadedDpopKeyPair {
readonly algorithm: DPoPAlgorithm;
readonly privateKey: CryptoKey;
readonly publicKey: CryptoKey;
readonly publicJwk: JsonWebKey;
readonly thumbprint: string;
}
@Injectable({
providedIn: 'root',
})
export class DpopKeyStore {
private dbPromise: Promise<IDBDatabase> | null = null;
async load(): Promise<LoadedDpopKeyPair | null> {
const record = await this.read();
if (!record) {
return null;
}
const [privateKey, publicKey] = await Promise.all([
crypto.subtle.importKey(
'jwk',
record.privateJwk,
this.toKeyAlgorithm(record.algorithm),
true,
['sign']
),
crypto.subtle.importKey(
'jwk',
record.publicJwk,
this.toKeyAlgorithm(record.algorithm),
true,
['verify']
),
]);
return {
algorithm: record.algorithm,
privateKey,
publicKey,
publicJwk: record.publicJwk,
thumbprint: record.thumbprint,
};
}
async save(
keyPair: CryptoKeyPair,
algorithm: DPoPAlgorithm
): Promise<LoadedDpopKeyPair> {
const [publicJwk, privateJwk] = await Promise.all([
crypto.subtle.exportKey('jwk', keyPair.publicKey),
crypto.subtle.exportKey('jwk', keyPair.privateKey),
]);
if (!publicJwk) {
throw new Error('Failed to export public JWK for DPoP key pair.');
}
const thumbprint = await computeJwkThumbprint(publicJwk);
const record: PersistedKeyPair = {
id: PRIMARY_KEY,
algorithm,
publicJwk,
privateJwk,
thumbprint,
createdAtIso: new Date().toISOString(),
};
await this.write(record);
return {
algorithm,
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
publicJwk,
thumbprint,
};
}
async clear(): Promise<void> {
const db = await this.openDb();
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
store.delete(PRIMARY_KEY)
);
}
async generate(algorithm: DPoPAlgorithm): Promise<LoadedDpopKeyPair> {
const algo = this.toKeyAlgorithm(algorithm);
const keyPair = await crypto.subtle.generateKey(algo, true, [
'sign',
'verify',
]);
const stored = await this.save(keyPair, algorithm);
return stored;
}
private async read(): Promise<PersistedKeyPair | null> {
const db = await this.openDb();
return transactionPromise(db, STORE_NAME, 'readonly', (store) =>
store.get(PRIMARY_KEY)
);
}
private async write(record: PersistedKeyPair): Promise<void> {
const db = await this.openDb();
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
store.put(record)
);
}
private toKeyAlgorithm(algorithm: DPoPAlgorithm): EcKeyImportParams {
switch (algorithm) {
case 'ES384':
return { name: 'ECDSA', namedCurve: 'P-384' };
case 'EdDSA':
throw new Error('EdDSA DPoP keys are not yet supported.');
case 'ES256':
default:
return { name: 'ECDSA', namedCurve: 'P-256' };
}
}
private async openDb(): Promise<IDBDatabase> {
if (typeof indexedDB === 'undefined') {
throw new Error('IndexedDB is not available for DPoP key persistence.');
}
if (!this.dbPromise) {
this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
return this.dbPromise;
}
}
function transactionPromise<T>(
db: IDBDatabase,
storeName: string,
mode: IDBTransactionMode,
executor: (store: IDBObjectStore) => IDBRequest<T>
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const transaction = db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const request = executor(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
transaction.onabort = () => reject(transaction.error);
});
}

View File

@@ -1,103 +1,103 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { APP_CONFIG, AppConfig } from '../../config/app-config.model';
import { AppConfigService } from '../../config/app-config.service';
import { base64UrlDecode } from './jose-utilities';
import { DpopKeyStore } from './dpop-key-store';
import { DpopService } from './dpop.service';
describe('DpopService', () => {
const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
const config: AppConfig = {
authority: {
issuer: 'https://auth.stellaops.test/',
clientId: 'ui-client',
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
redirectUri: 'https://ui.stellaops.test/auth/callback',
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { APP_CONFIG, AppConfig } from '../../config/app-config.model';
import { AppConfigService } from '../../config/app-config.service';
import { base64UrlDecode } from './jose-utilities';
import { DpopKeyStore } from './dpop-key-store';
import { DpopService } from './dpop.service';
describe('DpopService', () => {
const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
const config: AppConfig = {
authority: {
issuer: 'https://auth.stellaops.test/',
clientId: 'ui-client',
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
redirectUri: 'https://ui.stellaops.test/auth/callback',
scope: 'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
audience: 'https://scanner.stellaops.test',
},
apiBaseUrls: {
authority: 'https://auth.stellaops.test',
scanner: 'https://scanner.stellaops.test',
policy: 'https://policy.stellaops.test',
concelier: 'https://concelier.stellaops.test',
attestor: 'https://attestor.stellaops.test',
},
};
beforeEach(async () => {
jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
AppConfigService,
DpopKeyStore,
DpopService,
{
provide: APP_CONFIG,
useValue: config,
},
],
});
});
afterEach(async () => {
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
const store = TestBed.inject(DpopKeyStore);
try {
await store.clear();
} catch {
// ignore cleanup issues in test environment
}
});
it('creates a DPoP proof with expected header values', async () => {
const appConfig = TestBed.inject(AppConfigService);
appConfig.setConfigForTesting(config);
const service = TestBed.inject(DpopService);
const proof = await service.createProof({
htm: 'get',
htu: 'https://scanner.stellaops.test/api/v1/scans',
});
const [rawHeader, rawPayload] = proof.split('.');
const header = JSON.parse(
new TextDecoder().decode(base64UrlDecode(rawHeader))
);
const payload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(rawPayload))
);
expect(header.typ).toBe('dpop+jwt');
expect(header.alg).toBe('ES256');
expect(header.jwk.kty).toBe('EC');
expect(payload.htm).toBe('GET');
expect(payload.htu).toBe('https://scanner.stellaops.test/api/v1/scans');
expect(typeof payload.iat).toBe('number');
expect(typeof payload.jti).toBe('string');
});
it('binds access token hash when provided', async () => {
const appConfig = TestBed.inject(AppConfigService);
appConfig.setConfigForTesting(config);
const service = TestBed.inject(DpopService);
const accessToken = 'sample-access-token';
const proof = await service.createProof({
htm: 'post',
htu: 'https://scanner.stellaops.test/api/v1/scans',
accessToken,
});
const payload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(proof.split('.')[1]))
);
expect(payload.ath).toBeDefined();
expect(typeof payload.ath).toBe('string');
});
});
audience: 'https://scanner.stellaops.test',
},
apiBaseUrls: {
authority: 'https://auth.stellaops.test',
scanner: 'https://scanner.stellaops.test',
policy: 'https://policy.stellaops.test',
concelier: 'https://concelier.stellaops.test',
attestor: 'https://attestor.stellaops.test',
},
};
beforeEach(async () => {
jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
AppConfigService,
DpopKeyStore,
DpopService,
{
provide: APP_CONFIG,
useValue: config,
},
],
});
});
afterEach(async () => {
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
const store = TestBed.inject(DpopKeyStore);
try {
await store.clear();
} catch {
// ignore cleanup issues in test environment
}
});
it('creates a DPoP proof with expected header values', async () => {
const appConfig = TestBed.inject(AppConfigService);
appConfig.setConfigForTesting(config);
const service = TestBed.inject(DpopService);
const proof = await service.createProof({
htm: 'get',
htu: 'https://scanner.stellaops.test/api/v1/scans',
});
const [rawHeader, rawPayload] = proof.split('.');
const header = JSON.parse(
new TextDecoder().decode(base64UrlDecode(rawHeader))
);
const payload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(rawPayload))
);
expect(header.typ).toBe('dpop+jwt');
expect(header.alg).toBe('ES256');
expect(header.jwk.kty).toBe('EC');
expect(payload.htm).toBe('GET');
expect(payload.htu).toBe('https://scanner.stellaops.test/api/v1/scans');
expect(typeof payload.iat).toBe('number');
expect(typeof payload.jti).toBe('string');
});
it('binds access token hash when provided', async () => {
const appConfig = TestBed.inject(AppConfigService);
appConfig.setConfigForTesting(config);
const service = TestBed.inject(DpopService);
const accessToken = 'sample-access-token';
const proof = await service.createProof({
htm: 'post',
htu: 'https://scanner.stellaops.test/api/v1/scans',
accessToken,
});
const payload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(proof.split('.')[1]))
);
expect(payload.ath).toBeDefined();
expect(typeof payload.ath).toBe('string');
});
});

View File

@@ -1,148 +1,148 @@
import { Injectable, computed, signal } from '@angular/core';
import { AppConfigService } from '../../config/app-config.service';
import { DPoPAlgorithm } from '../../config/app-config.model';
import { sha256, base64UrlEncode, derToJoseSignature } from './jose-utilities';
import { DpopKeyStore, LoadedDpopKeyPair } from './dpop-key-store';
export interface DpopProofOptions {
readonly htm: string;
readonly htu: string;
readonly accessToken?: string;
readonly nonce?: string | null;
}
@Injectable({
providedIn: 'root',
})
export class DpopService {
private keyPairPromise: Promise<LoadedDpopKeyPair> | null = null;
private readonly nonceSignal = signal<string | null>(null);
readonly nonce = computed(() => this.nonceSignal());
constructor(
private readonly config: AppConfigService,
private readonly store: DpopKeyStore
) {}
async setNonce(nonce: string | null): Promise<void> {
this.nonceSignal.set(nonce);
}
async getThumbprint(): Promise<string | null> {
const key = await this.getOrCreateKeyPair();
return key.thumbprint ?? null;
}
async rotateKey(): Promise<void> {
const algorithm = this.resolveAlgorithm();
this.keyPairPromise = this.store.generate(algorithm);
}
async createProof(options: DpopProofOptions): Promise<string> {
const keyPair = await this.getOrCreateKeyPair();
const header = {
typ: 'dpop+jwt',
alg: keyPair.algorithm,
jwk: keyPair.publicJwk,
};
const nowSeconds = Math.floor(Date.now() / 1000);
const payload: Record<string, unknown> = {
htm: options.htm.toUpperCase(),
htu: normalizeHtu(options.htu),
iat: nowSeconds,
jti: crypto.randomUUID ? crypto.randomUUID() : createRandomId(),
};
const nonce = options.nonce ?? this.nonceSignal();
if (nonce) {
payload['nonce'] = nonce;
}
if (options.accessToken) {
const accessTokenHash = await sha256(
new TextEncoder().encode(options.accessToken)
);
payload['ath'] = base64UrlEncode(accessTokenHash);
}
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = await crypto.subtle.sign(
{
name: 'ECDSA',
hash: this.resolveHashAlgorithm(keyPair.algorithm),
},
keyPair.privateKey,
new TextEncoder().encode(signingInput)
);
const joseSignature = base64UrlEncode(derToJoseSignature(signature));
return `${signingInput}.${joseSignature}`;
}
private async getOrCreateKeyPair(): Promise<LoadedDpopKeyPair> {
if (!this.keyPairPromise) {
this.keyPairPromise = this.loadKeyPair();
}
try {
return await this.keyPairPromise;
} catch (error) {
// Reset the memoized promise so a subsequent call can retry.
this.keyPairPromise = null;
throw error;
}
}
private async loadKeyPair(): Promise<LoadedDpopKeyPair> {
const algorithm = this.resolveAlgorithm();
try {
const existing = await this.store.load();
if (existing && existing.algorithm === algorithm) {
return existing;
}
} catch {
// fall through to regeneration
}
return this.store.generate(algorithm);
}
private resolveAlgorithm(): DPoPAlgorithm {
const authority = this.config.authority;
return authority.dpopAlgorithms?.[0] ?? 'ES256';
}
private resolveHashAlgorithm(algorithm: DPoPAlgorithm): string {
switch (algorithm) {
case 'ES384':
return 'SHA-384';
case 'ES256':
default:
return 'SHA-256';
}
}
}
function normalizeHtu(value: string): string {
try {
const base =
typeof window !== 'undefined' && window.location
? window.location.origin
: undefined;
const url = base ? new URL(value, base) : new URL(value);
url.hash = '';
return url.toString();
} catch {
return value;
}
}
function createRandomId(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
import { Injectable, computed, signal } from '@angular/core';
import { AppConfigService } from '../../config/app-config.service';
import { DPoPAlgorithm } from '../../config/app-config.model';
import { sha256, base64UrlEncode, derToJoseSignature } from './jose-utilities';
import { DpopKeyStore, LoadedDpopKeyPair } from './dpop-key-store';
export interface DpopProofOptions {
readonly htm: string;
readonly htu: string;
readonly accessToken?: string;
readonly nonce?: string | null;
}
@Injectable({
providedIn: 'root',
})
export class DpopService {
private keyPairPromise: Promise<LoadedDpopKeyPair> | null = null;
private readonly nonceSignal = signal<string | null>(null);
readonly nonce = computed(() => this.nonceSignal());
constructor(
private readonly config: AppConfigService,
private readonly store: DpopKeyStore
) {}
async setNonce(nonce: string | null): Promise<void> {
this.nonceSignal.set(nonce);
}
async getThumbprint(): Promise<string | null> {
const key = await this.getOrCreateKeyPair();
return key.thumbprint ?? null;
}
async rotateKey(): Promise<void> {
const algorithm = this.resolveAlgorithm();
this.keyPairPromise = this.store.generate(algorithm);
}
async createProof(options: DpopProofOptions): Promise<string> {
const keyPair = await this.getOrCreateKeyPair();
const header = {
typ: 'dpop+jwt',
alg: keyPair.algorithm,
jwk: keyPair.publicJwk,
};
const nowSeconds = Math.floor(Date.now() / 1000);
const payload: Record<string, unknown> = {
htm: options.htm.toUpperCase(),
htu: normalizeHtu(options.htu),
iat: nowSeconds,
jti: crypto.randomUUID ? crypto.randomUUID() : createRandomId(),
};
const nonce = options.nonce ?? this.nonceSignal();
if (nonce) {
payload['nonce'] = nonce;
}
if (options.accessToken) {
const accessTokenHash = await sha256(
new TextEncoder().encode(options.accessToken)
);
payload['ath'] = base64UrlEncode(accessTokenHash);
}
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = await crypto.subtle.sign(
{
name: 'ECDSA',
hash: this.resolveHashAlgorithm(keyPair.algorithm),
},
keyPair.privateKey,
new TextEncoder().encode(signingInput)
);
const joseSignature = base64UrlEncode(derToJoseSignature(signature));
return `${signingInput}.${joseSignature}`;
}
private async getOrCreateKeyPair(): Promise<LoadedDpopKeyPair> {
if (!this.keyPairPromise) {
this.keyPairPromise = this.loadKeyPair();
}
try {
return await this.keyPairPromise;
} catch (error) {
// Reset the memoized promise so a subsequent call can retry.
this.keyPairPromise = null;
throw error;
}
}
private async loadKeyPair(): Promise<LoadedDpopKeyPair> {
const algorithm = this.resolveAlgorithm();
try {
const existing = await this.store.load();
if (existing && existing.algorithm === algorithm) {
return existing;
}
} catch {
// fall through to regeneration
}
return this.store.generate(algorithm);
}
private resolveAlgorithm(): DPoPAlgorithm {
const authority = this.config.authority;
return authority.dpopAlgorithms?.[0] ?? 'ES256';
}
private resolveHashAlgorithm(algorithm: DPoPAlgorithm): string {
switch (algorithm) {
case 'ES384':
return 'SHA-384';
case 'ES256':
default:
return 'SHA-256';
}
}
}
function normalizeHtu(value: string): string {
try {
const base =
typeof window !== 'undefined' && window.location
? window.location.origin
: undefined;
const url = base ? new URL(value, base) : new URL(value);
url.hash = '';
return url.toString();
} catch {
return value;
}
}
function createRandomId(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}

View File

@@ -1,123 +1,123 @@
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest('SHA-256', data);
return new Uint8Array(digest);
}
export function base64UrlEncode(
input: ArrayBuffer | Uint8Array | string
): string {
let bytes: Uint8Array;
if (typeof input === 'string') {
bytes = new TextEncoder().encode(input);
} else if (input instanceof Uint8Array) {
bytes = input;
} else {
bytes = new Uint8Array(input);
}
let binary = '';
for (let i = 0; i < bytes.byteLength; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function base64UrlDecode(value: string): Uint8Array {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padding = normalized.length % 4;
const padded =
padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
export async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
const canonical = canonicalizeJwk(jwk);
const digest = await sha256(new TextEncoder().encode(canonical));
return base64UrlEncode(digest);
}
function canonicalizeJwk(jwk: JsonWebKey): string {
if (!jwk.kty) {
throw new Error('JWK must include "kty"');
}
if (jwk.kty === 'EC') {
const { crv, kty, x, y } = jwk;
if (!crv || !x || !y) {
throw new Error('EC JWK must include "crv", "x", and "y".');
}
return JSON.stringify({ crv, kty, x, y });
}
if (jwk.kty === 'OKP') {
const { crv, kty, x } = jwk;
if (!crv || !x) {
throw new Error('OKP JWK must include "crv" and "x".');
}
return JSON.stringify({ crv, kty, x });
}
throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
}
export function derToJoseSignature(der: ArrayBuffer): Uint8Array {
const bytes = new Uint8Array(der);
if (bytes[0] !== 0x30) {
// Some implementations already return raw (r || s) signature bytes.
if (bytes.length === 64) {
return bytes;
}
throw new Error('Invalid DER signature: expected sequence.');
}
let offset = 2; // skip SEQUENCE header and length (assume short form)
if (bytes[1] & 0x80) {
const lengthBytes = bytes[1] & 0x7f;
offset = 2 + lengthBytes;
}
if (bytes[offset] !== 0x02) {
throw new Error('Invalid DER signature: expected INTEGER for r.');
}
const rLength = bytes[offset + 1];
let r = bytes.slice(offset + 2, offset + 2 + rLength);
offset = offset + 2 + rLength;
if (bytes[offset] !== 0x02) {
throw new Error('Invalid DER signature: expected INTEGER for s.');
}
const sLength = bytes[offset + 1];
let s = bytes.slice(offset + 2, offset + 2 + sLength);
r = trimLeadingZeros(r);
s = trimLeadingZeros(s);
const targetLength = 32;
const signature = new Uint8Array(targetLength * 2);
signature.set(padStart(r, targetLength), 0);
signature.set(padStart(s, targetLength), targetLength);
return signature;
}
function trimLeadingZeros(bytes: Uint8Array): Uint8Array {
let start = 0;
while (start < bytes.length - 1 && bytes[start] === 0x00) {
start += 1;
}
return bytes.subarray(start);
}
function padStart(bytes: Uint8Array, length: number): Uint8Array {
if (bytes.length >= length) {
return bytes;
}
const padded = new Uint8Array(length);
padded.set(bytes, length - bytes.length);
return padded;
}
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest('SHA-256', data);
return new Uint8Array(digest);
}
export function base64UrlEncode(
input: ArrayBuffer | Uint8Array | string
): string {
let bytes: Uint8Array;
if (typeof input === 'string') {
bytes = new TextEncoder().encode(input);
} else if (input instanceof Uint8Array) {
bytes = input;
} else {
bytes = new Uint8Array(input);
}
let binary = '';
for (let i = 0; i < bytes.byteLength; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function base64UrlDecode(value: string): Uint8Array {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padding = normalized.length % 4;
const padded =
padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
export async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
const canonical = canonicalizeJwk(jwk);
const digest = await sha256(new TextEncoder().encode(canonical));
return base64UrlEncode(digest);
}
function canonicalizeJwk(jwk: JsonWebKey): string {
if (!jwk.kty) {
throw new Error('JWK must include "kty"');
}
if (jwk.kty === 'EC') {
const { crv, kty, x, y } = jwk;
if (!crv || !x || !y) {
throw new Error('EC JWK must include "crv", "x", and "y".');
}
return JSON.stringify({ crv, kty, x, y });
}
if (jwk.kty === 'OKP') {
const { crv, kty, x } = jwk;
if (!crv || !x) {
throw new Error('OKP JWK must include "crv" and "x".');
}
return JSON.stringify({ crv, kty, x });
}
throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
}
export function derToJoseSignature(der: ArrayBuffer): Uint8Array {
const bytes = new Uint8Array(der);
if (bytes[0] !== 0x30) {
// Some implementations already return raw (r || s) signature bytes.
if (bytes.length === 64) {
return bytes;
}
throw new Error('Invalid DER signature: expected sequence.');
}
let offset = 2; // skip SEQUENCE header and length (assume short form)
if (bytes[1] & 0x80) {
const lengthBytes = bytes[1] & 0x7f;
offset = 2 + lengthBytes;
}
if (bytes[offset] !== 0x02) {
throw new Error('Invalid DER signature: expected INTEGER for r.');
}
const rLength = bytes[offset + 1];
let r = bytes.slice(offset + 2, offset + 2 + rLength);
offset = offset + 2 + rLength;
if (bytes[offset] !== 0x02) {
throw new Error('Invalid DER signature: expected INTEGER for s.');
}
const sLength = bytes[offset + 1];
let s = bytes.slice(offset + 2, offset + 2 + sLength);
r = trimLeadingZeros(r);
s = trimLeadingZeros(s);
const targetLength = 32;
const signature = new Uint8Array(targetLength * 2);
signature.set(padStart(r, targetLength), 0);
signature.set(padStart(s, targetLength), targetLength);
return signature;
}
function trimLeadingZeros(bytes: Uint8Array): Uint8Array {
let start = 0;
while (start < bytes.length - 1 && bytes[start] === 0x00) {
start += 1;
}
return bytes.subarray(start);
}
function padStart(bytes: Uint8Array, length: number): Uint8Array {
if (bytes.length >= length) {
return bytes;
}
const padded = new Uint8Array(length);
padded.set(bytes, length - bytes.length);
return padded;
}

View File

@@ -1,20 +1,20 @@
export {
StellaOpsScopes,
StellaOpsScope,
ScopeGroups,
ScopeLabels,
hasScope,
hasAllScopes,
hasAnyScope,
} from './scopes';
export {
AuthUser,
AuthService,
AUTH_SERVICE,
MockAuthService,
} from './auth.service';
export {
StellaOpsScopes,
StellaOpsScope,
ScopeGroups,
ScopeLabels,
hasScope,
hasAllScopes,
hasAnyScope,
} from './scopes';
export {
AuthUser,
AuthService,
AUTH_SERVICE,
MockAuthService,
} from './auth.service';
export {
requireAuthGuard,
requireScopesGuard,
@@ -32,34 +32,34 @@ export {
requirePolicyAuditGuard,
requireAnalyticsViewerGuard,
} from './auth.guard';
export {
TenantActivationService,
TenantScope,
AuthDecision,
DenyReason,
AuthDecisionAudit,
ScopeCheckResult,
TenantContext,
JwtClaims,
} from './tenant-activation.service';
export {
TenantHttpInterceptor,
TENANT_HEADERS,
} from './tenant-http.interceptor';
export {
TenantPersistenceService,
PersistenceAuditMetadata,
TenantPersistenceCheck,
TenantStoragePath,
PersistenceAuditEvent,
} from './tenant-persistence.service';
export {
AbacService,
AbacMode,
AbacConfig,
AbacAuthResult,
} from './abac.service';
export {
TenantActivationService,
TenantScope,
AuthDecision,
DenyReason,
AuthDecisionAudit,
ScopeCheckResult,
TenantContext,
JwtClaims,
} from './tenant-activation.service';
export {
TenantHttpInterceptor,
TENANT_HEADERS,
} from './tenant-http.interceptor';
export {
TenantPersistenceService,
PersistenceAuditMetadata,
TenantPersistenceCheck,
TenantStoragePath,
PersistenceAuditEvent,
} from './tenant-persistence.service';
export {
AbacService,
AbacMode,
AbacConfig,
AbacAuthResult,
} from './abac.service';

View File

@@ -1,24 +1,24 @@
import { base64UrlEncode, sha256 } from './dpop/jose-utilities';
export interface PkcePair {
readonly verifier: string;
readonly challenge: string;
readonly method: 'S256';
}
const VERIFIER_BYTE_LENGTH = 32;
export async function createPkcePair(): Promise<PkcePair> {
const verifierBytes = new Uint8Array(VERIFIER_BYTE_LENGTH);
crypto.getRandomValues(verifierBytes);
const verifier = base64UrlEncode(verifierBytes);
const challengeBytes = await sha256(new TextEncoder().encode(verifier));
const challenge = base64UrlEncode(challengeBytes);
return {
verifier,
challenge,
method: 'S256',
};
}
import { base64UrlEncode, sha256 } from './dpop/jose-utilities';
export interface PkcePair {
readonly verifier: string;
readonly challenge: string;
readonly method: 'S256';
}
const VERIFIER_BYTE_LENGTH = 32;
export async function createPkcePair(): Promise<PkcePair> {
const verifierBytes = new Uint8Array(VERIFIER_BYTE_LENGTH);
crypto.getRandomValues(verifierBytes);
const verifier = base64UrlEncode(verifierBytes);
const challengeBytes = await sha256(new TextEncoder().encode(verifier));
const challenge = base64UrlEncode(challengeBytes);
return {
verifier,
challenge,
method: 'S256',
};
}

View File

@@ -1,65 +1,65 @@
/**
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
*
* This is a stub implementation to unblock Graph Explorer development.
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
*
* @see docs/modules/platform/architecture-overview.md
*/
/**
* All available StellaOps OAuth2 scopes.
*/
export const StellaOpsScopes = {
// Graph scopes
GRAPH_READ: 'graph:read',
GRAPH_WRITE: 'graph:write',
GRAPH_ADMIN: 'graph:admin',
GRAPH_EXPORT: 'graph:export',
GRAPH_SIMULATE: 'graph:simulate',
// SBOM scopes
SBOM_READ: 'sbom:read',
SBOM_WRITE: 'sbom:write',
SBOM_ATTEST: 'sbom:attest',
// Scanner scopes
SCANNER_READ: 'scanner:read',
SCANNER_WRITE: 'scanner:write',
SCANNER_SCAN: 'scanner:scan',
SCANNER_EXPORT: 'scanner:export',
// Policy scopes (full Policy Studio workflow - UI-POLICY-20-003)
POLICY_READ: 'policy:read',
POLICY_WRITE: 'policy:write',
POLICY_EVALUATE: 'policy:evaluate',
POLICY_SIMULATE: 'policy:simulate',
// Policy Studio authoring & review workflow
POLICY_AUTHOR: 'policy:author',
POLICY_EDIT: 'policy:edit',
POLICY_REVIEW: 'policy:review',
POLICY_SUBMIT: 'policy:submit',
POLICY_APPROVE: 'policy:approve',
// Policy operations & execution
POLICY_OPERATE: 'policy:operate',
POLICY_ACTIVATE: 'policy:activate',
POLICY_RUN: 'policy:run',
POLICY_PUBLISH: 'policy:publish', // Requires interactive auth
POLICY_PROMOTE: 'policy:promote', // Requires interactive auth
POLICY_AUDIT: 'policy:audit',
// Exception scopes
EXCEPTION_READ: 'exception:read',
EXCEPTION_WRITE: 'exception:write',
EXCEPTION_APPROVE: 'exception:approve',
// Advisory scopes
ADVISORY_READ: 'advisory:read',
// VEX scopes
VEX_READ: 'vex:read',
VEX_EXPORT: 'vex:export',
/**
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
*
* This is a stub implementation to unblock Graph Explorer development.
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
*
* @see docs/modules/platform/architecture-overview.md
*/
/**
* All available StellaOps OAuth2 scopes.
*/
export const StellaOpsScopes = {
// Graph scopes
GRAPH_READ: 'graph:read',
GRAPH_WRITE: 'graph:write',
GRAPH_ADMIN: 'graph:admin',
GRAPH_EXPORT: 'graph:export',
GRAPH_SIMULATE: 'graph:simulate',
// SBOM scopes
SBOM_READ: 'sbom:read',
SBOM_WRITE: 'sbom:write',
SBOM_ATTEST: 'sbom:attest',
// Scanner scopes
SCANNER_READ: 'scanner:read',
SCANNER_WRITE: 'scanner:write',
SCANNER_SCAN: 'scanner:scan',
SCANNER_EXPORT: 'scanner:export',
// Policy scopes (full Policy Studio workflow - UI-POLICY-20-003)
POLICY_READ: 'policy:read',
POLICY_WRITE: 'policy:write',
POLICY_EVALUATE: 'policy:evaluate',
POLICY_SIMULATE: 'policy:simulate',
// Policy Studio authoring & review workflow
POLICY_AUTHOR: 'policy:author',
POLICY_EDIT: 'policy:edit',
POLICY_REVIEW: 'policy:review',
POLICY_SUBMIT: 'policy:submit',
POLICY_APPROVE: 'policy:approve',
// Policy operations & execution
POLICY_OPERATE: 'policy:operate',
POLICY_ACTIVATE: 'policy:activate',
POLICY_RUN: 'policy:run',
POLICY_PUBLISH: 'policy:publish', // Requires interactive auth
POLICY_PROMOTE: 'policy:promote', // Requires interactive auth
POLICY_AUDIT: 'policy:audit',
// Exception scopes
EXCEPTION_READ: 'exception:read',
EXCEPTION_WRITE: 'exception:write',
EXCEPTION_APPROVE: 'exception:approve',
// Advisory scopes
ADVISORY_READ: 'advisory:read',
// VEX scopes
VEX_READ: 'vex:read',
VEX_EXPORT: 'vex:export',
// Release scopes
RELEASE_READ: 'release:read',
RELEASE_WRITE: 'release:write',
@@ -72,215 +72,215 @@ export const StellaOpsScopes = {
// AOC scopes
AOC_READ: 'aoc:read',
AOC_VERIFY: 'aoc:verify',
// Orchestrator scopes (UI-ORCH-32-001)
ORCH_READ: 'orch:read',
ORCH_OPERATE: 'orch:operate',
ORCH_QUOTA: 'orch:quota',
ORCH_BACKFILL: 'orch:backfill',
// UI scopes
UI_READ: 'ui.read',
UI_ADMIN: 'ui.admin',
// Admin scopes
ADMIN: 'admin',
TENANT_ADMIN: 'tenant:admin',
// Authority admin scopes
AUTHORITY_TENANTS_READ: 'authority:tenants.read',
AUTHORITY_TENANTS_WRITE: 'authority:tenants.write',
AUTHORITY_USERS_READ: 'authority:users.read',
AUTHORITY_USERS_WRITE: 'authority:users.write',
AUTHORITY_ROLES_READ: 'authority:roles.read',
AUTHORITY_ROLES_WRITE: 'authority:roles.write',
AUTHORITY_CLIENTS_READ: 'authority:clients.read',
AUTHORITY_CLIENTS_WRITE: 'authority:clients.write',
AUTHORITY_TOKENS_READ: 'authority:tokens.read',
AUTHORITY_TOKENS_REVOKE: 'authority:tokens.revoke',
AUTHORITY_BRANDING_READ: 'authority:branding.read',
AUTHORITY_BRANDING_WRITE: 'authority:branding.write',
AUTHORITY_AUDIT_READ: 'authority:audit.read',
// Scheduler scopes
SCHEDULER_READ: 'scheduler:read',
SCHEDULER_OPERATE: 'scheduler:operate',
SCHEDULER_ADMIN: 'scheduler:admin',
// Attestor scopes
ATTEST_CREATE: 'attest:create',
ATTEST_ADMIN: 'attest:admin',
// Signer scopes
SIGNER_READ: 'signer:read',
SIGNER_SIGN: 'signer:sign',
SIGNER_ROTATE: 'signer:rotate',
SIGNER_ADMIN: 'signer:admin',
// Zastava scopes
ZASTAVA_READ: 'zastava:read',
ZASTAVA_TRIGGER: 'zastava:trigger',
ZASTAVA_ADMIN: 'zastava:admin',
// Exceptions scopes
EXCEPTIONS_READ: 'exceptions:read',
EXCEPTIONS_WRITE: 'exceptions:write',
// Findings scope
FINDINGS_READ: 'findings:read',
} as const;
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
/**
* Scope groupings for common use cases.
*/
export const ScopeGroups = {
GRAPH_VIEWER: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.POLICY_READ,
] as const,
GRAPH_EDITOR: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.SBOM_WRITE,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
] as const,
GRAPH_ADMIN: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.GRAPH_ADMIN,
StellaOpsScopes.GRAPH_EXPORT,
StellaOpsScopes.GRAPH_SIMULATE,
] as const,
RELEASE_MANAGER: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.RELEASE_WRITE,
StellaOpsScopes.RELEASE_PUBLISH,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
] as const,
SECURITY_ADMIN: [
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.EXCEPTION_WRITE,
StellaOpsScopes.EXCEPTION_APPROVE,
StellaOpsScopes.RELEASE_BYPASS,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_WRITE,
] as const,
// Orchestrator scope groups (UI-ORCH-32-001)
ORCH_VIEWER: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.UI_READ,
] as const,
ORCH_OPERATOR: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.UI_READ,
] as const,
ORCH_ADMIN: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.ORCH_QUOTA,
StellaOpsScopes.ORCH_BACKFILL,
StellaOpsScopes.UI_READ,
] as const,
// Policy Studio scope groups (UI-POLICY-20-003)
POLICY_VIEWER: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.UI_READ,
] as const,
POLICY_AUTHOR: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_AUTHOR,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_REVIEWER: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_APPROVER: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_OPERATOR: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_OPERATE,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_ADMIN: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_AUTHOR,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.POLICY_OPERATE,
StellaOpsScopes.POLICY_AUDIT,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
} as const;
/**
* Human-readable labels for scopes.
*/
export const ScopeLabels: Record<StellaOpsScope, string> = {
'graph:read': 'View Graph',
'graph:write': 'Edit Graph',
'graph:admin': 'Administer Graph',
'graph:export': 'Export Graph Data',
'graph:simulate': 'Run Graph Simulations',
'sbom:read': 'View SBOMs',
'sbom:write': 'Create/Edit SBOMs',
'sbom:attest': 'Attest SBOMs',
'scanner:read': 'View Scan Results',
'scanner:write': 'Configure Scanner',
'scanner:scan': 'Trigger Scans',
'scanner:export': 'Export Scan Results',
'policy:read': 'View Policies',
'policy:write': 'Edit Policies',
'policy:evaluate': 'Evaluate Policies',
'policy:simulate': 'Simulate Policy Changes',
// Policy Studio workflow scopes (UI-POLICY-20-003)
'policy:author': 'Author Policy Drafts',
'policy:edit': 'Edit Policy Configuration',
'policy:review': 'Review Policy Drafts',
'policy:submit': 'Submit Policies for Review',
'policy:approve': 'Approve/Reject Policies',
'policy:operate': 'Operate Policy Promotions',
'policy:activate': 'Activate Policies',
'policy:run': 'Trigger Policy Runs',
'policy:publish': 'Publish Policy Versions',
'policy:promote': 'Promote Between Environments',
'policy:audit': 'Audit Policy Activity',
'exception:read': 'View Exceptions',
'exception:write': 'Create Exceptions',
'exception:approve': 'Approve Exceptions',
'advisory:read': 'View Advisories',
'vex:read': 'View VEX Evidence',
'vex:export': 'Export VEX Evidence',
// Orchestrator scopes (UI-ORCH-32-001)
ORCH_READ: 'orch:read',
ORCH_OPERATE: 'orch:operate',
ORCH_QUOTA: 'orch:quota',
ORCH_BACKFILL: 'orch:backfill',
// UI scopes
UI_READ: 'ui.read',
UI_ADMIN: 'ui.admin',
// Admin scopes
ADMIN: 'admin',
TENANT_ADMIN: 'tenant:admin',
// Authority admin scopes
AUTHORITY_TENANTS_READ: 'authority:tenants.read',
AUTHORITY_TENANTS_WRITE: 'authority:tenants.write',
AUTHORITY_USERS_READ: 'authority:users.read',
AUTHORITY_USERS_WRITE: 'authority:users.write',
AUTHORITY_ROLES_READ: 'authority:roles.read',
AUTHORITY_ROLES_WRITE: 'authority:roles.write',
AUTHORITY_CLIENTS_READ: 'authority:clients.read',
AUTHORITY_CLIENTS_WRITE: 'authority:clients.write',
AUTHORITY_TOKENS_READ: 'authority:tokens.read',
AUTHORITY_TOKENS_REVOKE: 'authority:tokens.revoke',
AUTHORITY_BRANDING_READ: 'authority:branding.read',
AUTHORITY_BRANDING_WRITE: 'authority:branding.write',
AUTHORITY_AUDIT_READ: 'authority:audit.read',
// Scheduler scopes
SCHEDULER_READ: 'scheduler:read',
SCHEDULER_OPERATE: 'scheduler:operate',
SCHEDULER_ADMIN: 'scheduler:admin',
// Attestor scopes
ATTEST_CREATE: 'attest:create',
ATTEST_ADMIN: 'attest:admin',
// Signer scopes
SIGNER_READ: 'signer:read',
SIGNER_SIGN: 'signer:sign',
SIGNER_ROTATE: 'signer:rotate',
SIGNER_ADMIN: 'signer:admin',
// Zastava scopes
ZASTAVA_READ: 'zastava:read',
ZASTAVA_TRIGGER: 'zastava:trigger',
ZASTAVA_ADMIN: 'zastava:admin',
// Exceptions scopes
EXCEPTIONS_READ: 'exceptions:read',
EXCEPTIONS_WRITE: 'exceptions:write',
// Findings scope
FINDINGS_READ: 'findings:read',
} as const;
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
/**
* Scope groupings for common use cases.
*/
export const ScopeGroups = {
GRAPH_VIEWER: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.POLICY_READ,
] as const,
GRAPH_EDITOR: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.SBOM_WRITE,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
] as const,
GRAPH_ADMIN: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.GRAPH_ADMIN,
StellaOpsScopes.GRAPH_EXPORT,
StellaOpsScopes.GRAPH_SIMULATE,
] as const,
RELEASE_MANAGER: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.RELEASE_WRITE,
StellaOpsScopes.RELEASE_PUBLISH,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
] as const,
SECURITY_ADMIN: [
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.EXCEPTION_WRITE,
StellaOpsScopes.EXCEPTION_APPROVE,
StellaOpsScopes.RELEASE_BYPASS,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_WRITE,
] as const,
// Orchestrator scope groups (UI-ORCH-32-001)
ORCH_VIEWER: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.UI_READ,
] as const,
ORCH_OPERATOR: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.UI_READ,
] as const,
ORCH_ADMIN: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.ORCH_QUOTA,
StellaOpsScopes.ORCH_BACKFILL,
StellaOpsScopes.UI_READ,
] as const,
// Policy Studio scope groups (UI-POLICY-20-003)
POLICY_VIEWER: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.UI_READ,
] as const,
POLICY_AUTHOR: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_AUTHOR,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_REVIEWER: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_APPROVER: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_OPERATOR: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_OPERATE,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_ADMIN: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_AUTHOR,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.POLICY_OPERATE,
StellaOpsScopes.POLICY_AUDIT,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
} as const;
/**
* Human-readable labels for scopes.
*/
export const ScopeLabels: Record<StellaOpsScope, string> = {
'graph:read': 'View Graph',
'graph:write': 'Edit Graph',
'graph:admin': 'Administer Graph',
'graph:export': 'Export Graph Data',
'graph:simulate': 'Run Graph Simulations',
'sbom:read': 'View SBOMs',
'sbom:write': 'Create/Edit SBOMs',
'sbom:attest': 'Attest SBOMs',
'scanner:read': 'View Scan Results',
'scanner:write': 'Configure Scanner',
'scanner:scan': 'Trigger Scans',
'scanner:export': 'Export Scan Results',
'policy:read': 'View Policies',
'policy:write': 'Edit Policies',
'policy:evaluate': 'Evaluate Policies',
'policy:simulate': 'Simulate Policy Changes',
// Policy Studio workflow scopes (UI-POLICY-20-003)
'policy:author': 'Author Policy Drafts',
'policy:edit': 'Edit Policy Configuration',
'policy:review': 'Review Policy Drafts',
'policy:submit': 'Submit Policies for Review',
'policy:approve': 'Approve/Reject Policies',
'policy:operate': 'Operate Policy Promotions',
'policy:activate': 'Activate Policies',
'policy:run': 'Trigger Policy Runs',
'policy:publish': 'Publish Policy Versions',
'policy:promote': 'Promote Between Environments',
'policy:audit': 'Audit Policy Activity',
'exception:read': 'View Exceptions',
'exception:write': 'Create Exceptions',
'exception:approve': 'Approve Exceptions',
'advisory:read': 'View Advisories',
'vex:read': 'View VEX Evidence',
'vex:export': 'Export VEX Evidence',
'release:read': 'View Releases',
'release:write': 'Create Releases',
'release:publish': 'Publish Releases',
@@ -288,82 +288,82 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
'analytics.read': 'View Analytics',
'aoc:read': 'View AOC Status',
'aoc:verify': 'Trigger AOC Verification',
// Orchestrator scope labels (UI-ORCH-32-001)
'orch:read': 'View Orchestrator Jobs',
'orch:operate': 'Operate Orchestrator',
'orch:quota': 'Manage Orchestrator Quotas',
'orch:backfill': 'Initiate Backfill Runs',
// UI scope labels
'ui.read': 'Console Access',
'ui.admin': 'Console Admin Access',
// Admin scope labels
'admin': 'System Administrator',
'tenant:admin': 'Tenant Administrator',
// Authority admin scope labels
'authority:tenants.read': 'View Tenants',
'authority:tenants.write': 'Manage Tenants',
'authority:users.read': 'View Users',
'authority:users.write': 'Manage Users',
'authority:roles.read': 'View Roles',
'authority:roles.write': 'Manage Roles',
'authority:clients.read': 'View Clients',
'authority:clients.write': 'Manage Clients',
'authority:tokens.read': 'View Tokens',
'authority:tokens.revoke': 'Revoke Tokens',
'authority:branding.read': 'View Branding',
'authority:branding.write': 'Manage Branding',
'authority:audit.read': 'View Audit Log',
// Scheduler scope labels
'scheduler:read': 'View Scheduler Jobs',
'scheduler:operate': 'Operate Scheduler',
'scheduler:admin': 'Administer Scheduler',
// Attestor scope labels
'attest:create': 'Create Attestations',
'attest:admin': 'Administer Attestor',
// Signer scope labels
'signer:read': 'View Signer Configuration',
'signer:sign': 'Create Signatures',
'signer:rotate': 'Rotate Signing Keys',
'signer:admin': 'Administer Signer',
// Zastava scope labels
'zastava:read': 'View Zastava State',
'zastava:trigger': 'Trigger Zastava Processing',
'zastava:admin': 'Administer Zastava',
// Exception scope labels
'exceptions:read': 'View Exceptions',
'exceptions:write': 'Create Exceptions',
// Findings scope label
'findings:read': 'View Policy Findings',
};
/**
* Check if a set of scopes includes a required scope.
*/
export function hasScope(
userScopes: readonly string[],
requiredScope: StellaOpsScope
): boolean {
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
}
/**
* Check if a set of scopes includes all required scopes.
*/
export function hasAllScopes(
userScopes: readonly string[],
requiredScopes: readonly StellaOpsScope[]
): boolean {
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
return requiredScopes.every((scope) => userScopes.includes(scope));
}
/**
* Check if a set of scopes includes any of the required scopes.
*/
export function hasAnyScope(
userScopes: readonly string[],
requiredScopes: readonly StellaOpsScope[]
): boolean {
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
return requiredScopes.some((scope) => userScopes.includes(scope));
}
// Orchestrator scope labels (UI-ORCH-32-001)
'orch:read': 'View Orchestrator Jobs',
'orch:operate': 'Operate Orchestrator',
'orch:quota': 'Manage Orchestrator Quotas',
'orch:backfill': 'Initiate Backfill Runs',
// UI scope labels
'ui.read': 'Console Access',
'ui.admin': 'Console Admin Access',
// Admin scope labels
'admin': 'System Administrator',
'tenant:admin': 'Tenant Administrator',
// Authority admin scope labels
'authority:tenants.read': 'View Tenants',
'authority:tenants.write': 'Manage Tenants',
'authority:users.read': 'View Users',
'authority:users.write': 'Manage Users',
'authority:roles.read': 'View Roles',
'authority:roles.write': 'Manage Roles',
'authority:clients.read': 'View Clients',
'authority:clients.write': 'Manage Clients',
'authority:tokens.read': 'View Tokens',
'authority:tokens.revoke': 'Revoke Tokens',
'authority:branding.read': 'View Branding',
'authority:branding.write': 'Manage Branding',
'authority:audit.read': 'View Audit Log',
// Scheduler scope labels
'scheduler:read': 'View Scheduler Jobs',
'scheduler:operate': 'Operate Scheduler',
'scheduler:admin': 'Administer Scheduler',
// Attestor scope labels
'attest:create': 'Create Attestations',
'attest:admin': 'Administer Attestor',
// Signer scope labels
'signer:read': 'View Signer Configuration',
'signer:sign': 'Create Signatures',
'signer:rotate': 'Rotate Signing Keys',
'signer:admin': 'Administer Signer',
// Zastava scope labels
'zastava:read': 'View Zastava State',
'zastava:trigger': 'Trigger Zastava Processing',
'zastava:admin': 'Administer Zastava',
// Exception scope labels
'exceptions:read': 'View Exceptions',
'exceptions:write': 'Create Exceptions',
// Findings scope label
'findings:read': 'View Policy Findings',
};
/**
* Check if a set of scopes includes a required scope.
*/
export function hasScope(
userScopes: readonly string[],
requiredScope: StellaOpsScope
): boolean {
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
}
/**
* Check if a set of scopes includes all required scopes.
*/
export function hasAllScopes(
userScopes: readonly string[],
requiredScopes: readonly StellaOpsScope[]
): boolean {
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
return requiredScopes.every((scope) => userScopes.includes(scope));
}
/**
* Check if a set of scopes includes any of the required scopes.
*/
export function hasAnyScope(
userScopes: readonly string[],
requiredScopes: readonly StellaOpsScope[]
): boolean {
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
return requiredScopes.some((scope) => userScopes.includes(scope));
}

View File

@@ -1,29 +1,29 @@
import { InjectionToken } from '@angular/core';
export type DPoPAlgorithm = 'ES256' | 'ES384' | 'EdDSA';
export interface AuthorityConfig {
readonly issuer: string;
readonly clientId: string;
readonly authorizeEndpoint: string;
readonly tokenEndpoint: string;
readonly logoutEndpoint?: string;
readonly redirectUri: string;
readonly postLogoutRedirectUri?: string;
readonly scope: string;
readonly audience: string;
/**
* Preferred algorithms for DPoP proofs, in order of preference.
* Defaults to ES256 if omitted.
*/
readonly dpopAlgorithms?: readonly DPoPAlgorithm[];
/**
* Seconds of leeway before access token expiry that should trigger a proactive refresh.
* Defaults to 60.
*/
readonly refreshLeewaySeconds?: number;
}
import { InjectionToken } from '@angular/core';
export type DPoPAlgorithm = 'ES256' | 'ES384' | 'EdDSA';
export interface AuthorityConfig {
readonly issuer: string;
readonly clientId: string;
readonly authorizeEndpoint: string;
readonly tokenEndpoint: string;
readonly logoutEndpoint?: string;
readonly redirectUri: string;
readonly postLogoutRedirectUri?: string;
readonly scope: string;
readonly audience: string;
/**
* Preferred algorithms for DPoP proofs, in order of preference.
* Defaults to ES256 if omitted.
*/
readonly dpopAlgorithms?: readonly DPoPAlgorithm[];
/**
* Seconds of leeway before access token expiry that should trigger a proactive refresh.
* Defaults to 60.
*/
readonly refreshLeewaySeconds?: number;
}
export interface ApiBaseUrlConfig {
/**
* Optional API gateway base URL for cross-cutting endpoints.
@@ -38,11 +38,11 @@ export interface ApiBaseUrlConfig {
readonly concelier: string;
readonly excitor?: string;
readonly attestor: string;
readonly authority: string;
readonly notify?: string;
readonly scheduler?: string;
}
readonly authority: string;
readonly notify?: string;
readonly scheduler?: string;
}
export interface TelemetryConfig {
readonly otlpEndpoint?: string;
readonly sampleRate?: number;

View File

@@ -6,15 +6,15 @@ import {
computed,
signal,
} from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
APP_CONFIG,
AppConfig,
AuthorityConfig,
DPoPAlgorithm,
} from './app-config.model';
import { firstValueFrom } from 'rxjs';
import {
APP_CONFIG,
AppConfig,
AuthorityConfig,
DPoPAlgorithm,
} from './app-config.model';
const DEFAULT_CONFIG_URL = '/config.json';
const DEFAULT_DPOP_ALG: DPoPAlgorithm = 'ES256';
const DEFAULT_REFRESH_LEEWAY_SECONDS = 60;
@@ -41,53 +41,53 @@ export class AppConfigService {
// that themselves depend on AppConfigService.
this.http = new HttpClient(httpBackend);
}
/**
* Loads application configuration either from the injected static value or via HTTP fetch.
* Must be called during application bootstrap (see APP_INITIALIZER wiring).
*/
async load(configUrl: string = DEFAULT_CONFIG_URL): Promise<void> {
if (this.configSignal()) {
return;
}
const config = this.staticConfig ?? (await this.fetchConfig(configUrl));
this.configSignal.set(this.normalizeConfig(config));
}
/**
* Allows tests to short-circuit configuration loading.
*/
setConfigForTesting(config: AppConfig): void {
this.configSignal.set(this.normalizeConfig(config));
}
get config(): AppConfig {
const current = this.configSignal();
if (!current) {
throw new Error('App configuration has not been loaded yet.');
}
return current;
}
get authority(): AuthorityConfig {
const authority = this.authoritySignal();
if (!authority) {
throw new Error('Authority configuration has not been loaded yet.');
}
return authority;
}
private async fetchConfig(configUrl: string): Promise<AppConfig> {
const response = await firstValueFrom(
this.http.get<AppConfig>(configUrl, {
headers: { 'Cache-Control': 'no-cache' },
withCredentials: false,
})
);
return response;
}
/**
* Loads application configuration either from the injected static value or via HTTP fetch.
* Must be called during application bootstrap (see APP_INITIALIZER wiring).
*/
async load(configUrl: string = DEFAULT_CONFIG_URL): Promise<void> {
if (this.configSignal()) {
return;
}
const config = this.staticConfig ?? (await this.fetchConfig(configUrl));
this.configSignal.set(this.normalizeConfig(config));
}
/**
* Allows tests to short-circuit configuration loading.
*/
setConfigForTesting(config: AppConfig): void {
this.configSignal.set(this.normalizeConfig(config));
}
get config(): AppConfig {
const current = this.configSignal();
if (!current) {
throw new Error('App configuration has not been loaded yet.');
}
return current;
}
get authority(): AuthorityConfig {
const authority = this.authoritySignal();
if (!authority) {
throw new Error('Authority configuration has not been loaded yet.');
}
return authority;
}
private async fetchConfig(configUrl: string): Promise<AppConfig> {
const response = await firstValueFrom(
this.http.get<AppConfig>(configUrl, {
headers: { 'Cache-Control': 'no-cache' },
withCredentials: false,
})
);
return response;
}
private normalizeConfig(config: AppConfig): AppConfig {
const authority = {
...config.authority,

View File

@@ -1,139 +1,139 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import {
AUTHORITY_CONSOLE_API,
AuthorityConsoleApi,
TenantCatalogResponseDto,
} from '../api/authority-console.client';
import { AuthSessionStore } from '../auth/auth-session.store';
import { ConsoleSessionService } from './console-session.service';
import { ConsoleSessionStore } from './console-session.store';
class MockConsoleApi implements AuthorityConsoleApi {
private createTenantResponse(): TenantCatalogResponseDto {
return {
tenants: [
{
id: 'tenant-default',
displayName: 'Tenant Default',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.console'],
},
],
};
}
listTenants() {
return of(this.createTenantResponse());
}
getProfile() {
return of({
subjectId: 'user-1',
username: 'user@example.com',
displayName: 'Console User',
tenant: 'tenant-default',
sessionId: 'session-1',
roles: ['role.console'],
scopes: ['ui.read'],
audiences: ['console'],
authenticationMethods: ['pwd'],
issuedAt: '2025-10-31T12:00:00Z',
authenticationTime: '2025-10-31T12:00:00Z',
expiresAt: '2025-10-31T12:10:00Z',
freshAuth: true,
});
}
introspectToken() {
return of({
active: true,
tenant: 'tenant-default',
subject: 'user-1',
clientId: 'console-web',
tokenId: 'token-1',
scopes: ['ui.read'],
audiences: ['console'],
issuedAt: '2025-10-31T12:00:00Z',
authenticationTime: '2025-10-31T12:00:00Z',
expiresAt: '2025-10-31T12:10:00Z',
freshAuth: true,
});
}
}
class MockAuthSessionStore {
private tenantIdValue: string | null = 'tenant-default';
private readonly sessionValue = {
tenantId: 'tenant-default',
scopes: ['ui.read'],
audiences: ['console'],
authenticationTimeEpochMs: Date.parse('2025-10-31T12:00:00Z'),
freshAuthActive: true,
freshAuthExpiresAtEpochMs: Date.parse('2025-10-31T12:05:00Z'),
};
session = () => this.sessionValue as any;
getActiveTenantId(): string | null {
return this.tenantIdValue;
}
setTenantId(tenantId: string | null): void {
this.tenantIdValue = tenantId;
this.sessionValue.tenantId = tenantId ?? 'tenant-default';
}
}
describe('ConsoleSessionService', () => {
let service: ConsoleSessionService;
let store: ConsoleSessionStore;
let authStore: MockAuthSessionStore;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ConsoleSessionStore,
ConsoleSessionService,
{ provide: AUTHORITY_CONSOLE_API, useClass: MockConsoleApi },
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
],
});
service = TestBed.inject(ConsoleSessionService);
store = TestBed.inject(ConsoleSessionStore);
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
});
it('loads console context for active tenant', async () => {
await service.loadConsoleContext();
expect(store.tenants().length).toBe(1);
expect(store.selectedTenantId()).toBe('tenant-default');
expect(store.profile()?.displayName).toBe('Console User');
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
});
it('clears store when no tenant available', async () => {
authStore.setTenantId(null);
store.setTenants(
[
{
id: 'existing',
displayName: 'Existing',
status: 'active',
isolationMode: 'shared',
defaultRoles: [],
},
],
'existing'
);
await service.loadConsoleContext();
expect(store.tenants().length).toBe(0);
expect(store.selectedTenantId()).toBeNull();
});
});
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import {
AUTHORITY_CONSOLE_API,
AuthorityConsoleApi,
TenantCatalogResponseDto,
} from '../api/authority-console.client';
import { AuthSessionStore } from '../auth/auth-session.store';
import { ConsoleSessionService } from './console-session.service';
import { ConsoleSessionStore } from './console-session.store';
class MockConsoleApi implements AuthorityConsoleApi {
private createTenantResponse(): TenantCatalogResponseDto {
return {
tenants: [
{
id: 'tenant-default',
displayName: 'Tenant Default',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.console'],
},
],
};
}
listTenants() {
return of(this.createTenantResponse());
}
getProfile() {
return of({
subjectId: 'user-1',
username: 'user@example.com',
displayName: 'Console User',
tenant: 'tenant-default',
sessionId: 'session-1',
roles: ['role.console'],
scopes: ['ui.read'],
audiences: ['console'],
authenticationMethods: ['pwd'],
issuedAt: '2025-10-31T12:00:00Z',
authenticationTime: '2025-10-31T12:00:00Z',
expiresAt: '2025-10-31T12:10:00Z',
freshAuth: true,
});
}
introspectToken() {
return of({
active: true,
tenant: 'tenant-default',
subject: 'user-1',
clientId: 'console-web',
tokenId: 'token-1',
scopes: ['ui.read'],
audiences: ['console'],
issuedAt: '2025-10-31T12:00:00Z',
authenticationTime: '2025-10-31T12:00:00Z',
expiresAt: '2025-10-31T12:10:00Z',
freshAuth: true,
});
}
}
class MockAuthSessionStore {
private tenantIdValue: string | null = 'tenant-default';
private readonly sessionValue = {
tenantId: 'tenant-default',
scopes: ['ui.read'],
audiences: ['console'],
authenticationTimeEpochMs: Date.parse('2025-10-31T12:00:00Z'),
freshAuthActive: true,
freshAuthExpiresAtEpochMs: Date.parse('2025-10-31T12:05:00Z'),
};
session = () => this.sessionValue as any;
getActiveTenantId(): string | null {
return this.tenantIdValue;
}
setTenantId(tenantId: string | null): void {
this.tenantIdValue = tenantId;
this.sessionValue.tenantId = tenantId ?? 'tenant-default';
}
}
describe('ConsoleSessionService', () => {
let service: ConsoleSessionService;
let store: ConsoleSessionStore;
let authStore: MockAuthSessionStore;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ConsoleSessionStore,
ConsoleSessionService,
{ provide: AUTHORITY_CONSOLE_API, useClass: MockConsoleApi },
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
],
});
service = TestBed.inject(ConsoleSessionService);
store = TestBed.inject(ConsoleSessionStore);
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
});
it('loads console context for active tenant', async () => {
await service.loadConsoleContext();
expect(store.tenants().length).toBe(1);
expect(store.selectedTenantId()).toBe('tenant-default');
expect(store.profile()?.displayName).toBe('Console User');
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
});
it('clears store when no tenant available', async () => {
authStore.setTenantId(null);
store.setTenants(
[
{
id: 'existing',
displayName: 'Existing',
status: 'active',
isolationMode: 'shared',
defaultRoles: [],
},
],
'existing'
);
await service.loadConsoleContext();
expect(store.tenants().length).toBe(0);
expect(store.selectedTenantId()).toBeNull();
});
});

View File

@@ -1,161 +1,161 @@
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
AUTHORITY_CONSOLE_API,
AuthorityConsoleApi,
AuthorityTenantViewDto,
ConsoleProfileDto,
ConsoleTokenIntrospectionDto,
} from '../api/authority-console.client';
import { AuthSessionStore } from '../auth/auth-session.store';
import {
ConsoleProfile,
ConsoleSessionStore,
ConsoleTenant,
ConsoleTokenInfo,
} from './console-session.store';
@Injectable({
providedIn: 'root',
})
export class ConsoleSessionService {
private readonly api = inject<AuthorityConsoleApi>(AUTHORITY_CONSOLE_API);
private readonly store = inject(ConsoleSessionStore);
private readonly authSession = inject(AuthSessionStore);
async loadConsoleContext(tenantId?: string | null): Promise<void> {
const activeTenant =
(tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!activeTenant) {
this.store.clear();
return;
}
this.store.setSelectedTenant(activeTenant);
this.store.setLoading(true);
this.store.setError(null);
try {
const tenantResponse = await firstValueFrom(
this.api.listTenants(activeTenant)
);
const tenants = (tenantResponse.tenants ?? []).map((tenant) =>
this.mapTenant(tenant)
);
const [profileDto, tokenDto] = await Promise.all([
firstValueFrom(this.api.getProfile(activeTenant)),
firstValueFrom(this.api.introspectToken(activeTenant)),
]);
const profile = this.mapProfile(profileDto);
const tokenInfo = this.mapTokenInfo(tokenDto);
this.store.setContext({
tenants,
profile,
token: tokenInfo,
selectedTenantId: activeTenant,
});
} catch (error) {
console.error('Failed to load console context', error);
this.store.setError('Unable to load console context.');
} finally {
this.store.setLoading(false);
}
}
async switchTenant(tenantId: string): Promise<void> {
if (!tenantId || tenantId === this.store.selectedTenantId()) {
return this.loadConsoleContext(tenantId);
}
this.store.setSelectedTenant(tenantId);
await this.loadConsoleContext(tenantId);
}
async refresh(): Promise<void> {
await this.loadConsoleContext(this.store.selectedTenantId());
}
clear(): void {
this.store.clear();
}
private mapTenant(dto: AuthorityTenantViewDto): ConsoleTenant {
const roles = Array.isArray(dto.defaultRoles)
? dto.defaultRoles
.map((role) => role.trim())
.filter((role) => role.length > 0)
.sort((a, b) => a.localeCompare(b))
: [];
return {
id: dto.id,
displayName: dto.displayName || dto.id,
status: dto.status ?? 'active',
isolationMode: dto.isolationMode ?? 'shared',
defaultRoles: roles,
};
}
private mapProfile(dto: ConsoleProfileDto): ConsoleProfile {
return {
subjectId: dto.subjectId ?? null,
username: dto.username ?? null,
displayName: dto.displayName ?? dto.username ?? dto.subjectId ?? null,
tenant: dto.tenant,
sessionId: dto.sessionId ?? null,
roles: [...(dto.roles ?? [])].sort((a, b) => a.localeCompare(b)),
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
a.localeCompare(b)
),
authenticationMethods: [...(dto.authenticationMethods ?? [])],
issuedAt: this.parseInstant(dto.issuedAt),
authenticationTime: this.parseInstant(dto.authenticationTime),
expiresAt: this.parseInstant(dto.expiresAt),
freshAuth: !!dto.freshAuth,
};
}
private mapTokenInfo(dto: ConsoleTokenIntrospectionDto): ConsoleTokenInfo {
const session = this.authSession.session();
const freshAuthExpiresAt =
session?.freshAuthExpiresAtEpochMs != null
? new Date(session.freshAuthExpiresAtEpochMs)
: null;
const authenticationTime =
session?.authenticationTimeEpochMs != null
? new Date(session.authenticationTimeEpochMs)
: this.parseInstant(dto.authenticationTime);
return {
active: !!dto.active,
tenant: dto.tenant,
subject: dto.subject ?? null,
clientId: dto.clientId ?? null,
tokenId: dto.tokenId ?? null,
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
a.localeCompare(b)
),
issuedAt: this.parseInstant(dto.issuedAt),
authenticationTime,
expiresAt: this.parseInstant(dto.expiresAt),
freshAuthActive: session?.freshAuthActive ?? !!dto.freshAuth,
freshAuthExpiresAt,
};
}
private parseInstant(value: string | null | undefined): Date | null {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
}
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
AUTHORITY_CONSOLE_API,
AuthorityConsoleApi,
AuthorityTenantViewDto,
ConsoleProfileDto,
ConsoleTokenIntrospectionDto,
} from '../api/authority-console.client';
import { AuthSessionStore } from '../auth/auth-session.store';
import {
ConsoleProfile,
ConsoleSessionStore,
ConsoleTenant,
ConsoleTokenInfo,
} from './console-session.store';
@Injectable({
providedIn: 'root',
})
export class ConsoleSessionService {
private readonly api = inject<AuthorityConsoleApi>(AUTHORITY_CONSOLE_API);
private readonly store = inject(ConsoleSessionStore);
private readonly authSession = inject(AuthSessionStore);
async loadConsoleContext(tenantId?: string | null): Promise<void> {
const activeTenant =
(tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!activeTenant) {
this.store.clear();
return;
}
this.store.setSelectedTenant(activeTenant);
this.store.setLoading(true);
this.store.setError(null);
try {
const tenantResponse = await firstValueFrom(
this.api.listTenants(activeTenant)
);
const tenants = (tenantResponse.tenants ?? []).map((tenant) =>
this.mapTenant(tenant)
);
const [profileDto, tokenDto] = await Promise.all([
firstValueFrom(this.api.getProfile(activeTenant)),
firstValueFrom(this.api.introspectToken(activeTenant)),
]);
const profile = this.mapProfile(profileDto);
const tokenInfo = this.mapTokenInfo(tokenDto);
this.store.setContext({
tenants,
profile,
token: tokenInfo,
selectedTenantId: activeTenant,
});
} catch (error) {
console.error('Failed to load console context', error);
this.store.setError('Unable to load console context.');
} finally {
this.store.setLoading(false);
}
}
async switchTenant(tenantId: string): Promise<void> {
if (!tenantId || tenantId === this.store.selectedTenantId()) {
return this.loadConsoleContext(tenantId);
}
this.store.setSelectedTenant(tenantId);
await this.loadConsoleContext(tenantId);
}
async refresh(): Promise<void> {
await this.loadConsoleContext(this.store.selectedTenantId());
}
clear(): void {
this.store.clear();
}
private mapTenant(dto: AuthorityTenantViewDto): ConsoleTenant {
const roles = Array.isArray(dto.defaultRoles)
? dto.defaultRoles
.map((role) => role.trim())
.filter((role) => role.length > 0)
.sort((a, b) => a.localeCompare(b))
: [];
return {
id: dto.id,
displayName: dto.displayName || dto.id,
status: dto.status ?? 'active',
isolationMode: dto.isolationMode ?? 'shared',
defaultRoles: roles,
};
}
private mapProfile(dto: ConsoleProfileDto): ConsoleProfile {
return {
subjectId: dto.subjectId ?? null,
username: dto.username ?? null,
displayName: dto.displayName ?? dto.username ?? dto.subjectId ?? null,
tenant: dto.tenant,
sessionId: dto.sessionId ?? null,
roles: [...(dto.roles ?? [])].sort((a, b) => a.localeCompare(b)),
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
a.localeCompare(b)
),
authenticationMethods: [...(dto.authenticationMethods ?? [])],
issuedAt: this.parseInstant(dto.issuedAt),
authenticationTime: this.parseInstant(dto.authenticationTime),
expiresAt: this.parseInstant(dto.expiresAt),
freshAuth: !!dto.freshAuth,
};
}
private mapTokenInfo(dto: ConsoleTokenIntrospectionDto): ConsoleTokenInfo {
const session = this.authSession.session();
const freshAuthExpiresAt =
session?.freshAuthExpiresAtEpochMs != null
? new Date(session.freshAuthExpiresAtEpochMs)
: null;
const authenticationTime =
session?.authenticationTimeEpochMs != null
? new Date(session.authenticationTimeEpochMs)
: this.parseInstant(dto.authenticationTime);
return {
active: !!dto.active,
tenant: dto.tenant,
subject: dto.subject ?? null,
clientId: dto.clientId ?? null,
tokenId: dto.tokenId ?? null,
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
a.localeCompare(b)
),
issuedAt: this.parseInstant(dto.issuedAt),
authenticationTime,
expiresAt: this.parseInstant(dto.expiresAt),
freshAuthActive: session?.freshAuthActive ?? !!dto.freshAuth,
freshAuthExpiresAt,
};
}
private parseInstant(value: string | null | undefined): Date | null {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
}

View File

@@ -1,123 +1,123 @@
import { ConsoleSessionStore } from './console-session.store';
describe('ConsoleSessionStore', () => {
let store: ConsoleSessionStore;
beforeEach(() => {
store = new ConsoleSessionStore();
});
it('tracks tenants and selection', () => {
const tenants = [
{
id: 'tenant-a',
displayName: 'Tenant A',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.a'],
},
{
id: 'tenant-b',
displayName: 'Tenant B',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.b'],
},
];
const selected = store.setTenants(tenants, 'tenant-b');
expect(selected).toBe('tenant-b');
expect(store.selectedTenantId()).toBe('tenant-b');
expect(store.tenants().length).toBe(2);
});
it('sets context with profile and token info', () => {
const tenants = [
{
id: 'tenant-a',
displayName: 'Tenant A',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.a'],
},
];
store.setContext({
tenants,
selectedTenantId: 'tenant-a',
profile: {
subjectId: 'user-1',
username: 'user@example.com',
displayName: 'User Example',
tenant: 'tenant-a',
sessionId: 'session-123',
roles: ['role.a'],
scopes: ['scope.a'],
audiences: ['aud'],
authenticationMethods: ['pwd'],
issuedAt: new Date('2025-10-31T12:00:00Z'),
authenticationTime: new Date('2025-10-31T12:00:00Z'),
expiresAt: new Date('2025-10-31T12:10:00Z'),
freshAuth: true,
},
token: {
active: true,
tenant: 'tenant-a',
subject: 'user-1',
clientId: 'client',
tokenId: 'token-1',
scopes: ['scope.a'],
audiences: ['aud'],
issuedAt: new Date('2025-10-31T12:00:00Z'),
authenticationTime: new Date('2025-10-31T12:00:00Z'),
expiresAt: new Date('2025-10-31T12:10:00Z'),
freshAuthActive: true,
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
},
});
expect(store.selectedTenantId()).toBe('tenant-a');
expect(store.profile()?.displayName).toBe('User Example');
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
expect(store.hasContext()).toBeTrue();
});
it('clears state', () => {
store.setTenants(
[
{
id: 'tenant-a',
displayName: 'Tenant A',
status: 'active',
isolationMode: 'shared',
defaultRoles: [],
},
],
'tenant-a'
);
store.setProfile({
subjectId: null,
username: null,
displayName: null,
tenant: 'tenant-a',
sessionId: null,
roles: [],
scopes: [],
audiences: [],
authenticationMethods: [],
issuedAt: null,
authenticationTime: null,
expiresAt: null,
freshAuth: false,
});
store.clear();
expect(store.tenants().length).toBe(0);
expect(store.selectedTenantId()).toBeNull();
expect(store.profile()).toBeNull();
expect(store.tokenInfo()).toBeNull();
expect(store.loading()).toBeFalse();
expect(store.error()).toBeNull();
});
});
import { ConsoleSessionStore } from './console-session.store';
describe('ConsoleSessionStore', () => {
let store: ConsoleSessionStore;
beforeEach(() => {
store = new ConsoleSessionStore();
});
it('tracks tenants and selection', () => {
const tenants = [
{
id: 'tenant-a',
displayName: 'Tenant A',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.a'],
},
{
id: 'tenant-b',
displayName: 'Tenant B',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.b'],
},
];
const selected = store.setTenants(tenants, 'tenant-b');
expect(selected).toBe('tenant-b');
expect(store.selectedTenantId()).toBe('tenant-b');
expect(store.tenants().length).toBe(2);
});
it('sets context with profile and token info', () => {
const tenants = [
{
id: 'tenant-a',
displayName: 'Tenant A',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.a'],
},
];
store.setContext({
tenants,
selectedTenantId: 'tenant-a',
profile: {
subjectId: 'user-1',
username: 'user@example.com',
displayName: 'User Example',
tenant: 'tenant-a',
sessionId: 'session-123',
roles: ['role.a'],
scopes: ['scope.a'],
audiences: ['aud'],
authenticationMethods: ['pwd'],
issuedAt: new Date('2025-10-31T12:00:00Z'),
authenticationTime: new Date('2025-10-31T12:00:00Z'),
expiresAt: new Date('2025-10-31T12:10:00Z'),
freshAuth: true,
},
token: {
active: true,
tenant: 'tenant-a',
subject: 'user-1',
clientId: 'client',
tokenId: 'token-1',
scopes: ['scope.a'],
audiences: ['aud'],
issuedAt: new Date('2025-10-31T12:00:00Z'),
authenticationTime: new Date('2025-10-31T12:00:00Z'),
expiresAt: new Date('2025-10-31T12:10:00Z'),
freshAuthActive: true,
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
},
});
expect(store.selectedTenantId()).toBe('tenant-a');
expect(store.profile()?.displayName).toBe('User Example');
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
expect(store.hasContext()).toBeTrue();
});
it('clears state', () => {
store.setTenants(
[
{
id: 'tenant-a',
displayName: 'Tenant A',
status: 'active',
isolationMode: 'shared',
defaultRoles: [],
},
],
'tenant-a'
);
store.setProfile({
subjectId: null,
username: null,
displayName: null,
tenant: 'tenant-a',
sessionId: null,
roles: [],
scopes: [],
audiences: [],
authenticationMethods: [],
issuedAt: null,
authenticationTime: null,
expiresAt: null,
freshAuth: false,
});
store.clear();
expect(store.tenants().length).toBe(0);
expect(store.selectedTenantId()).toBeNull();
expect(store.profile()).toBeNull();
expect(store.tokenInfo()).toBeNull();
expect(store.loading()).toBeFalse();
expect(store.error()).toBeNull();
});
});

View File

@@ -1,137 +1,137 @@
import { Injectable, computed, signal } from '@angular/core';
export interface ConsoleTenant {
readonly id: string;
readonly displayName: string;
readonly status: string;
readonly isolationMode: string;
readonly defaultRoles: readonly string[];
}
export interface ConsoleProfile {
readonly subjectId: string | null;
readonly username: string | null;
readonly displayName: string | null;
readonly tenant: string;
readonly sessionId: string | null;
readonly roles: readonly string[];
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly authenticationMethods: readonly string[];
readonly issuedAt: Date | null;
readonly authenticationTime: Date | null;
readonly expiresAt: Date | null;
readonly freshAuth: boolean;
}
export interface ConsoleTokenInfo {
readonly active: boolean;
readonly tenant: string;
readonly subject: string | null;
readonly clientId: string | null;
readonly tokenId: string | null;
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly issuedAt: Date | null;
readonly authenticationTime: Date | null;
readonly expiresAt: Date | null;
readonly freshAuthActive: boolean;
readonly freshAuthExpiresAt: Date | null;
}
@Injectable({
providedIn: 'root',
})
export class ConsoleSessionStore {
private readonly tenantsSignal = signal<ConsoleTenant[]>([]);
private readonly selectedTenantIdSignal = signal<string | null>(null);
private readonly profileSignal = signal<ConsoleProfile | null>(null);
private readonly tokenSignal = signal<ConsoleTokenInfo | null>(null);
private readonly loadingSignal = signal(false);
private readonly errorSignal = signal<string | null>(null);
readonly tenants = computed(() => this.tenantsSignal());
readonly selectedTenantId = computed(() => this.selectedTenantIdSignal());
readonly profile = computed(() => this.profileSignal());
readonly tokenInfo = computed(() => this.tokenSignal());
readonly loading = computed(() => this.loadingSignal());
readonly error = computed(() => this.errorSignal());
readonly currentTenant = computed(() => {
const tenantId = this.selectedTenantIdSignal();
if (!tenantId) return null;
return this.tenantsSignal().find(tenant => tenant.id === tenantId) ?? null;
});
readonly hasContext = computed(
() =>
this.tenantsSignal().length > 0 ||
this.profileSignal() !== null ||
this.tokenSignal() !== null
);
setLoading(loading: boolean): void {
this.loadingSignal.set(loading);
}
setError(message: string | null): void {
this.errorSignal.set(message);
}
setContext(context: {
tenants: ConsoleTenant[];
profile: ConsoleProfile | null;
token: ConsoleTokenInfo | null;
selectedTenantId?: string | null;
}): void {
const selected = this.setTenants(context.tenants, context.selectedTenantId);
this.profileSignal.set(context.profile);
this.tokenSignal.set(context.token);
this.selectedTenantIdSignal.set(selected);
}
setProfile(profile: ConsoleProfile | null): void {
this.profileSignal.set(profile);
}
setTokenInfo(token: ConsoleTokenInfo | null): void {
this.tokenSignal.set(token);
}
setTenants(
tenants: ConsoleTenant[],
preferredTenantId?: string | null
): string | null {
this.tenantsSignal.set(tenants);
const currentSelection = this.selectedTenantIdSignal();
const fallbackSelection =
tenants.length > 0 ? tenants[0].id : null;
const nextSelection =
(preferredTenantId &&
tenants.some((tenant) => tenant.id === preferredTenantId) &&
preferredTenantId) ||
(currentSelection &&
tenants.some((tenant) => tenant.id === currentSelection) &&
currentSelection) ||
fallbackSelection;
this.selectedTenantIdSignal.set(nextSelection);
return nextSelection;
}
setSelectedTenant(tenantId: string | null): void {
this.selectedTenantIdSignal.set(tenantId);
}
currentTenantSnapshot(): ConsoleTenant | null {
return this.currentTenant();
}
clear(): void {
this.tenantsSignal.set([]);
this.selectedTenantIdSignal.set(null);
this.profileSignal.set(null);
this.tokenSignal.set(null);
this.loadingSignal.set(false);
this.errorSignal.set(null);
}
}
import { Injectable, computed, signal } from '@angular/core';
export interface ConsoleTenant {
readonly id: string;
readonly displayName: string;
readonly status: string;
readonly isolationMode: string;
readonly defaultRoles: readonly string[];
}
export interface ConsoleProfile {
readonly subjectId: string | null;
readonly username: string | null;
readonly displayName: string | null;
readonly tenant: string;
readonly sessionId: string | null;
readonly roles: readonly string[];
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly authenticationMethods: readonly string[];
readonly issuedAt: Date | null;
readonly authenticationTime: Date | null;
readonly expiresAt: Date | null;
readonly freshAuth: boolean;
}
export interface ConsoleTokenInfo {
readonly active: boolean;
readonly tenant: string;
readonly subject: string | null;
readonly clientId: string | null;
readonly tokenId: string | null;
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly issuedAt: Date | null;
readonly authenticationTime: Date | null;
readonly expiresAt: Date | null;
readonly freshAuthActive: boolean;
readonly freshAuthExpiresAt: Date | null;
}
@Injectable({
providedIn: 'root',
})
export class ConsoleSessionStore {
private readonly tenantsSignal = signal<ConsoleTenant[]>([]);
private readonly selectedTenantIdSignal = signal<string | null>(null);
private readonly profileSignal = signal<ConsoleProfile | null>(null);
private readonly tokenSignal = signal<ConsoleTokenInfo | null>(null);
private readonly loadingSignal = signal(false);
private readonly errorSignal = signal<string | null>(null);
readonly tenants = computed(() => this.tenantsSignal());
readonly selectedTenantId = computed(() => this.selectedTenantIdSignal());
readonly profile = computed(() => this.profileSignal());
readonly tokenInfo = computed(() => this.tokenSignal());
readonly loading = computed(() => this.loadingSignal());
readonly error = computed(() => this.errorSignal());
readonly currentTenant = computed(() => {
const tenantId = this.selectedTenantIdSignal();
if (!tenantId) return null;
return this.tenantsSignal().find(tenant => tenant.id === tenantId) ?? null;
});
readonly hasContext = computed(
() =>
this.tenantsSignal().length > 0 ||
this.profileSignal() !== null ||
this.tokenSignal() !== null
);
setLoading(loading: boolean): void {
this.loadingSignal.set(loading);
}
setError(message: string | null): void {
this.errorSignal.set(message);
}
setContext(context: {
tenants: ConsoleTenant[];
profile: ConsoleProfile | null;
token: ConsoleTokenInfo | null;
selectedTenantId?: string | null;
}): void {
const selected = this.setTenants(context.tenants, context.selectedTenantId);
this.profileSignal.set(context.profile);
this.tokenSignal.set(context.token);
this.selectedTenantIdSignal.set(selected);
}
setProfile(profile: ConsoleProfile | null): void {
this.profileSignal.set(profile);
}
setTokenInfo(token: ConsoleTokenInfo | null): void {
this.tokenSignal.set(token);
}
setTenants(
tenants: ConsoleTenant[],
preferredTenantId?: string | null
): string | null {
this.tenantsSignal.set(tenants);
const currentSelection = this.selectedTenantIdSignal();
const fallbackSelection =
tenants.length > 0 ? tenants[0].id : null;
const nextSelection =
(preferredTenantId &&
tenants.some((tenant) => tenant.id === preferredTenantId) &&
preferredTenantId) ||
(currentSelection &&
tenants.some((tenant) => tenant.id === currentSelection) &&
currentSelection) ||
fallbackSelection;
this.selectedTenantIdSignal.set(nextSelection);
return nextSelection;
}
setSelectedTenant(tenantId: string | null): void {
this.selectedTenantIdSignal.set(tenantId);
}
currentTenantSnapshot(): ConsoleTenant | null {
return this.currentTenant();
}
clear(): void {
this.tenantsSignal.set([]);
this.selectedTenantIdSignal.set(null);
this.profileSignal.set(null);
this.tokenSignal.set(null);
this.loadingSignal.set(false);
this.errorSignal.set(null);
}
}

View File

@@ -106,7 +106,7 @@ export class NavigationService {
const _ = this.activeRoute(); // Subscribe to route changes
this._mobileMenuOpen.set(false);
this._activeDropdown.set(null);
});
}, { allowSignalWrites: true });
}
// -------------------------------------------------------------------------

View File

@@ -1,35 +1,35 @@
import { Injectable, signal } from '@angular/core';
export interface OperatorContext {
readonly reason: string;
readonly ticket: string;
}
@Injectable({
providedIn: 'root',
})
export class OperatorContextService {
private readonly contextSignal = signal<OperatorContext | null>(null);
readonly context = this.contextSignal.asReadonly();
setContext(reason: string, ticket: string): void {
const normalizedReason = reason.trim();
const normalizedTicket = ticket.trim();
if (!normalizedReason || !normalizedTicket) {
throw new Error(
'operator_reason and operator_ticket must be provided for orchestrator control actions.'
);
}
this.contextSignal.set({ reason: normalizedReason, ticket: normalizedTicket });
}
clear(): void {
this.contextSignal.set(null);
}
snapshot(): OperatorContext | null {
return this.contextSignal();
}
}
import { Injectable, signal } from '@angular/core';
export interface OperatorContext {
readonly reason: string;
readonly ticket: string;
}
@Injectable({
providedIn: 'root',
})
export class OperatorContextService {
private readonly contextSignal = signal<OperatorContext | null>(null);
readonly context = this.contextSignal.asReadonly();
setContext(reason: string, ticket: string): void {
const normalizedReason = reason.trim();
const normalizedTicket = ticket.trim();
if (!normalizedReason || !normalizedTicket) {
throw new Error(
'operator_reason and operator_ticket must be provided for orchestrator control actions.'
);
}
this.contextSignal.set({ reason: normalizedReason, ticket: normalizedTicket });
}
clear(): void {
this.contextSignal.set(null);
}
snapshot(): OperatorContext | null {
return this.contextSignal();
}
}

View File

@@ -1,41 +1,41 @@
import {
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { OperatorContextService } from './operator-context.service';
export const OPERATOR_METADATA_SENTINEL_HEADER = 'X-Stella-Require-Operator';
export const OPERATOR_REASON_HEADER = 'X-Stella-Operator-Reason';
export const OPERATOR_TICKET_HEADER = 'X-Stella-Operator-Ticket';
@Injectable()
export class OperatorMetadataInterceptor implements HttpInterceptor {
constructor(private readonly context: OperatorContextService) {}
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
if (!request.headers.has(OPERATOR_METADATA_SENTINEL_HEADER)) {
return next.handle(request);
}
const current = this.context.snapshot();
const headers = request.headers.delete(OPERATOR_METADATA_SENTINEL_HEADER);
if (!current) {
return next.handle(request.clone({ headers }));
}
const enriched = headers
.set(OPERATOR_REASON_HEADER, current.reason)
.set(OPERATOR_TICKET_HEADER, current.ticket);
return next.handle(request.clone({ headers: enriched }));
}
}
import {
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { OperatorContextService } from './operator-context.service';
export const OPERATOR_METADATA_SENTINEL_HEADER = 'X-Stella-Require-Operator';
export const OPERATOR_REASON_HEADER = 'X-Stella-Operator-Reason';
export const OPERATOR_TICKET_HEADER = 'X-Stella-Operator-Ticket';
@Injectable()
export class OperatorMetadataInterceptor implements HttpInterceptor {
constructor(private readonly context: OperatorContextService) {}
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
if (!request.headers.has(OPERATOR_METADATA_SENTINEL_HEADER)) {
return next.handle(request);
}
const current = this.context.snapshot();
const headers = request.headers.delete(OPERATOR_METADATA_SENTINEL_HEADER);
if (!current) {
return next.handle(request.clone({ headers }));
}
const enriched = headers
.set(OPERATOR_REASON_HEADER, current.reason)
.set(OPERATOR_TICKET_HEADER, current.ticket);
return next.handle(request.clone({ headers: enriched }));
}
}

View File

@@ -350,7 +350,7 @@ import {
.stat-value.failed { color: #dc2626; }
.stat-value.pending { color: #d97706; }
.stat-value.throttled { color: #2563eb; }
.stat-value.rate { color: #6366f1; }
.stat-value.rate { color: #D4920A; }
.stat-label {
font-size: 0.75rem;

View File

@@ -239,7 +239,7 @@ interface ConfigSubTab {
.sent-icon { background: #10b981; }
.failed-icon { background: #ef4444; }
.pending-icon { background: #f59e0b; }
.rate-icon { background: #6366f1; }
.rate-icon { background: #D4920A; }
.stat-content {
display: flex;

View File

@@ -214,7 +214,7 @@ export const OBJECT_LINK_METADATA: Record<ObjectLinkType, { icon: string; color:
reach: { icon: 'git-branch', color: '#8b5cf6', label: 'Reachability' },
runtime: { icon: 'activity', color: '#f59e0b', label: 'Runtime' },
vex: { icon: 'shield', color: '#10b981', label: 'VEX' },
attest: { icon: 'file-signature', color: '#6366f1', label: 'Attestation' },
attest: { icon: 'file-signature', color: '#D4920A', label: 'Attestation' },
auth: { icon: 'key', color: '#ef4444', label: 'Auth' },
docs: { icon: 'book', color: '#64748b', label: 'Docs' },
finding: { icon: 'alert-triangle', color: '#f97316', label: 'Finding' },

View File

@@ -147,7 +147,7 @@ import {
.chip--reach { --chip-color: #8b5cf6; --chip-bg: rgba(139, 92, 246, 0.1); --chip-border: rgba(139, 92, 246, 0.2); }
.chip--runtime { --chip-color: #f59e0b; --chip-bg: rgba(245, 158, 11, 0.1); --chip-border: rgba(245, 158, 11, 0.2); }
.chip--vex { --chip-color: #10b981; --chip-bg: rgba(16, 185, 129, 0.1); --chip-border: rgba(16, 185, 129, 0.2); }
.chip--attest { --chip-color: #6366f1; --chip-bg: rgba(99, 102, 241, 0.1); --chip-border: rgba(99, 102, 241, 0.2); }
.chip--attest { --chip-color: #D4920A; --chip-bg: rgba(245, 166, 35, 0.1); --chip-border: rgba(245, 166, 35, 0.2); }
.chip--auth { --chip-color: #ef4444; --chip-bg: rgba(239, 68, 68, 0.1); --chip-border: rgba(239, 68, 68, 0.2); }
.chip--docs { --chip-color: #64748b; --chip-bg: rgba(100, 116, 139, 0.1); --chip-border: rgba(100, 116, 139, 0.2); }
.chip--finding { --chip-color: #f97316; --chip-bg: rgba(249, 115, 22, 0.1); --chip-border: rgba(249, 115, 22, 0.2); }

View File

@@ -207,7 +207,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
}
.evidence-type-badge.type-patch {
color: #4f46e5;
color: #F5A623;
background: #e0e7ff;
}

View File

@@ -471,7 +471,7 @@ import type {
}
.citation-type.type-patch {
color: #4f46e5;
color: #F5A623;
background: #e0e7ff;
}

View File

@@ -549,7 +549,7 @@ import type {
}
.step-type.type-vex_document {
color: #4f46e5;
color: #F5A623;
background: #e0e7ff;
}

View File

@@ -1,184 +1,184 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { AocClient } from '../../core/api/aoc.client';
import {
AocVerificationRequest,
AocVerificationResult,
AocViolationDetail,
} from '../../core/api/aoc.models';
type VerifyState = 'idle' | 'running' | 'completed' | 'error';
export interface CliParityGuidance {
command: string;
description: string;
flags: { flag: string; description: string }[];
examples: string[];
}
@Component({
selector: 'app-verify-action',
standalone: true,
imports: [CommonModule],
templateUrl: './verify-action.component.html',
styleUrls: ['./verify-action.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VerifyActionComponent {
private readonly aocClient = inject(AocClient);
/** Tenant ID to verify */
readonly tenantId = input.required<string>();
/** Time window in hours (default 24h) */
readonly windowHours = input(24);
/** Maximum documents to check */
readonly limit = input(10000);
/** Emits when verification completes */
readonly verified = output<AocVerificationResult>();
/** Emits when user clicks on a violation */
readonly selectViolation = output<AocViolationDetail>();
readonly state = signal<VerifyState>('idle');
readonly result = signal<AocVerificationResult | null>(null);
readonly error = signal<string | null>(null);
readonly progress = signal(0);
readonly showCliGuidance = signal(false);
readonly statusIcon = computed(() => {
switch (this.state()) {
case 'idle':
return '[ ]';
case 'running':
return '[~]';
case 'completed':
return this.result()?.status === 'passed' ? '[+]' : '[!]';
case 'error':
return '[X]';
default:
return '[?]';
}
});
readonly statusLabel = computed(() => {
switch (this.state()) {
case 'idle':
return 'Ready to verify';
case 'running':
return 'Verification in progress...';
case 'completed':
const r = this.result();
if (!r) return 'Completed';
return r.status === 'passed'
? 'Verification passed'
: r.status === 'failed'
? 'Verification failed'
: 'Verification completed with warnings';
case 'error':
return 'Verification error';
default:
return '';
}
});
readonly resultSummary = computed(() => {
const r = this.result();
if (!r) return null;
return {
passRate: ((r.passedCount / r.checkedCount) * 100).toFixed(2),
violationCount: r.violations.length,
uniqueCodes: [...new Set(r.violations.map((v) => v.violationCode))],
};
});
readonly cliGuidance: CliParityGuidance = {
command: 'stella aoc verify',
description:
'Run the same verification from CLI for automation, CI/CD pipelines, or detailed output.',
flags: [
{ flag: '--tenant', description: 'Tenant ID to verify' },
{ flag: '--since', description: 'Start time (ISO8601 or duration like "24h")' },
{ flag: '--limit', description: 'Maximum documents to check' },
{ flag: '--output', description: 'Output format: json, table, summary' },
{ flag: '--fail-on-violation', description: 'Exit with code 1 if any violations found' },
{ flag: '--verbose', description: 'Show detailed violation information' },
],
examples: [
'stella aoc verify --tenant $TENANT_ID --since 24h',
'stella aoc verify --tenant $TENANT_ID --since 24h --output json > report.json',
'stella aoc verify --tenant $TENANT_ID --since 24h --fail-on-violation',
],
};
async runVerification(): Promise<void> {
if (this.state() === 'running') return;
this.state.set('running');
this.error.set(null);
this.result.set(null);
this.progress.set(0);
// Simulate progress updates
const progressInterval = setInterval(() => {
this.progress.update((p) => Math.min(p + Math.random() * 15, 90));
}, 200);
const since = new Date();
since.setHours(since.getHours() - this.windowHours());
const request: AocVerificationRequest = {
tenantId: this.tenantId(),
since: since.toISOString(),
limit: this.limit(),
};
this.aocClient.verify(request).subscribe({
next: (result) => {
clearInterval(progressInterval);
this.progress.set(100);
this.result.set(result);
this.state.set('completed');
this.verified.emit(result);
},
error: (err) => {
clearInterval(progressInterval);
this.state.set('error');
this.error.set(err.message || 'Verification failed');
},
});
}
reset(): void {
this.state.set('idle');
this.result.set(null);
this.error.set(null);
this.progress.set(0);
}
toggleCliGuidance(): void {
this.showCliGuidance.update((v) => !v);
}
onSelectViolation(violation: AocViolationDetail): void {
this.selectViolation.emit(violation);
}
copyCommand(command: string): void {
navigator.clipboard.writeText(command);
}
getCliCommand(): string {
return `stella aoc verify --tenant ${this.tenantId()} --since ${this.windowHours()}h`;
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { AocClient } from '../../core/api/aoc.client';
import {
AocVerificationRequest,
AocVerificationResult,
AocViolationDetail,
} from '../../core/api/aoc.models';
type VerifyState = 'idle' | 'running' | 'completed' | 'error';
export interface CliParityGuidance {
command: string;
description: string;
flags: { flag: string; description: string }[];
examples: string[];
}
@Component({
selector: 'app-verify-action',
standalone: true,
imports: [CommonModule],
templateUrl: './verify-action.component.html',
styleUrls: ['./verify-action.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VerifyActionComponent {
private readonly aocClient = inject(AocClient);
/** Tenant ID to verify */
readonly tenantId = input.required<string>();
/** Time window in hours (default 24h) */
readonly windowHours = input(24);
/** Maximum documents to check */
readonly limit = input(10000);
/** Emits when verification completes */
readonly verified = output<AocVerificationResult>();
/** Emits when user clicks on a violation */
readonly selectViolation = output<AocViolationDetail>();
readonly state = signal<VerifyState>('idle');
readonly result = signal<AocVerificationResult | null>(null);
readonly error = signal<string | null>(null);
readonly progress = signal(0);
readonly showCliGuidance = signal(false);
readonly statusIcon = computed(() => {
switch (this.state()) {
case 'idle':
return '[ ]';
case 'running':
return '[~]';
case 'completed':
return this.result()?.status === 'passed' ? '[+]' : '[!]';
case 'error':
return '[X]';
default:
return '[?]';
}
});
readonly statusLabel = computed(() => {
switch (this.state()) {
case 'idle':
return 'Ready to verify';
case 'running':
return 'Verification in progress...';
case 'completed':
const r = this.result();
if (!r) return 'Completed';
return r.status === 'passed'
? 'Verification passed'
: r.status === 'failed'
? 'Verification failed'
: 'Verification completed with warnings';
case 'error':
return 'Verification error';
default:
return '';
}
});
readonly resultSummary = computed(() => {
const r = this.result();
if (!r) return null;
return {
passRate: ((r.passedCount / r.checkedCount) * 100).toFixed(2),
violationCount: r.violations.length,
uniqueCodes: [...new Set(r.violations.map((v) => v.violationCode))],
};
});
readonly cliGuidance: CliParityGuidance = {
command: 'stella aoc verify',
description:
'Run the same verification from CLI for automation, CI/CD pipelines, or detailed output.',
flags: [
{ flag: '--tenant', description: 'Tenant ID to verify' },
{ flag: '--since', description: 'Start time (ISO8601 or duration like "24h")' },
{ flag: '--limit', description: 'Maximum documents to check' },
{ flag: '--output', description: 'Output format: json, table, summary' },
{ flag: '--fail-on-violation', description: 'Exit with code 1 if any violations found' },
{ flag: '--verbose', description: 'Show detailed violation information' },
],
examples: [
'stella aoc verify --tenant $TENANT_ID --since 24h',
'stella aoc verify --tenant $TENANT_ID --since 24h --output json > report.json',
'stella aoc verify --tenant $TENANT_ID --since 24h --fail-on-violation',
],
};
async runVerification(): Promise<void> {
if (this.state() === 'running') return;
this.state.set('running');
this.error.set(null);
this.result.set(null);
this.progress.set(0);
// Simulate progress updates
const progressInterval = setInterval(() => {
this.progress.update((p) => Math.min(p + Math.random() * 15, 90));
}, 200);
const since = new Date();
since.setHours(since.getHours() - this.windowHours());
const request: AocVerificationRequest = {
tenantId: this.tenantId(),
since: since.toISOString(),
limit: this.limit(),
};
this.aocClient.verify(request).subscribe({
next: (result) => {
clearInterval(progressInterval);
this.progress.set(100);
this.result.set(result);
this.state.set('completed');
this.verified.emit(result);
},
error: (err) => {
clearInterval(progressInterval);
this.state.set('error');
this.error.set(err.message || 'Verification failed');
},
});
}
reset(): void {
this.state.set('idle');
this.result.set(null);
this.error.set(null);
this.progress.set(0);
}
toggleCliGuidance(): void {
this.showCliGuidance.update((v) => !v);
}
onSelectViolation(violation: AocViolationDetail): void {
this.selectViolation.emit(violation);
}
copyCommand(command: string): void {
navigator.clipboard.writeText(command);
}
getCliCommand(): string {
return `stella aoc verify --tenant ${this.tenantId()} --since ${this.windowHours()}h`;
}
}

View File

@@ -1,182 +1,182 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
AocViolationDetail,
AocViolationGroup,
AocDocumentView,
AocProvenance,
} from '../../core/api/aoc.models';
type ViewMode = 'by-violation' | 'by-document';
@Component({
selector: 'app-violation-drilldown',
standalone: true,
imports: [CommonModule],
templateUrl: './violation-drilldown.component.html',
styleUrls: ['./violation-drilldown.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ViolationDrilldownComponent {
/** Violation groups to display */
readonly violationGroups = input.required<AocViolationGroup[]>();
/** Document views for by-document mode */
readonly documentViews = input<AocDocumentView[]>([]);
/** Emits when user clicks on a document */
readonly selectDocument = output<string>();
/** Emits when user wants to view raw document */
readonly viewRawDocument = output<string>();
/** Current view mode */
readonly viewMode = signal<ViewMode>('by-violation');
/** Currently expanded violation code */
readonly expandedCode = signal<string | null>(null);
/** Currently expanded document ID */
readonly expandedDocId = signal<string | null>(null);
/** Search filter */
readonly searchFilter = signal('');
readonly filteredGroups = computed(() => {
const filter = this.searchFilter().toLowerCase();
if (!filter) return this.violationGroups();
return this.violationGroups().filter(
(g) =>
g.code.toLowerCase().includes(filter) ||
g.description.toLowerCase().includes(filter) ||
g.violations.some(
(v) =>
v.documentId.toLowerCase().includes(filter) ||
v.field?.toLowerCase().includes(filter)
)
);
});
readonly filteredDocuments = computed(() => {
const filter = this.searchFilter().toLowerCase();
if (!filter) return this.documentViews();
return this.documentViews().filter(
(d) =>
d.documentId.toLowerCase().includes(filter) ||
d.documentType.toLowerCase().includes(filter) ||
d.violations.some(
(v) =>
v.violationCode.toLowerCase().includes(filter) ||
v.field?.toLowerCase().includes(filter)
)
);
});
readonly totalViolations = computed(() =>
this.violationGroups().reduce((sum, g) => sum + g.violations.length, 0)
);
readonly totalDocuments = computed(() => {
const docIds = new Set<string>();
for (const group of this.violationGroups()) {
for (const v of group.violations) {
docIds.add(v.documentId);
}
}
return docIds.size;
});
readonly severityCounts = computed(() => {
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
for (const group of this.violationGroups()) {
counts[group.severity] += group.violations.length;
}
return counts;
});
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
toggleGroup(code: string): void {
this.expandedCode.update((current) => (current === code ? null : code));
}
toggleDocument(docId: string): void {
this.expandedDocId.update((current) => (current === docId ? null : docId));
}
onSearch(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchFilter.set(input.value);
}
onSelectDocument(docId: string): void {
this.selectDocument.emit(docId);
}
onViewRaw(docId: string): void {
this.viewRawDocument.emit(docId);
}
getSeverityIcon(severity: string): string {
switch (severity) {
case 'critical':
return '!!';
case 'high':
return '!';
case 'medium':
return '~';
default:
return '-';
}
}
getSourceTypeIcon(sourceType?: string): string {
switch (sourceType) {
case 'registry':
return '[R]';
case 'git':
return '[G]';
case 'upload':
return '[U]';
case 'api':
return '[A]';
default:
return '[?]';
}
}
formatDigest(digest: string, length = 12): string {
if (digest.length <= length) return digest;
return digest.slice(0, length) + '...';
}
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString();
}
isFieldHighlighted(doc: AocDocumentView, field: string): boolean {
return doc.highlightedFields.includes(field);
}
getFieldValue(content: Record<string, unknown> | undefined, path: string): string {
if (!content) return 'N/A';
const parts = path.split('.');
let current: unknown = content;
for (const part of parts) {
if (current == null || typeof current !== 'object') return 'N/A';
current = (current as Record<string, unknown>)[part];
}
if (current == null) return 'null';
if (typeof current === 'object') return JSON.stringify(current);
return String(current);
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
AocViolationDetail,
AocViolationGroup,
AocDocumentView,
AocProvenance,
} from '../../core/api/aoc.models';
type ViewMode = 'by-violation' | 'by-document';
@Component({
selector: 'app-violation-drilldown',
standalone: true,
imports: [CommonModule],
templateUrl: './violation-drilldown.component.html',
styleUrls: ['./violation-drilldown.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ViolationDrilldownComponent {
/** Violation groups to display */
readonly violationGroups = input.required<AocViolationGroup[]>();
/** Document views for by-document mode */
readonly documentViews = input<AocDocumentView[]>([]);
/** Emits when user clicks on a document */
readonly selectDocument = output<string>();
/** Emits when user wants to view raw document */
readonly viewRawDocument = output<string>();
/** Current view mode */
readonly viewMode = signal<ViewMode>('by-violation');
/** Currently expanded violation code */
readonly expandedCode = signal<string | null>(null);
/** Currently expanded document ID */
readonly expandedDocId = signal<string | null>(null);
/** Search filter */
readonly searchFilter = signal('');
readonly filteredGroups = computed(() => {
const filter = this.searchFilter().toLowerCase();
if (!filter) return this.violationGroups();
return this.violationGroups().filter(
(g) =>
g.code.toLowerCase().includes(filter) ||
g.description.toLowerCase().includes(filter) ||
g.violations.some(
(v) =>
v.documentId.toLowerCase().includes(filter) ||
v.field?.toLowerCase().includes(filter)
)
);
});
readonly filteredDocuments = computed(() => {
const filter = this.searchFilter().toLowerCase();
if (!filter) return this.documentViews();
return this.documentViews().filter(
(d) =>
d.documentId.toLowerCase().includes(filter) ||
d.documentType.toLowerCase().includes(filter) ||
d.violations.some(
(v) =>
v.violationCode.toLowerCase().includes(filter) ||
v.field?.toLowerCase().includes(filter)
)
);
});
readonly totalViolations = computed(() =>
this.violationGroups().reduce((sum, g) => sum + g.violations.length, 0)
);
readonly totalDocuments = computed(() => {
const docIds = new Set<string>();
for (const group of this.violationGroups()) {
for (const v of group.violations) {
docIds.add(v.documentId);
}
}
return docIds.size;
});
readonly severityCounts = computed(() => {
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
for (const group of this.violationGroups()) {
counts[group.severity] += group.violations.length;
}
return counts;
});
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
toggleGroup(code: string): void {
this.expandedCode.update((current) => (current === code ? null : code));
}
toggleDocument(docId: string): void {
this.expandedDocId.update((current) => (current === docId ? null : docId));
}
onSearch(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchFilter.set(input.value);
}
onSelectDocument(docId: string): void {
this.selectDocument.emit(docId);
}
onViewRaw(docId: string): void {
this.viewRawDocument.emit(docId);
}
getSeverityIcon(severity: string): string {
switch (severity) {
case 'critical':
return '!!';
case 'high':
return '!';
case 'medium':
return '~';
default:
return '-';
}
}
getSourceTypeIcon(sourceType?: string): string {
switch (sourceType) {
case 'registry':
return '[R]';
case 'git':
return '[G]';
case 'upload':
return '[U]';
case 'api':
return '[A]';
default:
return '[?]';
}
}
formatDigest(digest: string, length = 12): string {
if (digest.length <= length) return digest;
return digest.slice(0, length) + '...';
}
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString();
}
isFieldHighlighted(doc: AocDocumentView, field: string): boolean {
return doc.highlightedFields.includes(field);
}
getFieldValue(content: Record<string, unknown> | undefined, path: string): string {
if (!content) return 'N/A';
const parts = path.split('.');
let current: unknown = content;
for (const part of parts) {
if (current == null || typeof current !== 'object') return 'N/A';
current = (current as Record<string, unknown>)[part];
}
if (current == null) return 'null';
if (typeof current === 'object') return JSON.stringify(current);
return String(current);
}
}

View File

@@ -143,7 +143,7 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
.stat-card.authority { border-left: 4px solid #8b5cf6; }
.stat-card.vex { border-left: 4px solid #10b981; }
.stat-card.integrations { border-left: 4px solid #f59e0b; }
.stat-card.orchestrator { border-left: 4px solid #6366f1; }
.stat-card.orchestrator { border-left: 4px solid #D4920A; }
.anomaly-alerts { margin-bottom: 2rem; }
.anomaly-alerts h2 { margin: 0 0 1rem; font-size: 1.1rem; }
.alert-list { display: flex; gap: 1rem; flex-wrap: wrap; }

View File

@@ -1,61 +1,61 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
@Component({
selector: 'app-auth-callback',
standalone: true,
imports: [CommonModule],
template: `
<section class="auth-callback">
<p *ngIf="state() === 'processing'">Completing sign-in…</p>
<p *ngIf="state() === 'error'" class="error">
We were unable to complete the sign-in flow. Please try again.
</p>
</section>
`,
styles: [
`
.auth-callback {
margin: 4rem auto;
max-width: 420px;
text-align: center;
font-size: 1rem;
color: #0f172a;
}
.error {
color: #dc2626;
font-weight: 500;
}
`,
],
})
export class AuthCallbackComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly auth = inject(AuthorityAuthService);
readonly state = signal<'processing' | 'error'>('processing');
async ngOnInit(): Promise<void> {
const params = this.route.snapshot.queryParamMap;
const searchParams = new URLSearchParams();
params.keys.forEach((key) => {
const value = params.get(key);
if (value != null) {
searchParams.set(key, value);
}
});
try {
const result = await this.auth.completeLoginFromRedirect(searchParams);
const returnUrl = result.returnUrl ?? '/';
await this.router.navigateByUrl(returnUrl, { replaceUrl: true });
} catch {
this.state.set('error');
}
}
}
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
@Component({
selector: 'app-auth-callback',
standalone: true,
imports: [CommonModule],
template: `
<section class="auth-callback">
<p *ngIf="state() === 'processing'">Completing sign-in…</p>
<p *ngIf="state() === 'error'" class="error">
We were unable to complete the sign-in flow. Please try again.
</p>
</section>
`,
styles: [
`
.auth-callback {
margin: 4rem auto;
max-width: 420px;
text-align: center;
font-size: 1rem;
color: #0f172a;
}
.error {
color: #dc2626;
font-weight: 500;
}
`,
],
})
export class AuthCallbackComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly auth = inject(AuthorityAuthService);
readonly state = signal<'processing' | 'error'>('processing');
async ngOnInit(): Promise<void> {
const params = this.route.snapshot.queryParamMap;
const searchParams = new URLSearchParams();
params.keys.forEach((key) => {
const value = params.get(key);
if (value != null) {
searchParams.set(key, value);
}
});
try {
const result = await this.auth.completeLoginFromRedirect(searchParams);
const returnUrl = result.returnUrl ?? '/';
await this.router.navigateByUrl(returnUrl, { replaceUrl: true });
} catch {
this.state.set('error');
}
}
}

View File

@@ -1,226 +1,226 @@
@use 'tokens/breakpoints' as *;
.console-profile {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.console-profile__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-3);
h1 {
margin: 0;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.console-profile__subtitle {
margin: var(--space-1) 0 0;
color: var(--color-text-secondary);
font-size: var(--font-size-base);
}
button {
appearance: none;
border: none;
border-radius: var(--radius-lg);
padding: var(--space-2) var(--space-4);
font-weight: var(--font-weight-semibold);
cursor: pointer;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
box-shadow var(--motion-duration-fast) var(--motion-ease-default);
&:hover,
&:focus-visible {
transform: translateY(-1px);
background: var(--color-brand-primary-hover);
box-shadow: var(--shadow-lg);
}
&:disabled {
cursor: progress;
opacity: 0.75;
transform: none;
box-shadow: none;
}
}
}
.console-profile__loading {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
background: var(--color-brand-light);
color: var(--color-brand-primary);
font-weight: var(--font-weight-medium);
}
.console-profile__error {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
background: var(--color-status-error-bg);
color: var(--color-status-error);
border: 1px solid var(--color-status-error);
}
.console-profile__card {
background: var(--color-surface-primary);
border-radius: var(--radius-xl);
padding: var(--space-4);
box-shadow: var(--shadow-lg);
border: 1px solid var(--color-border-primary);
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-4);
h2 {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
}
dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-3) var(--space-4);
margin: 0;
dt {
margin: 0 0 var(--space-1);
font-size: var(--font-size-xs);
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-muted);
}
dd {
margin: 0;
font-size: var(--font-size-base);
color: var(--color-text-primary);
word-break: break-word;
}
}
}
.chip,
.tenant-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2-5);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
background-color: var(--color-surface-secondary);
color: var(--color-text-primary);
}
.chip--active {
background-color: var(--color-status-success-bg);
color: var(--color-status-success);
}
.chip--inactive {
background-color: var(--color-status-error-bg);
color: var(--color-status-error);
}
.tenant-chip {
background-color: var(--color-brand-light);
color: var(--color-brand-primary);
}
.tenant-count {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.tenant-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: var(--space-2-5);
}
.tenant-list__item--active button {
border-color: var(--color-brand-primary);
background-color: var(--color-brand-light);
}
.tenant-list button {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
padding: var(--space-2-5) var(--space-3);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
text-align: left;
cursor: pointer;
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
box-shadow var(--motion-duration-fast) var(--motion-ease-default),
transform var(--motion-duration-fast) var(--motion-ease-default);
&:hover,
&:focus-visible {
border-color: var(--color-brand-primary);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
}
.tenant-list__heading {
display: flex;
justify-content: space-between;
width: 100%;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.tenant-meta {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.fresh-auth {
margin-top: var(--space-4);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-lg);
font-size: var(--font-size-sm);
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.fresh-auth--active {
background-color: var(--color-status-success-bg);
color: var(--color-status-success);
}
.fresh-auth--stale {
background-color: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
.console-profile__empty {
margin: 0;
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
@use 'tokens/breakpoints' as *;
.console-profile {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.console-profile__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-3);
h1 {
margin: 0;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.console-profile__subtitle {
margin: var(--space-1) 0 0;
color: var(--color-text-secondary);
font-size: var(--font-size-base);
}
button {
appearance: none;
border: none;
border-radius: var(--radius-lg);
padding: var(--space-2) var(--space-4);
font-weight: var(--font-weight-semibold);
cursor: pointer;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
box-shadow var(--motion-duration-fast) var(--motion-ease-default);
&:hover,
&:focus-visible {
transform: translateY(-1px);
background: var(--color-brand-primary-hover);
box-shadow: var(--shadow-lg);
}
&:disabled {
cursor: progress;
opacity: 0.75;
transform: none;
box-shadow: none;
}
}
}
.console-profile__loading {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
background: var(--color-brand-light);
color: var(--color-brand-primary);
font-weight: var(--font-weight-medium);
}
.console-profile__error {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
background: var(--color-status-error-bg);
color: var(--color-status-error);
border: 1px solid var(--color-status-error);
}
.console-profile__card {
background: var(--color-surface-primary);
border-radius: var(--radius-xl);
padding: var(--space-4);
box-shadow: var(--shadow-lg);
border: 1px solid var(--color-border-primary);
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-4);
h2 {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
}
dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-3) var(--space-4);
margin: 0;
dt {
margin: 0 0 var(--space-1);
font-size: var(--font-size-xs);
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-muted);
}
dd {
margin: 0;
font-size: var(--font-size-base);
color: var(--color-text-primary);
word-break: break-word;
}
}
}
.chip,
.tenant-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2-5);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
background-color: var(--color-surface-secondary);
color: var(--color-text-primary);
}
.chip--active {
background-color: var(--color-status-success-bg);
color: var(--color-status-success);
}
.chip--inactive {
background-color: var(--color-status-error-bg);
color: var(--color-status-error);
}
.tenant-chip {
background-color: var(--color-brand-light);
color: var(--color-brand-primary);
}
.tenant-count {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.tenant-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: var(--space-2-5);
}
.tenant-list__item--active button {
border-color: var(--color-brand-primary);
background-color: var(--color-brand-light);
}
.tenant-list button {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
padding: var(--space-2-5) var(--space-3);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
text-align: left;
cursor: pointer;
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
box-shadow var(--motion-duration-fast) var(--motion-ease-default),
transform var(--motion-duration-fast) var(--motion-ease-default);
&:hover,
&:focus-visible {
border-color: var(--color-brand-primary);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
}
.tenant-list__heading {
display: flex;
justify-content: space-between;
width: 100%;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.tenant-meta {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.fresh-auth {
margin-top: var(--space-4);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-lg);
font-size: var(--font-size-sm);
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.fresh-auth--active {
background-color: var(--color-status-success-bg);
color: var(--color-status-success);
}
.fresh-auth--stale {
background-color: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
.console-profile__empty {
margin: 0;
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}

View File

@@ -1,110 +1,110 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConsoleSessionService } from '../../core/console/console-session.service';
import { ConsoleSessionStore } from '../../core/console/console-session.store';
import { ConsoleProfileComponent } from './console-profile.component';
class MockConsoleSessionService {
loadConsoleContext = jasmine
.createSpy('loadConsoleContext')
.and.returnValue(Promise.resolve());
refresh = jasmine
.createSpy('refresh')
.and.returnValue(Promise.resolve());
switchTenant = jasmine
.createSpy('switchTenant')
.and.returnValue(Promise.resolve());
}
describe('ConsoleProfileComponent', () => {
let fixture: ComponentFixture<ConsoleProfileComponent>;
let service: MockConsoleSessionService;
let store: ConsoleSessionStore;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ConsoleProfileComponent],
providers: [
ConsoleSessionStore,
{ provide: ConsoleSessionService, useClass: MockConsoleSessionService },
],
}).compileComponents();
service = TestBed.inject(
ConsoleSessionService
) as unknown as MockConsoleSessionService;
store = TestBed.inject(ConsoleSessionStore);
fixture = TestBed.createComponent(ConsoleProfileComponent);
});
it('renders profile and tenant information', async () => {
store.setContext({
tenants: [
{
id: 'tenant-default',
displayName: 'Tenant Default',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.console'],
},
],
selectedTenantId: 'tenant-default',
profile: {
subjectId: 'user-1',
username: 'user@example.com',
displayName: 'Console User',
tenant: 'tenant-default',
sessionId: 'session-1',
roles: ['role.console'],
scopes: ['ui.read'],
audiences: ['console'],
authenticationMethods: ['pwd'],
issuedAt: new Date('2025-10-31T12:00:00Z'),
authenticationTime: new Date('2025-10-31T12:00:00Z'),
expiresAt: new Date('2025-10-31T12:10:00Z'),
freshAuth: true,
},
token: {
active: true,
tenant: 'tenant-default',
subject: 'user-1',
clientId: 'console-web',
tokenId: 'token-1',
scopes: ['ui.read'],
audiences: ['console'],
issuedAt: new Date('2025-10-31T12:00:00Z'),
authenticationTime: new Date('2025-10-31T12:00:00Z'),
expiresAt: new Date('2025-10-31T12:10:00Z'),
freshAuthActive: true,
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
},
});
fixture.detectChanges();
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain(
'Console Session'
);
expect(compiled.querySelector('.tenant-name')?.textContent).toContain(
'Tenant Default'
);
expect(compiled.querySelector('dd')?.textContent).toContain('Console User');
expect(service.loadConsoleContext).not.toHaveBeenCalled();
});
it('invokes refresh on demand', async () => {
store.clear();
fixture.detectChanges();
await fixture.whenStable();
const button = fixture.nativeElement.querySelector(
'button[type="button"]'
) as HTMLButtonElement;
button.click();
await fixture.whenStable();
expect(service.refresh).toHaveBeenCalled();
});
});
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConsoleSessionService } from '../../core/console/console-session.service';
import { ConsoleSessionStore } from '../../core/console/console-session.store';
import { ConsoleProfileComponent } from './console-profile.component';
class MockConsoleSessionService {
loadConsoleContext = jasmine
.createSpy('loadConsoleContext')
.and.returnValue(Promise.resolve());
refresh = jasmine
.createSpy('refresh')
.and.returnValue(Promise.resolve());
switchTenant = jasmine
.createSpy('switchTenant')
.and.returnValue(Promise.resolve());
}
describe('ConsoleProfileComponent', () => {
let fixture: ComponentFixture<ConsoleProfileComponent>;
let service: MockConsoleSessionService;
let store: ConsoleSessionStore;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ConsoleProfileComponent],
providers: [
ConsoleSessionStore,
{ provide: ConsoleSessionService, useClass: MockConsoleSessionService },
],
}).compileComponents();
service = TestBed.inject(
ConsoleSessionService
) as unknown as MockConsoleSessionService;
store = TestBed.inject(ConsoleSessionStore);
fixture = TestBed.createComponent(ConsoleProfileComponent);
});
it('renders profile and tenant information', async () => {
store.setContext({
tenants: [
{
id: 'tenant-default',
displayName: 'Tenant Default',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.console'],
},
],
selectedTenantId: 'tenant-default',
profile: {
subjectId: 'user-1',
username: 'user@example.com',
displayName: 'Console User',
tenant: 'tenant-default',
sessionId: 'session-1',
roles: ['role.console'],
scopes: ['ui.read'],
audiences: ['console'],
authenticationMethods: ['pwd'],
issuedAt: new Date('2025-10-31T12:00:00Z'),
authenticationTime: new Date('2025-10-31T12:00:00Z'),
expiresAt: new Date('2025-10-31T12:10:00Z'),
freshAuth: true,
},
token: {
active: true,
tenant: 'tenant-default',
subject: 'user-1',
clientId: 'console-web',
tokenId: 'token-1',
scopes: ['ui.read'],
audiences: ['console'],
issuedAt: new Date('2025-10-31T12:00:00Z'),
authenticationTime: new Date('2025-10-31T12:00:00Z'),
expiresAt: new Date('2025-10-31T12:10:00Z'),
freshAuthActive: true,
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
},
});
fixture.detectChanges();
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain(
'Console Session'
);
expect(compiled.querySelector('.tenant-name')?.textContent).toContain(
'Tenant Default'
);
expect(compiled.querySelector('dd')?.textContent).toContain('Console User');
expect(service.loadConsoleContext).not.toHaveBeenCalled();
});
it('invokes refresh on demand', async () => {
store.clear();
fixture.detectChanges();
await fixture.whenStable();
const button = fixture.nativeElement.querySelector(
'button[type="button"]'
) as HTMLButtonElement;
button.click();
await fixture.whenStable();
expect(service.refresh).toHaveBeenCalled();
});
});

View File

@@ -1,70 +1,70 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
} from '@angular/core';
import { ConsoleSessionService } from '../../core/console/console-session.service';
import { ConsoleSessionStore } from '../../core/console/console-session.store';
@Component({
selector: 'app-console-profile',
standalone: true,
imports: [CommonModule],
templateUrl: './console-profile.component.html',
styleUrls: ['./console-profile.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConsoleProfileComponent implements OnInit {
private readonly store = inject(ConsoleSessionStore);
private readonly service = inject(ConsoleSessionService);
readonly loading = this.store.loading;
readonly error = this.store.error;
readonly profile = this.store.profile;
readonly tokenInfo = this.store.tokenInfo;
readonly tenants = this.store.tenants;
readonly selectedTenantId = this.store.selectedTenantId;
readonly hasProfile = computed(() => this.profile() !== null);
readonly tenantCount = computed(() => this.tenants().length);
readonly freshAuthState = computed(() => {
const token = this.tokenInfo();
if (!token) {
return null;
}
return {
active: token.freshAuthActive,
expiresAt: token.freshAuthExpiresAt,
};
});
async ngOnInit(): Promise<void> {
if (!this.store.hasContext()) {
try {
await this.service.loadConsoleContext();
} catch {
// error surfaced via store
}
}
}
async refresh(): Promise<void> {
try {
await this.service.refresh();
} catch {
// error surfaced via store
}
}
async selectTenant(tenantId: string): Promise<void> {
try {
await this.service.switchTenant(tenantId);
} catch {
// error surfaced via store
}
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
} from '@angular/core';
import { ConsoleSessionService } from '../../core/console/console-session.service';
import { ConsoleSessionStore } from '../../core/console/console-session.store';
@Component({
selector: 'app-console-profile',
standalone: true,
imports: [CommonModule],
templateUrl: './console-profile.component.html',
styleUrls: ['./console-profile.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConsoleProfileComponent implements OnInit {
private readonly store = inject(ConsoleSessionStore);
private readonly service = inject(ConsoleSessionService);
readonly loading = this.store.loading;
readonly error = this.store.error;
readonly profile = this.store.profile;
readonly tokenInfo = this.store.tokenInfo;
readonly tenants = this.store.tenants;
readonly selectedTenantId = this.store.selectedTenantId;
readonly hasProfile = computed(() => this.profile() !== null);
readonly tenantCount = computed(() => this.tenants().length);
readonly freshAuthState = computed(() => {
const token = this.tokenInfo();
if (!token) {
return null;
}
return {
active: token.freshAuthActive,
expiresAt: token.freshAuthExpiresAt,
};
});
async ngOnInit(): Promise<void> {
if (!this.store.hasContext()) {
try {
await this.service.loadConsoleContext();
} catch {
// error surfaced via store
}
}
}
async refresh(): Promise<void> {
try {
await this.service.refresh();
} catch {
// error surfaced via store
}
}
async selectTenant(tenantId: string): Promise<void> {
try {
await this.service.switchTenant(tenantId);
} catch {
// error surfaced via store
}
}
}

View File

@@ -256,7 +256,7 @@ export interface DashboardAiData {
justify-content: center;
width: 1.5rem;
height: 1.5rem;
background: #4f46e5;
background: #F5A623;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
@@ -293,7 +293,7 @@ export interface DashboardAiData {
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.75rem;
color: #4f46e5;
color: #F5A623;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
@@ -302,7 +302,7 @@ export interface DashboardAiData {
.ai-risk-drivers__evidence-link:hover,
.ai-risk-drivers__action:hover {
background: #eef2ff;
border-color: #a5b4fc;
border-color: #FFCF70;
}
.ai-risk-drivers__empty {

View File

@@ -1,350 +1,350 @@
@use 'tokens/breakpoints' as *;
.sources-dashboard {
padding: var(--space-6);
max-width: 1400px;
margin: 0 auto;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-6);
h1 {
margin: 0;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.actions {
display: flex;
gap: var(--space-2);
}
}
.btn {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
cursor: pointer;
border: 1px solid transparent;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
border-color var(--motion-duration-fast) var(--motion-ease-default);
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&-primary {
background-color: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover:not(:disabled) {
background-color: var(--color-brand-primary-hover);
}
}
&-secondary {
background-color: transparent;
border-color: var(--color-border-primary);
color: var(--color-text-primary);
&:hover:not(:disabled) {
background-color: var(--color-surface-secondary);
}
}
}
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-12);
text-align: center;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-message {
color: var(--color-status-error);
margin-bottom: var(--space-4);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: var(--space-6);
}
.tile {
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
overflow: hidden;
&-title {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
padding: var(--space-3) var(--space-4);
margin: 0;
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-primary);
color: var(--color-text-primary);
}
&-content {
padding: var(--space-4);
}
}
.tile-pass-fail {
&.excellent .metric-large .value { color: var(--color-status-success); }
&.good .metric-large .value { color: var(--color-status-success); }
&.warning .metric-large .value { color: var(--color-status-warning); }
&.critical .metric-large .value { color: var(--color-status-error); }
}
.metric-large {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: var(--space-4);
.value {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
line-height: 1;
color: var(--color-text-primary);
}
.label {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: var(--space-1);
}
}
.metric-details {
display: flex;
justify-content: space-around;
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-primary);
.detail {
display: flex;
flex-direction: column;
align-items: center;
}
.count {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
&.pass { color: var(--color-status-success); }
&.fail { color: var(--color-status-error); }
&.total { color: var(--color-text-primary); }
}
.label {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
.violations-list {
list-style: none;
padding: 0;
margin: 0;
}
.violation-item {
padding: var(--space-3);
border-radius: var(--radius-sm);
margin-bottom: var(--space-2);
background: var(--color-surface-secondary);
&.severity-critical { border-left: 3px solid var(--color-severity-critical); }
&.severity-high { border-left: 3px solid var(--color-severity-high); }
&.severity-medium { border-left: 3px solid var(--color-severity-medium); }
&.severity-low { border-left: 3px solid var(--color-severity-low); }
}
.violation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-1);
}
.violation-code {
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.violation-count {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.violation-desc {
font-size: var(--font-size-sm);
margin: 0 0 var(--space-1);
color: var(--color-text-primary);
}
.violation-time {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.empty-state {
color: var(--color-text-muted);
font-style: italic;
text-align: center;
padding: var(--space-4);
}
.throughput-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: var(--space-4);
text-align: center;
}
.throughput-item {
display: flex;
flex-direction: column;
.value {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.label {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-transform: uppercase;
}
}
.tile-throughput {
&.critical .throughput-item .value { color: var(--color-status-error); }
&.warning .throughput-item .value { color: var(--color-status-warning); }
}
.verification-result {
margin-top: var(--space-6);
padding: var(--space-4);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
&.status-passed { background: var(--color-status-success-bg); border-color: var(--color-status-success); }
&.status-failed { background: var(--color-status-error-bg); border-color: var(--color-status-error); }
&.status-partial { background: var(--color-status-warning-bg); border-color: var(--color-status-warning); }
h3 {
margin: 0 0 var(--space-2);
font-size: var(--font-size-md);
color: var(--color-text-primary);
}
.result-summary {
display: flex;
gap: var(--space-4);
flex-wrap: wrap;
margin-bottom: var(--space-3);
}
.status-badge {
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
}
&.status-passed .status-badge { background: var(--color-status-success); color: var(--color-text-inverse); }
&.status-failed .status-badge { background: var(--color-status-error); color: var(--color-text-inverse); }
&.status-partial .status-badge { background: var(--color-status-warning); color: var(--color-text-inverse); }
}
.violations-details {
margin: var(--space-3) 0;
summary {
cursor: pointer;
color: var(--color-brand-primary);
font-size: var(--font-size-base);
}
.violation-list {
margin-top: var(--space-2);
padding-left: var(--space-5);
font-size: var(--font-size-sm);
li {
margin-bottom: var(--space-2);
}
}
}
.cli-hint {
margin: var(--space-3) 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
code {
background: var(--color-surface-tertiary);
padding: var(--space-0-5) var(--space-1-5);
border-radius: var(--radius-xs);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
}
}
.time-window {
margin-top: var(--space-4);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-align: center;
}
// Responsive
@include screen-below-md {
.sources-dashboard {
padding: var(--space-4);
}
.dashboard-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
}
.metrics-grid {
grid-template-columns: 1fr;
}
}
@use 'tokens/breakpoints' as *;
.sources-dashboard {
padding: var(--space-6);
max-width: 1400px;
margin: 0 auto;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-6);
h1 {
margin: 0;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.actions {
display: flex;
gap: var(--space-2);
}
}
.btn {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
cursor: pointer;
border: 1px solid transparent;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
border-color var(--motion-duration-fast) var(--motion-ease-default);
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&-primary {
background-color: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover:not(:disabled) {
background-color: var(--color-brand-primary-hover);
}
}
&-secondary {
background-color: transparent;
border-color: var(--color-border-primary);
color: var(--color-text-primary);
&:hover:not(:disabled) {
background-color: var(--color-surface-secondary);
}
}
}
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-12);
text-align: center;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-message {
color: var(--color-status-error);
margin-bottom: var(--space-4);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: var(--space-6);
}
.tile {
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
overflow: hidden;
&-title {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
padding: var(--space-3) var(--space-4);
margin: 0;
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-primary);
color: var(--color-text-primary);
}
&-content {
padding: var(--space-4);
}
}
.tile-pass-fail {
&.excellent .metric-large .value { color: var(--color-status-success); }
&.good .metric-large .value { color: var(--color-status-success); }
&.warning .metric-large .value { color: var(--color-status-warning); }
&.critical .metric-large .value { color: var(--color-status-error); }
}
.metric-large {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: var(--space-4);
.value {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
line-height: 1;
color: var(--color-text-primary);
}
.label {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: var(--space-1);
}
}
.metric-details {
display: flex;
justify-content: space-around;
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-primary);
.detail {
display: flex;
flex-direction: column;
align-items: center;
}
.count {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
&.pass { color: var(--color-status-success); }
&.fail { color: var(--color-status-error); }
&.total { color: var(--color-text-primary); }
}
.label {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
.violations-list {
list-style: none;
padding: 0;
margin: 0;
}
.violation-item {
padding: var(--space-3);
border-radius: var(--radius-sm);
margin-bottom: var(--space-2);
background: var(--color-surface-secondary);
&.severity-critical { border-left: 3px solid var(--color-severity-critical); }
&.severity-high { border-left: 3px solid var(--color-severity-high); }
&.severity-medium { border-left: 3px solid var(--color-severity-medium); }
&.severity-low { border-left: 3px solid var(--color-severity-low); }
}
.violation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-1);
}
.violation-code {
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.violation-count {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.violation-desc {
font-size: var(--font-size-sm);
margin: 0 0 var(--space-1);
color: var(--color-text-primary);
}
.violation-time {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.empty-state {
color: var(--color-text-muted);
font-style: italic;
text-align: center;
padding: var(--space-4);
}
.throughput-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: var(--space-4);
text-align: center;
}
.throughput-item {
display: flex;
flex-direction: column;
.value {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.label {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-transform: uppercase;
}
}
.tile-throughput {
&.critical .throughput-item .value { color: var(--color-status-error); }
&.warning .throughput-item .value { color: var(--color-status-warning); }
}
.verification-result {
margin-top: var(--space-6);
padding: var(--space-4);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
&.status-passed { background: var(--color-status-success-bg); border-color: var(--color-status-success); }
&.status-failed { background: var(--color-status-error-bg); border-color: var(--color-status-error); }
&.status-partial { background: var(--color-status-warning-bg); border-color: var(--color-status-warning); }
h3 {
margin: 0 0 var(--space-2);
font-size: var(--font-size-md);
color: var(--color-text-primary);
}
.result-summary {
display: flex;
gap: var(--space-4);
flex-wrap: wrap;
margin-bottom: var(--space-3);
}
.status-badge {
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
}
&.status-passed .status-badge { background: var(--color-status-success); color: var(--color-text-inverse); }
&.status-failed .status-badge { background: var(--color-status-error); color: var(--color-text-inverse); }
&.status-partial .status-badge { background: var(--color-status-warning); color: var(--color-text-inverse); }
}
.violations-details {
margin: var(--space-3) 0;
summary {
cursor: pointer;
color: var(--color-brand-primary);
font-size: var(--font-size-base);
}
.violation-list {
margin-top: var(--space-2);
padding-left: var(--space-5);
font-size: var(--font-size-sm);
li {
margin-bottom: var(--space-2);
}
}
}
.cli-hint {
margin: var(--space-3) 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
code {
background: var(--color-surface-tertiary);
padding: var(--space-0-5) var(--space-1-5);
border-radius: var(--radius-xs);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
}
}
.time-window {
margin-top: var(--space-4);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-align: center;
}
// Responsive
@include screen-below-md {
.sources-dashboard {
padding: var(--space-4);
}
.dashboard-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
}
.metrics-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,111 +1,111 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
} from '@angular/core';
import { AocClient } from '../../core/api/aoc.client';
import {
AocMetrics,
AocViolationSummary,
AocVerificationResult,
} from '../../core/api/aoc.models';
@Component({
selector: 'app-sources-dashboard',
standalone: true,
imports: [CommonModule],
templateUrl: './sources-dashboard.component.html',
styleUrls: ['./sources-dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SourcesDashboardComponent implements OnInit {
private readonly aocClient = inject(AocClient);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly metrics = signal<AocMetrics | null>(null);
readonly verifying = signal(false);
readonly verificationResult = signal<AocVerificationResult | null>(null);
readonly passRate = computed(() => {
const m = this.metrics();
return m ? m.passRate.toFixed(2) : '0.00';
});
readonly passRateClass = computed(() => {
const m = this.metrics();
if (!m) return 'neutral';
if (m.passRate >= 99.5) return 'excellent';
if (m.passRate >= 95) return 'good';
if (m.passRate >= 90) return 'warning';
return 'critical';
});
readonly throughputStatus = computed(() => {
const m = this.metrics();
if (!m) return 'neutral';
if (m.ingestThroughput.queueDepth > 100) return 'critical';
if (m.ingestThroughput.queueDepth > 50) return 'warning';
return 'good';
});
ngOnInit(): void {
this.loadMetrics();
}
loadMetrics(): void {
this.loading.set(true);
this.error.set(null);
this.aocClient.getMetrics('default').subscribe({
next: (metrics) => {
this.metrics.set(metrics);
this.loading.set(false);
},
error: (err) => {
this.error.set('Failed to load AOC metrics');
this.loading.set(false);
console.error('AOC metrics error:', err);
},
});
}
onVerifyLast24h(): void {
this.verifying.set(true);
this.verificationResult.set(null);
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
this.aocClient.verify({ tenantId: 'default', since }).subscribe({
next: (result) => {
this.verificationResult.set(result);
this.verifying.set(false);
},
error: (err) => {
this.verifying.set(false);
console.error('AOC verification error:', err);
},
});
}
getSeverityClass(severity: AocViolationSummary['severity']): string {
return 'severity-' + severity;
}
formatRelativeTime(isoDate: string): string {
const date = new Date(isoDate);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return diffMins + 'm ago';
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return diffHours + 'h ago';
const diffDays = Math.floor(diffHours / 24);
return diffDays + 'd ago';
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
} from '@angular/core';
import { AocClient } from '../../core/api/aoc.client';
import {
AocMetrics,
AocViolationSummary,
AocVerificationResult,
} from '../../core/api/aoc.models';
@Component({
selector: 'app-sources-dashboard',
standalone: true,
imports: [CommonModule],
templateUrl: './sources-dashboard.component.html',
styleUrls: ['./sources-dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SourcesDashboardComponent implements OnInit {
private readonly aocClient = inject(AocClient);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly metrics = signal<AocMetrics | null>(null);
readonly verifying = signal(false);
readonly verificationResult = signal<AocVerificationResult | null>(null);
readonly passRate = computed(() => {
const m = this.metrics();
return m ? m.passRate.toFixed(2) : '0.00';
});
readonly passRateClass = computed(() => {
const m = this.metrics();
if (!m) return 'neutral';
if (m.passRate >= 99.5) return 'excellent';
if (m.passRate >= 95) return 'good';
if (m.passRate >= 90) return 'warning';
return 'critical';
});
readonly throughputStatus = computed(() => {
const m = this.metrics();
if (!m) return 'neutral';
if (m.ingestThroughput.queueDepth > 100) return 'critical';
if (m.ingestThroughput.queueDepth > 50) return 'warning';
return 'good';
});
ngOnInit(): void {
this.loadMetrics();
}
loadMetrics(): void {
this.loading.set(true);
this.error.set(null);
this.aocClient.getMetrics('default').subscribe({
next: (metrics) => {
this.metrics.set(metrics);
this.loading.set(false);
},
error: (err) => {
this.error.set('Failed to load AOC metrics');
this.loading.set(false);
console.error('AOC metrics error:', err);
},
});
}
onVerifyLast24h(): void {
this.verifying.set(true);
this.verificationResult.set(null);
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
this.aocClient.verify({ tenantId: 'default', since }).subscribe({
next: (result) => {
this.verificationResult.set(result);
this.verifying.set(false);
},
error: (err) => {
this.verifying.set(false);
console.error('AOC verification error:', err);
},
});
}
getSeverityClass(severity: AocViolationSummary['severity']): string {
return 'severity-' + severity;
}
formatRelativeTime(isoDate: string): string {
const date = new Date(isoDate);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return diffMins + 'm ago';
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return diffHours + 'h ago';
const diffDays = Math.floor(diffHours / 24);
return diffDays + 'd ago';
}
}

View File

@@ -1,200 +1,200 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { EvidenceData } from '../../core/api/evidence.models';
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
import { EvidencePanelComponent } from './evidence-panel.component';
@Component({
selector: 'app-evidence-page',
standalone: true,
imports: [CommonModule, EvidencePanelComponent],
providers: [
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
],
template: `
<div class="evidence-page">
@if (loading()) {
<div class="evidence-page__loading">
<div class="spinner" aria-label="Loading evidence"></div>
<p>Loading evidence for {{ advisoryId() }}...</p>
</div>
} @else if (error()) {
<div class="evidence-page__error" role="alert">
<h2>Error Loading Evidence</h2>
<p>{{ error() }}</p>
<button type="button" (click)="reload()">Retry</button>
</div>
} @else if (evidenceData()) {
<app-evidence-panel
[advisoryId]="advisoryId()"
[evidenceData]="evidenceData()"
(close)="onClose()"
(downloadDocument)="onDownload($event)"
/>
} @else {
<div class="evidence-page__empty">
<h2>No Advisory ID</h2>
<p>Please provide an advisory ID to view evidence.</p>
</div>
}
</div>
`,
styles: [`
.evidence-page {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 2rem;
background: #f3f4f6;
}
.evidence-page__loading,
.evidence-page__error,
.evidence-page__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
text-align: center;
}
.evidence-page__loading .spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.evidence-page__loading p {
margin-top: 1rem;
color: #6b7280;
}
.evidence-page__error {
border: 1px solid #fca5a5;
background: #fef2f2;
}
.evidence-page__error h2 {
color: #dc2626;
margin: 0 0 0.5rem;
}
.evidence-page__error p {
color: #991b1b;
margin: 0 0 1rem;
}
.evidence-page__error button {
padding: 0.5rem 1rem;
border: 1px solid #dc2626;
border-radius: 4px;
background: #fff;
color: #dc2626;
cursor: pointer;
}
.evidence-page__error button:hover {
background: #fee2e2;
}
.evidence-page__empty h2 {
color: #374151;
margin: 0 0 0.5rem;
}
.evidence-page__empty p {
color: #6b7280;
margin: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EvidencePageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly evidenceApi = inject(EVIDENCE_API);
readonly advisoryId = signal<string>('');
readonly evidenceData = signal<EvidenceData | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
constructor() {
// React to route param changes
effect(() => {
const params = this.route.snapshot.paramMap;
const id = params.get('advisoryId');
if (id) {
this.advisoryId.set(id);
this.loadEvidence(id);
}
}, { allowSignalWrites: true });
}
private loadEvidence(advisoryId: string): void {
this.loading.set(true);
this.error.set(null);
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
next: (data) => {
this.evidenceData.set(data);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message ?? 'Failed to load evidence');
this.loading.set(false);
},
});
}
reload(): void {
const id = this.advisoryId();
if (id) {
this.loadEvidence(id);
}
}
onClose(): void {
this.router.navigate(['/vulnerabilities']);
}
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
next: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${event.type}-${event.id}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
error: (err) => {
console.error('Download failed:', err);
},
});
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { EvidenceData } from '../../core/api/evidence.models';
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
import { EvidencePanelComponent } from './evidence-panel.component';
@Component({
selector: 'app-evidence-page',
standalone: true,
imports: [CommonModule, EvidencePanelComponent],
providers: [
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
],
template: `
<div class="evidence-page">
@if (loading()) {
<div class="evidence-page__loading">
<div class="spinner" aria-label="Loading evidence"></div>
<p>Loading evidence for {{ advisoryId() }}...</p>
</div>
} @else if (error()) {
<div class="evidence-page__error" role="alert">
<h2>Error Loading Evidence</h2>
<p>{{ error() }}</p>
<button type="button" (click)="reload()">Retry</button>
</div>
} @else if (evidenceData()) {
<app-evidence-panel
[advisoryId]="advisoryId()"
[evidenceData]="evidenceData()"
(close)="onClose()"
(downloadDocument)="onDownload($event)"
/>
} @else {
<div class="evidence-page__empty">
<h2>No Advisory ID</h2>
<p>Please provide an advisory ID to view evidence.</p>
</div>
}
</div>
`,
styles: [`
.evidence-page {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 2rem;
background: #f3f4f6;
}
.evidence-page__loading,
.evidence-page__error,
.evidence-page__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
text-align: center;
}
.evidence-page__loading .spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.evidence-page__loading p {
margin-top: 1rem;
color: #6b7280;
}
.evidence-page__error {
border: 1px solid #fca5a5;
background: #fef2f2;
}
.evidence-page__error h2 {
color: #dc2626;
margin: 0 0 0.5rem;
}
.evidence-page__error p {
color: #991b1b;
margin: 0 0 1rem;
}
.evidence-page__error button {
padding: 0.5rem 1rem;
border: 1px solid #dc2626;
border-radius: 4px;
background: #fff;
color: #dc2626;
cursor: pointer;
}
.evidence-page__error button:hover {
background: #fee2e2;
}
.evidence-page__empty h2 {
color: #374151;
margin: 0 0 0.5rem;
}
.evidence-page__empty p {
color: #6b7280;
margin: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EvidencePageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly evidenceApi = inject(EVIDENCE_API);
readonly advisoryId = signal<string>('');
readonly evidenceData = signal<EvidenceData | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
constructor() {
// React to route param changes
effect(() => {
const params = this.route.snapshot.paramMap;
const id = params.get('advisoryId');
if (id) {
this.advisoryId.set(id);
this.loadEvidence(id);
}
}, { allowSignalWrites: true });
}
private loadEvidence(advisoryId: string): void {
this.loading.set(true);
this.error.set(null);
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
next: (data) => {
this.evidenceData.set(data);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message ?? 'Failed to load evidence');
this.loading.set(false);
},
});
}
reload(): void {
const id = this.advisoryId();
if (id) {
this.loadEvidence(id);
}
}
onClose(): void {
this.router.navigate(['/vulnerabilities']);
}
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
next: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${event.type}-${event.id}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
error: (err) => {
console.error('Download failed:', err);
},
});
}
}

View File

@@ -1,2 +1,2 @@
export { EvidencePanelComponent } from './evidence-panel.component';
export { EvidencePageComponent } from './evidence-page.component';
export { EvidencePanelComponent } from './evidence-panel.component';
export { EvidencePageComponent } from './evidence-page.component';

View File

@@ -1,278 +1,278 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
Exception,
ExceptionStatus,
ExceptionType,
ExceptionFilter,
ExceptionSortOption,
ExceptionTransition,
EXCEPTION_TRANSITIONS,
KANBAN_COLUMNS,
} from '../../core/api/exception.models';
type ViewMode = 'list' | 'kanban';
@Component({
selector: 'app-exception-center',
standalone: true,
imports: [CommonModule],
templateUrl: './exception-center.component.html',
styleUrls: ['./exception-center.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionCenterComponent {
/** All exceptions */
readonly exceptions = input.required<Exception[]>();
/** Current user role for transition permissions */
readonly userRole = input<string>('user');
/** Emits when creating new exception */
readonly create = output<void>();
/** Emits when selecting an exception */
readonly select = output<Exception>();
/** Emits when performing a workflow transition */
readonly transition = output<{ exception: Exception; to: ExceptionStatus }>();
/** Emits when viewing audit log */
readonly viewAudit = output<Exception>();
readonly viewMode = signal<ViewMode>('list');
readonly filter = signal<ExceptionFilter>({});
readonly sort = signal<ExceptionSortOption>({ field: 'updatedAt', direction: 'desc' });
readonly expandedId = signal<string | null>(null);
readonly showFilters = signal(false);
readonly kanbanColumns = KANBAN_COLUMNS;
readonly filteredExceptions = computed(() => {
let result = [...this.exceptions()];
const f = this.filter();
// Apply filters
if (f.status && f.status.length > 0) {
result = result.filter((e) => f.status!.includes(e.status));
}
if (f.type && f.type.length > 0) {
result = result.filter((e) => f.type!.includes(e.type));
}
if (f.severity && f.severity.length > 0) {
result = result.filter((e) => f.severity!.includes(e.severity));
}
if (f.search) {
const search = f.search.toLowerCase();
result = result.filter(
(e) =>
e.title.toLowerCase().includes(search) ||
e.justification.toLowerCase().includes(search) ||
e.id.toLowerCase().includes(search)
);
}
if (f.tags && f.tags.length > 0) {
result = result.filter((e) => f.tags!.some((t) => e.tags.includes(t)));
}
if (f.expiringSoon) {
result = result.filter((e) => e.timebox.isWarning && !e.timebox.isExpired);
}
// Apply sort
const s = this.sort();
result.sort((a, b) => {
let cmp = 0;
switch (s.field) {
case 'createdAt':
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case 'updatedAt':
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
break;
case 'expiresAt':
cmp = new Date(a.timebox.expiresAt).getTime() - new Date(b.timebox.expiresAt).getTime();
break;
case 'severity':
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
cmp = sevOrder[a.severity] - sevOrder[b.severity];
break;
case 'title':
cmp = a.title.localeCompare(b.title);
break;
}
return s.direction === 'asc' ? cmp : -cmp;
});
return result;
});
readonly exceptionsByStatus = computed(() => {
const byStatus = new Map<ExceptionStatus, Exception[]>();
for (const col of KANBAN_COLUMNS) {
byStatus.set(col.status, []);
}
for (const exc of this.filteredExceptions()) {
const list = byStatus.get(exc.status) || [];
list.push(exc);
byStatus.set(exc.status, list);
}
return byStatus;
});
readonly statusCounts = computed(() => {
const counts: Record<string, number> = {};
for (const exc of this.exceptions()) {
counts[exc.status] = (counts[exc.status] || 0) + 1;
}
return counts;
});
readonly allTags = computed(() => {
const tags = new Set<string>();
for (const exc of this.exceptions()) {
for (const tag of exc.tags) {
tags.add(tag);
}
}
return Array.from(tags).sort();
});
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
toggleFilters(): void {
this.showFilters.update((v) => !v);
}
toggleStatusFilter(status: ExceptionStatus): void {
const current = this.filter().status || [];
const newStatuses = current.includes(status)
? current.filter((s) => s !== status)
: [...current, status];
this.updateFilter('status', newStatuses.length > 0 ? newStatuses : undefined);
}
toggleTypeFilter(type: ExceptionType): void {
const current = this.filter().type || [];
const newTypes = current.includes(type)
? current.filter((t) => t !== type)
: [...current, type];
this.updateFilter('type', newTypes.length > 0 ? newTypes : undefined);
}
toggleSeverityFilter(severity: string): void {
const current = this.filter().severity || [];
const newSeverities = current.includes(severity)
? current.filter((s) => s !== severity)
: [...current, severity];
this.updateFilter('severity', newSeverities.length > 0 ? newSeverities : undefined);
}
toggleTagFilter(tag: string): void {
const current = this.filter().tags || [];
const newTags = current.includes(tag)
? current.filter((t) => t !== tag)
: [...current, tag];
this.updateFilter('tags', newTags.length > 0 ? newTags : undefined);
}
updateFilter(key: keyof ExceptionFilter, value: unknown): void {
this.filter.update((f) => ({ ...f, [key]: value }));
}
clearFilters(): void {
this.filter.set({});
}
setSort(field: ExceptionSortOption['field']): void {
this.sort.update((s) => ({
field,
direction: s.field === field && s.direction === 'desc' ? 'asc' : 'desc',
}));
}
toggleExpand(id: string): void {
this.expandedId.update((current) => (current === id ? null : id));
}
onCreate(): void {
this.create.emit();
}
onSelect(exc: Exception): void {
this.select.emit(exc);
}
onTransition(exc: Exception, to: ExceptionStatus): void {
this.transition.emit({ exception: exc, to });
}
onViewAudit(exc: Exception): void {
this.viewAudit.emit(exc);
}
getAvailableTransitions(exc: Exception): ExceptionTransition[] {
return EXCEPTION_TRANSITIONS.filter(
(t) => t.from === exc.status && t.allowedRoles.includes(this.userRole())
);
}
getStatusIcon(status: ExceptionStatus): string {
switch (status) {
case 'draft':
return '[D]';
case 'pending_review':
return '[?]';
case 'approved':
return '[+]';
case 'rejected':
return '[~]';
case 'expired':
return '[X]';
case 'revoked':
return '[!]';
default:
return '[-]';
}
}
getTypeIcon(type: ExceptionType): string {
switch (type) {
case 'vulnerability':
return 'V';
case 'license':
return 'L';
case 'policy':
return 'P';
case 'entropy':
return 'E';
case 'determinism':
return 'D';
default:
return '?';
}
}
getSeverityClass(severity: string): string {
return 'severity-' + severity;
}
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
formatRemainingDays(days: number): string {
if (days < 0) return 'Expired';
if (days === 0) return 'Expires today';
if (days === 1) return '1 day left';
return days + ' days left';
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
Exception,
ExceptionStatus,
ExceptionType,
ExceptionFilter,
ExceptionSortOption,
ExceptionTransition,
EXCEPTION_TRANSITIONS,
KANBAN_COLUMNS,
} from '../../core/api/exception.models';
type ViewMode = 'list' | 'kanban';
@Component({
selector: 'app-exception-center',
standalone: true,
imports: [CommonModule],
templateUrl: './exception-center.component.html',
styleUrls: ['./exception-center.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionCenterComponent {
/** All exceptions */
readonly exceptions = input.required<Exception[]>();
/** Current user role for transition permissions */
readonly userRole = input<string>('user');
/** Emits when creating new exception */
readonly create = output<void>();
/** Emits when selecting an exception */
readonly select = output<Exception>();
/** Emits when performing a workflow transition */
readonly transition = output<{ exception: Exception; to: ExceptionStatus }>();
/** Emits when viewing audit log */
readonly viewAudit = output<Exception>();
readonly viewMode = signal<ViewMode>('list');
readonly filter = signal<ExceptionFilter>({});
readonly sort = signal<ExceptionSortOption>({ field: 'updatedAt', direction: 'desc' });
readonly expandedId = signal<string | null>(null);
readonly showFilters = signal(false);
readonly kanbanColumns = KANBAN_COLUMNS;
readonly filteredExceptions = computed(() => {
let result = [...this.exceptions()];
const f = this.filter();
// Apply filters
if (f.status && f.status.length > 0) {
result = result.filter((e) => f.status!.includes(e.status));
}
if (f.type && f.type.length > 0) {
result = result.filter((e) => f.type!.includes(e.type));
}
if (f.severity && f.severity.length > 0) {
result = result.filter((e) => f.severity!.includes(e.severity));
}
if (f.search) {
const search = f.search.toLowerCase();
result = result.filter(
(e) =>
e.title.toLowerCase().includes(search) ||
e.justification.toLowerCase().includes(search) ||
e.id.toLowerCase().includes(search)
);
}
if (f.tags && f.tags.length > 0) {
result = result.filter((e) => f.tags!.some((t) => e.tags.includes(t)));
}
if (f.expiringSoon) {
result = result.filter((e) => e.timebox.isWarning && !e.timebox.isExpired);
}
// Apply sort
const s = this.sort();
result.sort((a, b) => {
let cmp = 0;
switch (s.field) {
case 'createdAt':
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case 'updatedAt':
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
break;
case 'expiresAt':
cmp = new Date(a.timebox.expiresAt).getTime() - new Date(b.timebox.expiresAt).getTime();
break;
case 'severity':
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
cmp = sevOrder[a.severity] - sevOrder[b.severity];
break;
case 'title':
cmp = a.title.localeCompare(b.title);
break;
}
return s.direction === 'asc' ? cmp : -cmp;
});
return result;
});
readonly exceptionsByStatus = computed(() => {
const byStatus = new Map<ExceptionStatus, Exception[]>();
for (const col of KANBAN_COLUMNS) {
byStatus.set(col.status, []);
}
for (const exc of this.filteredExceptions()) {
const list = byStatus.get(exc.status) || [];
list.push(exc);
byStatus.set(exc.status, list);
}
return byStatus;
});
readonly statusCounts = computed(() => {
const counts: Record<string, number> = {};
for (const exc of this.exceptions()) {
counts[exc.status] = (counts[exc.status] || 0) + 1;
}
return counts;
});
readonly allTags = computed(() => {
const tags = new Set<string>();
for (const exc of this.exceptions()) {
for (const tag of exc.tags) {
tags.add(tag);
}
}
return Array.from(tags).sort();
});
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
toggleFilters(): void {
this.showFilters.update((v) => !v);
}
toggleStatusFilter(status: ExceptionStatus): void {
const current = this.filter().status || [];
const newStatuses = current.includes(status)
? current.filter((s) => s !== status)
: [...current, status];
this.updateFilter('status', newStatuses.length > 0 ? newStatuses : undefined);
}
toggleTypeFilter(type: ExceptionType): void {
const current = this.filter().type || [];
const newTypes = current.includes(type)
? current.filter((t) => t !== type)
: [...current, type];
this.updateFilter('type', newTypes.length > 0 ? newTypes : undefined);
}
toggleSeverityFilter(severity: string): void {
const current = this.filter().severity || [];
const newSeverities = current.includes(severity)
? current.filter((s) => s !== severity)
: [...current, severity];
this.updateFilter('severity', newSeverities.length > 0 ? newSeverities : undefined);
}
toggleTagFilter(tag: string): void {
const current = this.filter().tags || [];
const newTags = current.includes(tag)
? current.filter((t) => t !== tag)
: [...current, tag];
this.updateFilter('tags', newTags.length > 0 ? newTags : undefined);
}
updateFilter(key: keyof ExceptionFilter, value: unknown): void {
this.filter.update((f) => ({ ...f, [key]: value }));
}
clearFilters(): void {
this.filter.set({});
}
setSort(field: ExceptionSortOption['field']): void {
this.sort.update((s) => ({
field,
direction: s.field === field && s.direction === 'desc' ? 'asc' : 'desc',
}));
}
toggleExpand(id: string): void {
this.expandedId.update((current) => (current === id ? null : id));
}
onCreate(): void {
this.create.emit();
}
onSelect(exc: Exception): void {
this.select.emit(exc);
}
onTransition(exc: Exception, to: ExceptionStatus): void {
this.transition.emit({ exception: exc, to });
}
onViewAudit(exc: Exception): void {
this.viewAudit.emit(exc);
}
getAvailableTransitions(exc: Exception): ExceptionTransition[] {
return EXCEPTION_TRANSITIONS.filter(
(t) => t.from === exc.status && t.allowedRoles.includes(this.userRole())
);
}
getStatusIcon(status: ExceptionStatus): string {
switch (status) {
case 'draft':
return '[D]';
case 'pending_review':
return '[?]';
case 'approved':
return '[+]';
case 'rejected':
return '[~]';
case 'expired':
return '[X]';
case 'revoked':
return '[!]';
default:
return '[-]';
}
}
getTypeIcon(type: ExceptionType): string {
switch (type) {
case 'vulnerability':
return 'V';
case 'license':
return 'L';
case 'policy':
return 'P';
case 'entropy':
return 'E';
case 'determinism':
return 'D';
default:
return '?';
}
}
getSeverityClass(severity: string): string {
return 'severity-' + severity;
}
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
formatRemainingDays(days: number): string {
if (days < 0) return 'Expired';
if (days === 0) return 'Expires today';
if (days === 1) return '1 day left';
return days + ' days left';
}
}

View File

@@ -1,441 +1,441 @@
@use 'tokens/breakpoints' as *;
.draft-inline {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-4);
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
box-shadow: var(--shadow-md);
}
// Header
.draft-inline__header {
display: flex;
align-items: baseline;
gap: var(--space-2);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border-secondary);
h3 {
margin: 0;
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
}
.draft-inline__source {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// Error
.draft-inline__error {
padding: var(--space-2) var(--space-3);
background: var(--color-status-error-bg);
color: var(--color-status-error);
border: 1px solid var(--color-status-error);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
}
// Scope
.draft-inline__scope {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
}
.draft-inline__scope-label {
color: var(--color-text-muted);
font-weight: var(--font-weight-medium);
}
.draft-inline__scope-value {
color: var(--color-text-primary);
flex: 1;
}
.draft-inline__scope-type {
padding: var(--space-0-5) var(--space-2);
background: var(--color-brand-light);
color: var(--color-brand-primary);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
}
// Vulnerabilities preview
.draft-inline__vulns,
.draft-inline__components {
display: flex;
flex-direction: column;
gap: var(--space-1-5);
}
.draft-inline__vulns-label,
.draft-inline__components-label {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-weight: var(--font-weight-medium);
}
.draft-inline__vulns-list,
.draft-inline__components-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-1-5);
}
.vuln-chip {
display: inline-flex;
padding: var(--space-0-5) var(--space-2);
background: var(--color-status-error-bg);
color: var(--color-status-error);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-family: var(--font-family-mono);
&--more {
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
}
}
.component-chip {
display: inline-flex;
padding: var(--space-0-5) var(--space-2);
background: var(--color-status-success-bg);
color: var(--color-status-success);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-family: var(--font-family-mono);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--more {
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
}
}
// Form
.draft-inline__form {
display: flex;
flex-direction: column;
gap: var(--space-3-5);
}
.form-row {
display: flex;
flex-direction: column;
gap: var(--space-1);
&--inline {
flex-direction: row;
align-items: center;
gap: var(--space-2);
}
}
.form-label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.required {
color: var(--color-status-error);
}
.form-input {
padding: var(--space-2) var(--space-2-5);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
transition: border-color var(--motion-duration-fast) var(--motion-ease-default);
&:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-brand-light);
}
&--small {
width: 60px;
text-align: center;
}
}
.form-textarea {
padding: var(--space-2) var(--space-2-5);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
resize: vertical;
min-height: 60px;
&:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-brand-light);
}
}
.form-hint {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
// Severity chips
.severity-chips {
display: flex;
gap: var(--space-1-5);
flex-wrap: wrap;
}
.severity-option {
cursor: pointer;
input {
display: none;
}
&--selected .severity-chip {
box-shadow: 0 0 0 2px currentColor;
}
}
.severity-chip {
display: inline-flex;
padding: var(--space-1) var(--space-2-5);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
transition: box-shadow var(--motion-duration-fast) var(--motion-ease-default);
&--critical {
background: var(--color-severity-critical-bg);
color: var(--color-severity-critical);
}
&--high {
background: var(--color-severity-high-bg);
color: var(--color-severity-high);
}
&--medium {
background: var(--color-severity-medium-bg);
color: var(--color-severity-medium);
}
&--low {
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
}
// Template chips
.template-chips {
display: flex;
gap: var(--space-1-5);
flex-wrap: wrap;
}
.template-chip {
padding: var(--space-1) var(--space-2-5);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
cursor: pointer;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--color-surface-secondary);
border-color: var(--color-brand-primary);
}
&--selected {
background: var(--color-brand-light);
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
}
}
// Timebox
.timebox-quick {
display: flex;
align-items: center;
gap: var(--space-1-5);
}
.timebox-btn {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
cursor: pointer;
&:hover {
background: var(--color-surface-tertiary);
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
}
}
.timebox-label {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// Simulation
.draft-inline__simulation {
padding-top: var(--space-3);
border-top: 1px solid var(--color-border-secondary);
}
.simulation-toggle {
padding: var(--space-1-5) var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-brand-primary);
font-size: var(--font-size-sm);
cursor: pointer;
&:hover {
background: var(--color-brand-light);
}
}
.simulation-result {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-2);
margin-top: var(--space-3);
padding: var(--space-3);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
}
.simulation-stat {
display: flex;
flex-direction: column;
gap: var(--space-0-5);
text-align: center;
}
.simulation-stat__label {
font-size: 0.625rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.simulation-stat__value {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
&--high {
color: var(--color-status-error);
}
&--moderate {
color: var(--color-status-warning);
}
&--low {
color: var(--color-status-success);
}
}
// Footer
.draft-inline__footer {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border-secondary);
}
// Buttons
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-2) var(--space-4);
border: none;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--primary {
background: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover:not(:disabled) {
background: var(--color-brand-primary-hover);
}
}
&--secondary {
background: var(--color-surface-primary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border-primary);
&:hover:not(:disabled) {
background: var(--color-surface-secondary);
}
}
&--text {
background: transparent;
color: var(--color-text-muted);
&:hover:not(:disabled) {
color: var(--color-text-primary);
}
}
}
// Responsive
@include screen-below-sm {
.simulation-result {
grid-template-columns: 1fr;
}
.draft-inline__footer {
flex-direction: column;
}
.severity-chips,
.template-chips {
flex-direction: column;
}
}
@use 'tokens/breakpoints' as *;
.draft-inline {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-4);
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
box-shadow: var(--shadow-md);
}
// Header
.draft-inline__header {
display: flex;
align-items: baseline;
gap: var(--space-2);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border-secondary);
h3 {
margin: 0;
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
}
.draft-inline__source {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// Error
.draft-inline__error {
padding: var(--space-2) var(--space-3);
background: var(--color-status-error-bg);
color: var(--color-status-error);
border: 1px solid var(--color-status-error);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
}
// Scope
.draft-inline__scope {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
}
.draft-inline__scope-label {
color: var(--color-text-muted);
font-weight: var(--font-weight-medium);
}
.draft-inline__scope-value {
color: var(--color-text-primary);
flex: 1;
}
.draft-inline__scope-type {
padding: var(--space-0-5) var(--space-2);
background: var(--color-brand-light);
color: var(--color-brand-primary);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
}
// Vulnerabilities preview
.draft-inline__vulns,
.draft-inline__components {
display: flex;
flex-direction: column;
gap: var(--space-1-5);
}
.draft-inline__vulns-label,
.draft-inline__components-label {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-weight: var(--font-weight-medium);
}
.draft-inline__vulns-list,
.draft-inline__components-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-1-5);
}
.vuln-chip {
display: inline-flex;
padding: var(--space-0-5) var(--space-2);
background: var(--color-status-error-bg);
color: var(--color-status-error);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-family: var(--font-family-mono);
&--more {
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
}
}
.component-chip {
display: inline-flex;
padding: var(--space-0-5) var(--space-2);
background: var(--color-status-success-bg);
color: var(--color-status-success);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-family: var(--font-family-mono);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--more {
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
}
}
// Form
.draft-inline__form {
display: flex;
flex-direction: column;
gap: var(--space-3-5);
}
.form-row {
display: flex;
flex-direction: column;
gap: var(--space-1);
&--inline {
flex-direction: row;
align-items: center;
gap: var(--space-2);
}
}
.form-label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.required {
color: var(--color-status-error);
}
.form-input {
padding: var(--space-2) var(--space-2-5);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
transition: border-color var(--motion-duration-fast) var(--motion-ease-default);
&:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-brand-light);
}
&--small {
width: 60px;
text-align: center;
}
}
.form-textarea {
padding: var(--space-2) var(--space-2-5);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
resize: vertical;
min-height: 60px;
&:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-brand-light);
}
}
.form-hint {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
// Severity chips
.severity-chips {
display: flex;
gap: var(--space-1-5);
flex-wrap: wrap;
}
.severity-option {
cursor: pointer;
input {
display: none;
}
&--selected .severity-chip {
box-shadow: 0 0 0 2px currentColor;
}
}
.severity-chip {
display: inline-flex;
padding: var(--space-1) var(--space-2-5);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
transition: box-shadow var(--motion-duration-fast) var(--motion-ease-default);
&--critical {
background: var(--color-severity-critical-bg);
color: var(--color-severity-critical);
}
&--high {
background: var(--color-severity-high-bg);
color: var(--color-severity-high);
}
&--medium {
background: var(--color-severity-medium-bg);
color: var(--color-severity-medium);
}
&--low {
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
}
// Template chips
.template-chips {
display: flex;
gap: var(--space-1-5);
flex-wrap: wrap;
}
.template-chip {
padding: var(--space-1) var(--space-2-5);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
cursor: pointer;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--color-surface-secondary);
border-color: var(--color-brand-primary);
}
&--selected {
background: var(--color-brand-light);
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
}
}
// Timebox
.timebox-quick {
display: flex;
align-items: center;
gap: var(--space-1-5);
}
.timebox-btn {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
cursor: pointer;
&:hover {
background: var(--color-surface-tertiary);
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
}
}
.timebox-label {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// Simulation
.draft-inline__simulation {
padding-top: var(--space-3);
border-top: 1px solid var(--color-border-secondary);
}
.simulation-toggle {
padding: var(--space-1-5) var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-brand-primary);
font-size: var(--font-size-sm);
cursor: pointer;
&:hover {
background: var(--color-brand-light);
}
}
.simulation-result {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-2);
margin-top: var(--space-3);
padding: var(--space-3);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
}
.simulation-stat {
display: flex;
flex-direction: column;
gap: var(--space-0-5);
text-align: center;
}
.simulation-stat__label {
font-size: 0.625rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.simulation-stat__value {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
&--high {
color: var(--color-status-error);
}
&--moderate {
color: var(--color-status-warning);
}
&--low {
color: var(--color-status-success);
}
}
// Footer
.draft-inline__footer {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border-secondary);
}
// Buttons
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-2) var(--space-4);
border: none;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--primary {
background: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover:not(:disabled) {
background: var(--color-brand-primary-hover);
}
}
&--secondary {
background: var(--color-surface-primary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border-primary);
&:hover:not(:disabled) {
background: var(--color-surface-secondary);
}
}
&--text {
background: transparent;
color: var(--color-text-muted);
&:hover:not(:disabled) {
color: var(--color-text-primary);
}
}
}
// Responsive
@include screen-below-sm {
.simulation-result {
grid-template-columns: 1fr;
}
.draft-inline__footer {
flex-direction: column;
}
.severity-chips,
.template-chips {
flex-direction: column;
}
}

View File

@@ -1,4 +1,4 @@
import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@@ -17,7 +17,7 @@ import {
} from '@angular/forms';
import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import {
EXCEPTION_API,
ExceptionApi,
@@ -27,36 +27,36 @@ import {
ExceptionSeverity,
ExceptionScopeType,
} from '../../core/api/exception.contract.models';
export interface ExceptionDraftContext {
readonly vulnIds?: readonly string[];
readonly componentPurls?: readonly string[];
readonly assetIds?: readonly string[];
readonly tenantId?: string;
readonly suggestedName?: string;
readonly suggestedSeverity?: ExceptionSeverity;
readonly sourceType: 'vulnerability' | 'component' | 'asset' | 'graph';
readonly sourceLabel: string;
}
const QUICK_TEMPLATES = [
{ id: 'risk-accepted', label: 'Risk Accepted', text: 'Risk has been reviewed and formally accepted.' },
{ id: 'compensating-control', label: 'Compensating Control', text: 'Compensating controls in place: ' },
{ id: 'false-positive', label: 'False Positive', text: 'This finding is a false positive because: ' },
{ id: 'scheduled-fix', label: 'Scheduled Fix', text: 'Fix scheduled for deployment. Timeline: ' },
] as const;
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
{ value: 'critical', label: 'Critical' },
{ value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' },
{ value: 'low', label: 'Low' },
];
@Component({
selector: 'app-exception-draft-inline',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
export interface ExceptionDraftContext {
readonly vulnIds?: readonly string[];
readonly componentPurls?: readonly string[];
readonly assetIds?: readonly string[];
readonly tenantId?: string;
readonly suggestedName?: string;
readonly suggestedSeverity?: ExceptionSeverity;
readonly sourceType: 'vulnerability' | 'component' | 'asset' | 'graph';
readonly sourceLabel: string;
}
const QUICK_TEMPLATES = [
{ id: 'risk-accepted', label: 'Risk Accepted', text: 'Risk has been reviewed and formally accepted.' },
{ id: 'compensating-control', label: 'Compensating Control', text: 'Compensating controls in place: ' },
{ id: 'false-positive', label: 'False Positive', text: 'This finding is a false positive because: ' },
{ id: 'scheduled-fix', label: 'Scheduled Fix', text: 'Fix scheduled for deployment. Timeline: ' },
] as const;
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
{ value: 'critical', label: 'Critical' },
{ value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' },
{ value: 'low', label: 'Low' },
];
@Component({
selector: 'app-exception-draft-inline',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './exception-draft-inline.component.html',
styleUrls: ['./exception-draft-inline.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -65,148 +65,148 @@ export class ExceptionDraftInlineComponent implements OnInit {
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly router = inject(Router);
@Input() context!: ExceptionDraftContext;
@Output() readonly created = new EventEmitter<Exception>();
@Output() readonly cancelled = new EventEmitter<void>();
@Output() readonly openFullWizard = new EventEmitter<ExceptionDraftContext>();
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly showSimulation = signal(false);
readonly quickTemplates = QUICK_TEMPLATES;
readonly severityOptions = SEVERITY_OPTIONS;
readonly draftForm = this.formBuilder.group({
name: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(3)],
}),
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
justificationTemplate: this.formBuilder.control('risk-accepted'),
justificationText: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(20)],
}),
timeboxDays: this.formBuilder.control(30),
});
@Input() context!: ExceptionDraftContext;
@Output() readonly created = new EventEmitter<Exception>();
@Output() readonly cancelled = new EventEmitter<void>();
@Output() readonly openFullWizard = new EventEmitter<ExceptionDraftContext>();
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly showSimulation = signal(false);
readonly quickTemplates = QUICK_TEMPLATES;
readonly severityOptions = SEVERITY_OPTIONS;
readonly draftForm = this.formBuilder.group({
name: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(3)],
}),
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
justificationTemplate: this.formBuilder.control('risk-accepted'),
justificationText: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(20)],
}),
timeboxDays: this.formBuilder.control(30),
});
readonly scopeType = computed<ExceptionScopeType>(() => {
if (this.context?.componentPurls?.length) return 'component';
if (this.context?.assetIds?.length) return 'asset';
if (this.context?.tenantId) return 'tenant';
return 'global';
});
readonly scopeSummary = computed(() => {
const ctx = this.context;
const items: string[] = [];
if (ctx?.vulnIds?.length) {
items.push(`${ctx.vulnIds.length} vulnerabilit${ctx.vulnIds.length === 1 ? 'y' : 'ies'}`);
}
if (ctx?.componentPurls?.length) {
items.push(`${ctx.componentPurls.length} component${ctx.componentPurls.length === 1 ? '' : 's'}`);
}
if (ctx?.assetIds?.length) {
items.push(`${ctx.assetIds.length} asset${ctx.assetIds.length === 1 ? '' : 's'}`);
}
if (ctx?.tenantId) {
items.push(`Tenant: ${ctx.tenantId}`);
}
return items.length > 0 ? items.join(', ') : 'Global scope';
});
readonly simulationResult = computed(() => {
if (!this.showSimulation()) return null;
const vulnCount = this.context?.vulnIds?.length ?? 0;
const componentCount = this.context?.componentPurls?.length ?? 0;
return {
affectedFindings: vulnCount * Math.max(1, componentCount),
policyImpact: this.draftForm.controls.severity.value === 'critical' ? 'high' : 'moderate',
coverageEstimate: `~${Math.min(100, vulnCount * 15 + componentCount * 10)}%`,
};
});
readonly canSubmit = computed(() => {
return this.draftForm.valid && !this.loading();
});
ngOnInit(): void {
if (this.context?.suggestedName) {
this.draftForm.patchValue({ name: this.context.suggestedName });
}
if (this.context?.suggestedSeverity) {
this.draftForm.patchValue({ severity: this.context.suggestedSeverity });
}
const defaultTemplate = this.quickTemplates[0];
this.draftForm.patchValue({ justificationText: defaultTemplate.text });
}
selectTemplate(templateId: string): void {
const template = this.quickTemplates.find((t) => t.id === templateId);
if (template) {
this.draftForm.patchValue({
justificationTemplate: templateId,
justificationText: template.text,
});
}
}
toggleSimulation(): void {
this.showSimulation.set(!this.showSimulation());
}
async submitDraft(): Promise<void> {
if (!this.canSubmit()) return;
this.loading.set(true);
this.error.set(null);
try {
const formValue = this.draftForm.getRawValue();
const endDate = new Date();
endDate.setDate(endDate.getDate() + formValue.timeboxDays);
const exception: Partial<Exception> = {
name: formValue.name,
severity: formValue.severity,
status: 'draft',
scope: {
type: this.scopeType(),
tenantId: this.context?.tenantId,
assetIds: this.context?.assetIds ? [...this.context.assetIds] : undefined,
componentPurls: this.context?.componentPurls ? [...this.context.componentPurls] : undefined,
vulnIds: this.context?.vulnIds ? [...this.context.vulnIds] : undefined,
},
justification: {
template: formValue.justificationTemplate,
text: formValue.justificationText,
},
timebox: {
startDate: new Date().toISOString(),
endDate: endDate.toISOString(),
},
};
readonly scopeSummary = computed(() => {
const ctx = this.context;
const items: string[] = [];
if (ctx?.vulnIds?.length) {
items.push(`${ctx.vulnIds.length} vulnerabilit${ctx.vulnIds.length === 1 ? 'y' : 'ies'}`);
}
if (ctx?.componentPurls?.length) {
items.push(`${ctx.componentPurls.length} component${ctx.componentPurls.length === 1 ? '' : 's'}`);
}
if (ctx?.assetIds?.length) {
items.push(`${ctx.assetIds.length} asset${ctx.assetIds.length === 1 ? '' : 's'}`);
}
if (ctx?.tenantId) {
items.push(`Tenant: ${ctx.tenantId}`);
}
return items.length > 0 ? items.join(', ') : 'Global scope';
});
readonly simulationResult = computed(() => {
if (!this.showSimulation()) return null;
const vulnCount = this.context?.vulnIds?.length ?? 0;
const componentCount = this.context?.componentPurls?.length ?? 0;
return {
affectedFindings: vulnCount * Math.max(1, componentCount),
policyImpact: this.draftForm.controls.severity.value === 'critical' ? 'high' : 'moderate',
coverageEstimate: `~${Math.min(100, vulnCount * 15 + componentCount * 10)}%`,
};
});
readonly canSubmit = computed(() => {
return this.draftForm.valid && !this.loading();
});
ngOnInit(): void {
if (this.context?.suggestedName) {
this.draftForm.patchValue({ name: this.context.suggestedName });
}
if (this.context?.suggestedSeverity) {
this.draftForm.patchValue({ severity: this.context.suggestedSeverity });
}
const defaultTemplate = this.quickTemplates[0];
this.draftForm.patchValue({ justificationText: defaultTemplate.text });
}
selectTemplate(templateId: string): void {
const template = this.quickTemplates.find((t) => t.id === templateId);
if (template) {
this.draftForm.patchValue({
justificationTemplate: templateId,
justificationText: template.text,
});
}
}
toggleSimulation(): void {
this.showSimulation.set(!this.showSimulation());
}
async submitDraft(): Promise<void> {
if (!this.canSubmit()) return;
this.loading.set(true);
this.error.set(null);
try {
const formValue = this.draftForm.getRawValue();
const endDate = new Date();
endDate.setDate(endDate.getDate() + formValue.timeboxDays);
const exception: Partial<Exception> = {
name: formValue.name,
severity: formValue.severity,
status: 'draft',
scope: {
type: this.scopeType(),
tenantId: this.context?.tenantId,
assetIds: this.context?.assetIds ? [...this.context.assetIds] : undefined,
componentPurls: this.context?.componentPurls ? [...this.context.componentPurls] : undefined,
vulnIds: this.context?.vulnIds ? [...this.context.vulnIds] : undefined,
},
justification: {
template: formValue.justificationTemplate,
text: formValue.justificationText,
},
timebox: {
startDate: new Date().toISOString(),
endDate: endDate.toISOString(),
},
};
const created = await firstValueFrom(this.api.createException(exception));
this.created.emit(created);
this.router.navigate(['/exceptions', created.exceptionId]);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
} finally {
this.loading.set(false);
}
}
cancel(): void {
this.cancelled.emit();
}
expandToFullWizard(): void {
this.openFullWizard.emit(this.context);
}
}
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
} finally {
this.loading.set(false);
}
}
cancel(): void {
this.cancelled.emit();
}
expandToFullWizard(): void {
this.openFullWizard.emit(this.context);
}
}

View File

@@ -379,7 +379,7 @@ import {
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
background: #EEF2FF;
color: #4338CA;
color: #E09115;
border-radius: 4px;
}
@@ -507,7 +507,7 @@ import {
font-size: 10px;
padding: 1px 5px;
background: #EEF2FF;
color: #4338CA;
color: #E09115;
border-radius: 3px;
margin-left: auto;
}

View File

@@ -515,14 +515,14 @@ type SbomSourceType = 'file' | 'oci';
border-radius: 4px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
color: #4338CA;
color: #E09115;
}
.remove-pattern {
background: none;
border: none;
font-size: 14px;
color: #6366F1;
color: #D4920A;
cursor: pointer;
padding: 0 2px;
line-height: 1;

View File

@@ -206,7 +206,7 @@ const VIEWPORT_PADDING = 100;
<!-- Selection filter -->
<filter id="selection-glow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#4f46e5" flood-opacity="0.5"/>
<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#F5A623" flood-opacity="0.5"/>
</filter>
</defs>
@@ -390,7 +390,7 @@ const VIEWPORT_PADDING = 100;
[attr.width]="viewportBounds().maxX - viewportBounds().minX"
[attr.height]="viewportBounds().maxY - viewportBounds().minY"
fill="none"
stroke="#4f46e5"
stroke="#F5A623"
stroke-width="8"
/>
</svg>
@@ -413,7 +413,7 @@ const VIEWPORT_PADDING = 100;
}
&:focus {
outline: 2px solid #4f46e5;
outline: 2px solid #F5A623;
outline-offset: 2px;
}
}
@@ -459,7 +459,7 @@ const VIEWPORT_PADDING = 100;
}
&:focus-visible {
outline: 2px solid #4f46e5;
outline: 2px solid #F5A623;
outline-offset: 1px;
}
@@ -507,16 +507,16 @@ const VIEWPORT_PADDING = 100;
}
&--active {
background: #4f46e5;
background: #F5A623;
color: white;
&:hover {
background: #4338ca;
background: #E09115;
}
}
&:focus-visible {
outline: 2px solid #4f46e5;
outline: 2px solid #F5A623;
outline-offset: -2px;
}
}
@@ -555,7 +555,7 @@ const VIEWPORT_PADDING = 100;
&--highlighted {
stroke-width: 3;
stroke: #4f46e5;
stroke: #F5A623;
}
}
@@ -573,14 +573,14 @@ const VIEWPORT_PADDING = 100;
&--selected {
.node-bg {
filter: url(#selection-glow);
stroke: #4f46e5 !important;
stroke: #F5A623 !important;
stroke-width: 3 !important;
}
}
&--highlighted:not(.node-group--selected) {
.node-bg {
stroke: #818cf8 !important;
stroke: #F5B84A !important;
stroke-width: 2 !important;
}
}
@@ -595,7 +595,7 @@ const VIEWPORT_PADDING = 100;
outline: none;
.node-bg {
stroke: #4f46e5 !important;
stroke: #F5A623 !important;
stroke-width: 3 !important;
}
}

View File

@@ -1,476 +1,476 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
HostListener,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import {
ExceptionDraftContext,
ExceptionDraftInlineComponent,
} from '../exceptions/exception-draft-inline.component';
import {
ExceptionBadgeComponent,
ExceptionBadgeData,
ExceptionExplainComponent,
ExceptionExplainData,
} from '../../shared/components';
import {
AUTH_SERVICE,
AuthService,
MockAuthService,
StellaOpsScopes,
} from '../../core/auth';
import { GraphCanvasComponent, CanvasNode, CanvasEdge } from './graph-canvas.component';
import { GraphOverlaysComponent, GraphOverlayState } from './graph-overlays.component';
export interface GraphNode {
readonly id: string;
readonly type: 'asset' | 'component' | 'vulnerability';
readonly name: string;
readonly purl?: string;
readonly version?: string;
readonly severity?: 'critical' | 'high' | 'medium' | 'low';
readonly vulnCount?: number;
readonly hasException?: boolean;
}
export interface GraphEdge {
readonly source: string;
readonly target: string;
readonly type: 'depends_on' | 'has_vulnerability' | 'child_of';
}
const MOCK_NODES: GraphNode[] = [
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1, hasException: true },
{ id: 'comp-curl', type: 'component', name: 'curl', purl: 'pkg:deb/debian/curl@7.88.1-10', version: '7.88.1-10', severity: 'high', vulnCount: 1 },
{ id: 'comp-nghttp2', type: 'component', name: 'nghttp2', purl: 'pkg:npm/nghttp2@1.55.0', version: '1.55.0', severity: 'high', vulnCount: 1 },
{ id: 'comp-golang-net', type: 'component', name: 'golang.org/x/net', purl: 'pkg:golang/golang.org/x/net@0.15.0', version: '0.15.0', severity: 'high', vulnCount: 1 },
{ id: 'comp-zlib', type: 'component', name: 'zlib', purl: 'pkg:deb/debian/zlib@1.2.13', version: '1.2.13', severity: 'medium', vulnCount: 1 },
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
{ id: 'vuln-log4j-dos', type: 'vulnerability', name: 'CVE-2021-45046', severity: 'critical', hasException: true },
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical', hasException: true },
{ id: 'vuln-http2-reset', type: 'vulnerability', name: 'CVE-2023-44487', severity: 'high' },
{ id: 'vuln-curl-heap', type: 'vulnerability', name: 'CVE-2023-38545', severity: 'high' },
];
const MOCK_EDGES: GraphEdge[] = [
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-curl', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-nghttp2', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-zlib', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-curl', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-golang-net', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
{ source: 'comp-log4j', target: 'vuln-log4j-dos', type: 'has_vulnerability' },
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
{ source: 'comp-nghttp2', target: 'vuln-http2-reset', type: 'has_vulnerability' },
{ source: 'comp-golang-net', target: 'vuln-http2-reset', type: 'has_vulnerability' },
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
];
type ViewMode = 'hierarchy' | 'flat' | 'canvas';
@Component({
selector: 'app-graph-explorer',
standalone: true,
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent, GraphCanvasComponent, GraphOverlaysComponent],
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
templateUrl: './graph-explorer.component.html',
styleUrls: ['./graph-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GraphExplorerComponent implements OnInit {
private readonly authService = inject(AUTH_SERVICE);
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
readonly canViewGraph = computed(() => this.authService.canViewGraph());
readonly canEditGraph = computed(() => this.authService.canEditGraph());
readonly canExportGraph = computed(() => this.authService.canExportGraph());
readonly canSimulate = computed(() => this.authService.canSimulate());
readonly canCreateException = computed(() =>
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
);
// Current user info
readonly currentUser = computed(() => this.authService.user());
readonly userScopes = computed(() => this.authService.scopes());
// View state
readonly loading = signal(false);
readonly message = signal<string | null>(null);
readonly messageType = signal<'success' | 'error' | 'info'>('info');
readonly viewMode = signal<ViewMode>('canvas'); // Default to canvas view
// Data
readonly nodes = signal<GraphNode[]>([]);
readonly edges = signal<GraphEdge[]>([]);
readonly selectedNodeId = signal<string | null>(null);
// Exception draft state
readonly showExceptionDraft = signal(false);
// Exception explain state
readonly showExceptionExplain = signal(false);
readonly explainNodeId = signal<string | null>(null);
// Filters
readonly showVulnerabilities = signal(true);
readonly showComponents = signal(true);
readonly showAssets = signal(true);
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
// Overlay state
readonly overlayState = signal<GraphOverlayState | null>(null);
readonly simulationMode = signal(false);
readonly pathViewState = signal<{ enabled: boolean; type: string }>({ enabled: false, type: 'shortest' });
readonly timeTravelState = signal<{ enabled: boolean; snapshot: string }>({ enabled: false, snapshot: 'current' });
// Computed: node IDs for overlay component
readonly nodeIds = computed(() => this.filteredNodes().map(n => n.id));
// Computed: filtered nodes
readonly filteredNodes = computed(() => {
let items = [...this.nodes()];
const showVulns = this.showVulnerabilities();
const showComps = this.showComponents();
const showAssetNodes = this.showAssets();
const severity = this.filterSeverity();
items = items.filter((n) => {
if (n.type === 'vulnerability' && !showVulns) return false;
if (n.type === 'component' && !showComps) return false;
if (n.type === 'asset' && !showAssetNodes) return false;
if (severity !== 'all' && n.severity && n.severity !== severity) return false;
return true;
});
return items;
});
// Computed: canvas nodes (filtered for canvas view)
readonly canvasNodes = computed<CanvasNode[]>(() => {
return this.filteredNodes().map(n => ({
id: n.id,
type: n.type,
name: n.name,
purl: n.purl,
version: n.version,
severity: n.severity,
vulnCount: n.vulnCount,
hasException: n.hasException,
}));
});
// Computed: canvas edges (filtered based on visible nodes)
readonly canvasEdges = computed<CanvasEdge[]>(() => {
const visibleIds = new Set(this.filteredNodes().map(n => n.id));
return this.edges()
.filter(e => visibleIds.has(e.source) && visibleIds.has(e.target))
.map(e => ({
source: e.source,
target: e.target,
type: e.type,
}));
});
// Computed: assets
readonly assets = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'asset');
});
// Computed: components
readonly components = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'component');
});
// Computed: vulnerabilities
readonly vulnerabilities = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'vulnerability');
});
// Computed: selected node
readonly selectedNode = computed(() => {
const id = this.selectedNodeId();
if (!id) return null;
return this.nodes().find((n) => n.id === id) ?? null;
});
// Computed: related nodes for selected
readonly relatedNodes = computed(() => {
const selectedId = this.selectedNodeId();
if (!selectedId) return [];
const edgeList = this.edges();
const relatedIds = new Set<string>();
edgeList.forEach((e) => {
if (e.source === selectedId) relatedIds.add(e.target);
if (e.target === selectedId) relatedIds.add(e.source);
});
return this.nodes().filter((n) => relatedIds.has(n.id));
});
// Get exception badge data for a node
getExceptionBadgeData(node: GraphNode): ExceptionBadgeData | null {
if (!node.hasException) return null;
return {
exceptionId: `exc-${node.id}`,
status: 'approved',
severity: node.severity ?? 'medium',
name: `${node.name} Exception`,
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
justificationSummary: 'Risk accepted with compensating controls.',
approvedBy: 'Security Team',
};
}
// Computed: explain data for selected node
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
const nodeId = this.explainNodeId();
if (!nodeId) return null;
const node = this.nodes().find((n) => n.id === nodeId);
if (!node || !node.hasException) return null;
const relatedComps = this.edges()
.filter((e) => e.source === nodeId || e.target === nodeId)
.map((e) => (e.source === nodeId ? e.target : e.source))
.map((id) => this.nodes().find((n) => n.id === id))
.filter((n): n is GraphNode => n !== undefined && n.type === 'component');
return {
exceptionId: `exc-${node.id}`,
name: `${node.name} Exception`,
status: 'approved',
severity: node.severity ?? 'medium',
scope: {
type: node.type === 'vulnerability' ? 'vulnerability' : 'component',
vulnIds: node.type === 'vulnerability' ? [node.name] : undefined,
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
},
justification: {
template: 'risk-accepted',
text: 'Risk accepted with compensating controls in place. The affected item is in a controlled environment.',
},
timebox: {
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
autoRenew: false,
},
approvedBy: 'Security Team',
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
impact: {
affectedFindings: 1,
affectedAssets: 1,
policyOverrides: 1,
},
};
});
// Computed: exception draft context
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
const node = this.selectedNode();
if (!node) return null;
if (node.type === 'component') {
const relatedVulns = this.relatedNodes().filter((n) => n.type === 'vulnerability');
return {
componentPurls: node.purl ? [node.purl] : undefined,
vulnIds: relatedVulns.map((v) => v.name),
suggestedName: `${node.name}-exception`,
suggestedSeverity: node.severity ?? 'medium',
sourceType: 'component',
sourceLabel: `${node.name}@${node.version}`,
};
}
if (node.type === 'vulnerability') {
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
return {
vulnIds: [node.name],
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
suggestedName: `${node.name.toLowerCase()}-exception`,
suggestedSeverity: node.severity ?? 'medium',
sourceType: 'vulnerability',
sourceLabel: node.name,
};
}
if (node.type === 'asset') {
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
return {
assetIds: [node.name],
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
suggestedName: `${node.name}-exception`,
sourceType: 'asset',
sourceLabel: node.name,
};
}
return null;
});
ngOnInit(): void {
this.loadData();
}
loadData(): void {
this.loading.set(true);
// Simulate API call
setTimeout(() => {
this.nodes.set([...MOCK_NODES]);
this.edges.set([...MOCK_EDGES]);
this.loading.set(false);
}, 300);
}
// View mode
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
// Filters
toggleVulnerabilities(): void {
this.showVulnerabilities.set(!this.showVulnerabilities());
}
toggleComponents(): void {
this.showComponents.set(!this.showComponents());
}
toggleAssets(): void {
this.showAssets.set(!this.showAssets());
}
setSeverityFilter(severity: 'all' | 'critical' | 'high' | 'medium' | 'low'): void {
this.filterSeverity.set(severity);
}
// Selection
selectNode(nodeId: string): void {
this.selectedNodeId.set(nodeId);
this.showExceptionDraft.set(false);
}
clearSelection(): void {
this.selectedNodeId.set(null);
this.showExceptionDraft.set(false);
}
// Exception drafting
startExceptionDraft(): void {
this.showExceptionDraft.set(true);
}
cancelExceptionDraft(): void {
this.showExceptionDraft.set(false);
}
onExceptionCreated(): void {
this.showExceptionDraft.set(false);
this.showMessage('Exception draft created successfully', 'success');
this.loadData();
}
openFullWizard(): void {
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
}
// Exception explain
onViewExceptionDetails(exceptionId: string): void {
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
}
onExplainException(exceptionId: string): void {
// Find the node with this exception ID
const node = this.nodes().find((n) => `exc-${n.id}` === exceptionId);
if (node) {
this.explainNodeId.set(node.id);
this.showExceptionExplain.set(true);
}
}
closeExplain(): void {
this.showExceptionExplain.set(false);
this.explainNodeId.set(null);
}
viewExceptionFromExplain(exceptionId: string): void {
this.closeExplain();
this.onViewExceptionDetails(exceptionId);
}
// Helpers
getNodeTypeIcon(type: GraphNode['type']): string {
switch (type) {
case 'asset':
return '📦';
case 'component':
return '🧩';
case 'vulnerability':
return '⚠️';
default:
return '•';
}
}
getSeverityClass(severity: string | undefined): string {
if (!severity) return '';
return `severity--${severity}`;
}
getNodeClass(node: GraphNode): string {
const classes = [`node--${node.type}`];
if (node.severity) classes.push(`node--${node.severity}`);
if (node.hasException) classes.push('node--excepted');
if (this.selectedNodeId() === node.id) classes.push('node--selected');
return classes.join(' ');
}
trackByNode = (_: number, item: GraphNode) => item.id;
// Overlay handlers
onOverlayStateChange(state: GraphOverlayState): void {
this.overlayState.set(state);
}
onSimulationModeChange(enabled: boolean): void {
this.simulationMode.set(enabled);
this.showMessage(enabled ? 'Simulation mode enabled' : 'Simulation mode disabled', 'info');
}
onPathViewChange(state: { enabled: boolean; type: string }): void {
this.pathViewState.set(state);
if (state.enabled) {
this.showMessage(`Path view enabled: ${state.type}`, 'info');
}
}
onTimeTravelChange(state: { enabled: boolean; snapshot: string }): void {
this.timeTravelState.set(state);
if (state.enabled && state.snapshot !== 'current') {
this.showMessage(`Viewing snapshot: ${state.snapshot}`, 'info');
}
}
onShowDiffRequest(snapshot: string): void {
this.showMessage(`Loading diff for ${snapshot}...`, 'info');
}
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
this.message.set(text);
this.messageType.set(type);
setTimeout(() => this.message.set(null), 5000);
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
HostListener,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import {
ExceptionDraftContext,
ExceptionDraftInlineComponent,
} from '../exceptions/exception-draft-inline.component';
import {
ExceptionBadgeComponent,
ExceptionBadgeData,
ExceptionExplainComponent,
ExceptionExplainData,
} from '../../shared/components';
import {
AUTH_SERVICE,
AuthService,
MockAuthService,
StellaOpsScopes,
} from '../../core/auth';
import { GraphCanvasComponent, CanvasNode, CanvasEdge } from './graph-canvas.component';
import { GraphOverlaysComponent, GraphOverlayState } from './graph-overlays.component';
export interface GraphNode {
readonly id: string;
readonly type: 'asset' | 'component' | 'vulnerability';
readonly name: string;
readonly purl?: string;
readonly version?: string;
readonly severity?: 'critical' | 'high' | 'medium' | 'low';
readonly vulnCount?: number;
readonly hasException?: boolean;
}
export interface GraphEdge {
readonly source: string;
readonly target: string;
readonly type: 'depends_on' | 'has_vulnerability' | 'child_of';
}
const MOCK_NODES: GraphNode[] = [
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1, hasException: true },
{ id: 'comp-curl', type: 'component', name: 'curl', purl: 'pkg:deb/debian/curl@7.88.1-10', version: '7.88.1-10', severity: 'high', vulnCount: 1 },
{ id: 'comp-nghttp2', type: 'component', name: 'nghttp2', purl: 'pkg:npm/nghttp2@1.55.0', version: '1.55.0', severity: 'high', vulnCount: 1 },
{ id: 'comp-golang-net', type: 'component', name: 'golang.org/x/net', purl: 'pkg:golang/golang.org/x/net@0.15.0', version: '0.15.0', severity: 'high', vulnCount: 1 },
{ id: 'comp-zlib', type: 'component', name: 'zlib', purl: 'pkg:deb/debian/zlib@1.2.13', version: '1.2.13', severity: 'medium', vulnCount: 1 },
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
{ id: 'vuln-log4j-dos', type: 'vulnerability', name: 'CVE-2021-45046', severity: 'critical', hasException: true },
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical', hasException: true },
{ id: 'vuln-http2-reset', type: 'vulnerability', name: 'CVE-2023-44487', severity: 'high' },
{ id: 'vuln-curl-heap', type: 'vulnerability', name: 'CVE-2023-38545', severity: 'high' },
];
const MOCK_EDGES: GraphEdge[] = [
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-curl', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-nghttp2', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-zlib', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-curl', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-golang-net', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
{ source: 'comp-log4j', target: 'vuln-log4j-dos', type: 'has_vulnerability' },
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
{ source: 'comp-nghttp2', target: 'vuln-http2-reset', type: 'has_vulnerability' },
{ source: 'comp-golang-net', target: 'vuln-http2-reset', type: 'has_vulnerability' },
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
];
type ViewMode = 'hierarchy' | 'flat' | 'canvas';
@Component({
selector: 'app-graph-explorer',
standalone: true,
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent, GraphCanvasComponent, GraphOverlaysComponent],
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
templateUrl: './graph-explorer.component.html',
styleUrls: ['./graph-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GraphExplorerComponent implements OnInit {
private readonly authService = inject(AUTH_SERVICE);
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
readonly canViewGraph = computed(() => this.authService.canViewGraph());
readonly canEditGraph = computed(() => this.authService.canEditGraph());
readonly canExportGraph = computed(() => this.authService.canExportGraph());
readonly canSimulate = computed(() => this.authService.canSimulate());
readonly canCreateException = computed(() =>
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
);
// Current user info
readonly currentUser = computed(() => this.authService.user());
readonly userScopes = computed(() => this.authService.scopes());
// View state
readonly loading = signal(false);
readonly message = signal<string | null>(null);
readonly messageType = signal<'success' | 'error' | 'info'>('info');
readonly viewMode = signal<ViewMode>('canvas'); // Default to canvas view
// Data
readonly nodes = signal<GraphNode[]>([]);
readonly edges = signal<GraphEdge[]>([]);
readonly selectedNodeId = signal<string | null>(null);
// Exception draft state
readonly showExceptionDraft = signal(false);
// Exception explain state
readonly showExceptionExplain = signal(false);
readonly explainNodeId = signal<string | null>(null);
// Filters
readonly showVulnerabilities = signal(true);
readonly showComponents = signal(true);
readonly showAssets = signal(true);
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
// Overlay state
readonly overlayState = signal<GraphOverlayState | null>(null);
readonly simulationMode = signal(false);
readonly pathViewState = signal<{ enabled: boolean; type: string }>({ enabled: false, type: 'shortest' });
readonly timeTravelState = signal<{ enabled: boolean; snapshot: string }>({ enabled: false, snapshot: 'current' });
// Computed: node IDs for overlay component
readonly nodeIds = computed(() => this.filteredNodes().map(n => n.id));
// Computed: filtered nodes
readonly filteredNodes = computed(() => {
let items = [...this.nodes()];
const showVulns = this.showVulnerabilities();
const showComps = this.showComponents();
const showAssetNodes = this.showAssets();
const severity = this.filterSeverity();
items = items.filter((n) => {
if (n.type === 'vulnerability' && !showVulns) return false;
if (n.type === 'component' && !showComps) return false;
if (n.type === 'asset' && !showAssetNodes) return false;
if (severity !== 'all' && n.severity && n.severity !== severity) return false;
return true;
});
return items;
});
// Computed: canvas nodes (filtered for canvas view)
readonly canvasNodes = computed<CanvasNode[]>(() => {
return this.filteredNodes().map(n => ({
id: n.id,
type: n.type,
name: n.name,
purl: n.purl,
version: n.version,
severity: n.severity,
vulnCount: n.vulnCount,
hasException: n.hasException,
}));
});
// Computed: canvas edges (filtered based on visible nodes)
readonly canvasEdges = computed<CanvasEdge[]>(() => {
const visibleIds = new Set(this.filteredNodes().map(n => n.id));
return this.edges()
.filter(e => visibleIds.has(e.source) && visibleIds.has(e.target))
.map(e => ({
source: e.source,
target: e.target,
type: e.type,
}));
});
// Computed: assets
readonly assets = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'asset');
});
// Computed: components
readonly components = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'component');
});
// Computed: vulnerabilities
readonly vulnerabilities = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'vulnerability');
});
// Computed: selected node
readonly selectedNode = computed(() => {
const id = this.selectedNodeId();
if (!id) return null;
return this.nodes().find((n) => n.id === id) ?? null;
});
// Computed: related nodes for selected
readonly relatedNodes = computed(() => {
const selectedId = this.selectedNodeId();
if (!selectedId) return [];
const edgeList = this.edges();
const relatedIds = new Set<string>();
edgeList.forEach((e) => {
if (e.source === selectedId) relatedIds.add(e.target);
if (e.target === selectedId) relatedIds.add(e.source);
});
return this.nodes().filter((n) => relatedIds.has(n.id));
});
// Get exception badge data for a node
getExceptionBadgeData(node: GraphNode): ExceptionBadgeData | null {
if (!node.hasException) return null;
return {
exceptionId: `exc-${node.id}`,
status: 'approved',
severity: node.severity ?? 'medium',
name: `${node.name} Exception`,
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
justificationSummary: 'Risk accepted with compensating controls.',
approvedBy: 'Security Team',
};
}
// Computed: explain data for selected node
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
const nodeId = this.explainNodeId();
if (!nodeId) return null;
const node = this.nodes().find((n) => n.id === nodeId);
if (!node || !node.hasException) return null;
const relatedComps = this.edges()
.filter((e) => e.source === nodeId || e.target === nodeId)
.map((e) => (e.source === nodeId ? e.target : e.source))
.map((id) => this.nodes().find((n) => n.id === id))
.filter((n): n is GraphNode => n !== undefined && n.type === 'component');
return {
exceptionId: `exc-${node.id}`,
name: `${node.name} Exception`,
status: 'approved',
severity: node.severity ?? 'medium',
scope: {
type: node.type === 'vulnerability' ? 'vulnerability' : 'component',
vulnIds: node.type === 'vulnerability' ? [node.name] : undefined,
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
},
justification: {
template: 'risk-accepted',
text: 'Risk accepted with compensating controls in place. The affected item is in a controlled environment.',
},
timebox: {
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
autoRenew: false,
},
approvedBy: 'Security Team',
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
impact: {
affectedFindings: 1,
affectedAssets: 1,
policyOverrides: 1,
},
};
});
// Computed: exception draft context
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
const node = this.selectedNode();
if (!node) return null;
if (node.type === 'component') {
const relatedVulns = this.relatedNodes().filter((n) => n.type === 'vulnerability');
return {
componentPurls: node.purl ? [node.purl] : undefined,
vulnIds: relatedVulns.map((v) => v.name),
suggestedName: `${node.name}-exception`,
suggestedSeverity: node.severity ?? 'medium',
sourceType: 'component',
sourceLabel: `${node.name}@${node.version}`,
};
}
if (node.type === 'vulnerability') {
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
return {
vulnIds: [node.name],
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
suggestedName: `${node.name.toLowerCase()}-exception`,
suggestedSeverity: node.severity ?? 'medium',
sourceType: 'vulnerability',
sourceLabel: node.name,
};
}
if (node.type === 'asset') {
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
return {
assetIds: [node.name],
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
suggestedName: `${node.name}-exception`,
sourceType: 'asset',
sourceLabel: node.name,
};
}
return null;
});
ngOnInit(): void {
this.loadData();
}
loadData(): void {
this.loading.set(true);
// Simulate API call
setTimeout(() => {
this.nodes.set([...MOCK_NODES]);
this.edges.set([...MOCK_EDGES]);
this.loading.set(false);
}, 300);
}
// View mode
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
// Filters
toggleVulnerabilities(): void {
this.showVulnerabilities.set(!this.showVulnerabilities());
}
toggleComponents(): void {
this.showComponents.set(!this.showComponents());
}
toggleAssets(): void {
this.showAssets.set(!this.showAssets());
}
setSeverityFilter(severity: 'all' | 'critical' | 'high' | 'medium' | 'low'): void {
this.filterSeverity.set(severity);
}
// Selection
selectNode(nodeId: string): void {
this.selectedNodeId.set(nodeId);
this.showExceptionDraft.set(false);
}
clearSelection(): void {
this.selectedNodeId.set(null);
this.showExceptionDraft.set(false);
}
// Exception drafting
startExceptionDraft(): void {
this.showExceptionDraft.set(true);
}
cancelExceptionDraft(): void {
this.showExceptionDraft.set(false);
}
onExceptionCreated(): void {
this.showExceptionDraft.set(false);
this.showMessage('Exception draft created successfully', 'success');
this.loadData();
}
openFullWizard(): void {
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
}
// Exception explain
onViewExceptionDetails(exceptionId: string): void {
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
}
onExplainException(exceptionId: string): void {
// Find the node with this exception ID
const node = this.nodes().find((n) => `exc-${n.id}` === exceptionId);
if (node) {
this.explainNodeId.set(node.id);
this.showExceptionExplain.set(true);
}
}
closeExplain(): void {
this.showExceptionExplain.set(false);
this.explainNodeId.set(null);
}
viewExceptionFromExplain(exceptionId: string): void {
this.closeExplain();
this.onViewExceptionDetails(exceptionId);
}
// Helpers
getNodeTypeIcon(type: GraphNode['type']): string {
switch (type) {
case 'asset':
return '📦';
case 'component':
return '🧩';
case 'vulnerability':
return '⚠️';
default:
return '•';
}
}
getSeverityClass(severity: string | undefined): string {
if (!severity) return '';
return `severity--${severity}`;
}
getNodeClass(node: GraphNode): string {
const classes = [`node--${node.type}`];
if (node.severity) classes.push(`node--${node.severity}`);
if (node.hasException) classes.push('node--excepted');
if (this.selectedNodeId() === node.id) classes.push('node--selected');
return classes.join(' ');
}
trackByNode = (_: number, item: GraphNode) => item.id;
// Overlay handlers
onOverlayStateChange(state: GraphOverlayState): void {
this.overlayState.set(state);
}
onSimulationModeChange(enabled: boolean): void {
this.simulationMode.set(enabled);
this.showMessage(enabled ? 'Simulation mode enabled' : 'Simulation mode disabled', 'info');
}
onPathViewChange(state: { enabled: boolean; type: string }): void {
this.pathViewState.set(state);
if (state.enabled) {
this.showMessage(`Path view enabled: ${state.type}`, 'info');
}
}
onTimeTravelChange(state: { enabled: boolean; snapshot: string }): void {
this.timeTravelState.set(state);
if (state.enabled && state.snapshot !== 'current') {
this.showMessage(`Viewing snapshot: ${state.snapshot}`, 'info');
}
}
onShowDiffRequest(snapshot: string): void {
this.showMessage(`Loading diff for ${snapshot}...`, 'info');
}
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
this.message.set(text);
this.messageType.set(type);
setTimeout(() => this.message.set(null), 5000);
}
}

View File

@@ -408,7 +408,7 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
transition: border-color 0.15s ease;
&:focus-within {
border-color: #4f46e5;
border-color: #F5A623;
}
}
@@ -494,18 +494,18 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
transition: all 0.15s ease;
&:hover {
border-color: #4f46e5;
color: #4f46e5;
border-color: #F5A623;
color: #F5A623;
}
&--active {
background: #4f46e5;
border-color: #4f46e5;
background: #F5A623;
border-color: #F5A623;
color: white;
&:hover {
background: #4338ca;
border-color: #4338ca;
background: #E09115;
border-color: #E09115;
}
}
}
@@ -649,7 +649,7 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
padding: 0.25rem 0.5rem;
border: none;
background: transparent;
color: #4f46e5;
color: #F5A623;
font-size: 0.75rem;
cursor: pointer;
@@ -681,8 +681,8 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
cursor: pointer;
&:hover:not(:disabled) {
border-color: #4f46e5;
color: #4f46e5;
border-color: #F5A623;
color: #F5A623;
}
&:disabled {
@@ -769,8 +769,8 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
cursor: pointer;
&:hover:not(:disabled) {
border-color: #4f46e5;
color: #4f46e5;
border-color: #F5A623;
color: #F5A623;
}
&:disabled {
@@ -844,7 +844,7 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
&:focus {
outline: none;
border-color: #4f46e5;
border-color: #F5A623;
}
}
@@ -872,11 +872,11 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
}
&--primary {
background: #4f46e5;
background: #F5A623;
color: white;
&:hover:not(:disabled) {
background: #4338ca;
background: #E09115;
}
&:disabled {

View File

@@ -156,7 +156,7 @@ import { GraphAccessibilityService, HotkeyBinding } from './graph-accessibility.
}
&:focus-visible {
outline: 2px solid #4f46e5;
outline: 2px solid #F5A623;
outline-offset: 2px;
}
}

View File

@@ -606,18 +606,18 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
position: relative;
&:hover {
border-color: var(--overlay-color, #4f46e5);
border-color: var(--overlay-color, #F5A623);
color: #1e293b;
}
&--active {
border-color: var(--overlay-color, #4f46e5);
background: color-mix(in srgb, var(--overlay-color, #4f46e5) 10%, white);
color: var(--overlay-color, #4f46e5);
border-color: var(--overlay-color, #F5A623);
background: color-mix(in srgb, var(--overlay-color, #F5A623) 10%, white);
color: var(--overlay-color, #F5A623);
}
&:focus-visible {
outline: 2px solid var(--overlay-color, #4f46e5);
outline: 2px solid var(--overlay-color, #F5A623);
outline-offset: 2px;
}
}
@@ -634,7 +634,7 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--overlay-color, #4f46e5);
background: var(--overlay-color, #F5A623);
}
/* Simulation toggle */
@@ -869,14 +869,14 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
transition: all 0.15s ease;
&:hover {
border-color: #4f46e5;
border-color: #F5A623;
color: #1e293b;
}
&--active {
border-color: #4f46e5;
border-color: #F5A623;
background: #eef2ff;
color: #4f46e5;
color: #F5A623;
}
}
@@ -1023,7 +1023,7 @@ export class GraphOverlaysComponent implements OnChanges {
// Overlay configurations
readonly overlayConfigs = signal<OverlayConfig[]>([
{ type: 'policy', enabled: false, label: 'Policy', icon: '📋', color: '#4f46e5' },
{ type: 'policy', enabled: false, label: 'Policy', icon: '📋', color: '#F5A623' },
{ type: 'evidence', enabled: false, label: 'Evidence', icon: '🔍', color: '#0ea5e9' },
{ type: 'license', enabled: false, label: 'License', icon: '📜', color: '#22c55e' },
{ type: 'exposure', enabled: false, label: 'Exposure', icon: '🌐', color: '#ef4444' },

View File

@@ -610,7 +610,7 @@ function generateMockDiff(): SbomDiff {
}
&--active {
color: #4f46e5;
color: #F5A623;
background: white;
&::after {
@@ -620,7 +620,7 @@ function generateMockDiff(): SbomDiff {
left: 0;
right: 0;
height: 2px;
background: #4f46e5;
background: #F5A623;
}
}
}
@@ -704,8 +704,8 @@ function generateMockDiff(): SbomDiff {
cursor: pointer;
&:hover {
border-color: #4f46e5;
color: #4f46e5;
border-color: #F5A623;
color: #F5A623;
}
}
@@ -748,7 +748,7 @@ function generateMockDiff(): SbomDiff {
font-size: 0.8125rem;
a {
color: #4f46e5;
color: #F5A623;
text-decoration: none;
&:hover {
@@ -776,7 +776,7 @@ function generateMockDiff(): SbomDiff {
width: 100%;
&:hover {
border-color: #4f46e5;
border-color: #F5A623;
background: #f8fafc;
}
}
@@ -866,11 +866,11 @@ function generateMockDiff(): SbomDiff {
transition: all 0.15s ease;
&:hover {
border-color: #4f46e5;
border-color: #F5A623;
}
&--selected {
border-color: #4f46e5;
border-color: #F5A623;
background: #f8fafc;
}
}
@@ -989,11 +989,11 @@ function generateMockDiff(): SbomDiff {
cursor: pointer;
&--primary {
background: #4f46e5;
background: #F5A623;
color: white;
&:hover {
background: #4338ca;
background: #E09115;
}
}
@@ -1003,8 +1003,8 @@ function generateMockDiff(): SbomDiff {
color: #475569;
&:hover {
border-color: #4f46e5;
color: #4f46e5;
border-color: #F5A623;
color: #F5A623;
}
}
}
@@ -1151,8 +1151,8 @@ function generateMockDiff(): SbomDiff {
cursor: pointer;
&:hover:not(:disabled) {
border-color: #4f46e5;
color: #4f46e5;
border-color: #F5A623;
color: #F5A623;
}
&:disabled {

View File

@@ -52,7 +52,7 @@ import { GateDisplay, GateChangeDisplay, GateType } from '../models/reachability
}
}
.gate-chip.auth { border-left-color: #6366f1; }
.gate-chip.auth { border-left-color: #D4920A; }
.gate-chip.feature-flag { border-left-color: #f59e0b; }
.gate-chip.config { border-left-color: #8b5cf6; }
.gate-chip.runtime { border-left-color: #ec4899; }

View File

@@ -1,394 +1,394 @@
@use 'tokens/breakpoints' as *;
:host {
display: block;
color: var(--color-text-primary);
}
.notify-panel {
background: var(--color-surface-primary);
border-radius: var(--radius-xl);
padding: var(--space-6);
box-shadow: var(--shadow-xl);
}
.notify-panel__header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-6);
h1 {
margin: var(--space-1) 0;
font-size: var(--font-size-2xl);
}
p {
margin: 0;
color: var(--color-text-secondary);
}
}
.eyebrow {
text-transform: uppercase;
font-size: var(--font-size-xs);
letter-spacing: 0.1em;
color: var(--color-text-muted);
margin: 0;
}
.notify-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.notify-card {
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xl);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
min-height: 100%;
}
.notify-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
h2 {
margin: 0;
font-size: var(--font-size-xl);
}
p {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
}
.ghost-button {
border: 1px solid var(--color-border-primary);
background: transparent;
color: var(--color-text-primary);
border-radius: var(--radius-full);
padding: var(--space-1) var(--space-3);
font-size: var(--font-size-sm);
cursor: pointer;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
border-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover,
&:focus-visible {
border-color: var(--color-brand-primary);
background: var(--color-brand-light);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.notify-message {
margin: 0;
padding: var(--space-2) var(--space-2-5);
border-radius: var(--radius-md);
background: var(--color-status-info-bg);
color: var(--color-status-info);
font-size: var(--font-size-sm);
}
.channel-list,
.rule-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.channel-item,
.rule-item {
width: 100%;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
color: inherit;
border-radius: var(--radius-lg);
padding: var(--space-2) var(--space-3);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
cursor: pointer;
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
transform var(--motion-duration-fast) var(--motion-ease-default);
&.active {
border-color: var(--color-brand-primary);
background: var(--color-brand-light);
transform: translateY(-1px);
}
}
.channel-meta,
.rule-meta {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.channel-status {
font-size: var(--font-size-xs);
text-transform: uppercase;
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-full);
border: 1px solid var(--color-border-primary);
}
.channel-status--enabled {
border-color: var(--color-status-success);
color: var(--color-status-success);
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-3);
width: 100%;
}
label {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--font-size-sm);
}
label span {
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
}
input,
textarea,
select {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
color: inherit;
padding: var(--space-2);
font-size: var(--font-size-base);
font-family: inherit;
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
}
.checkbox {
flex-direction: row;
align-items: center;
gap: var(--space-2);
font-weight: var(--font-weight-medium);
input {
width: auto;
accent-color: var(--color-brand-primary);
}
}
.full-width {
grid-column: 1 / -1;
}
.notify-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.notify-actions button {
border: none;
border-radius: var(--radius-full);
padding: var(--space-1-5) var(--space-4);
font-weight: var(--font-weight-semibold);
cursor: pointer;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
transition: opacity var(--motion-duration-fast) var(--motion-ease-default);
&:hover:not(:disabled) {
background: var(--color-brand-primary-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.notify-actions .ghost-button {
background: transparent;
color: var(--color-text-primary);
}
.channel-health {
display: flex;
gap: var(--space-2);
align-items: center;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-lg);
background: var(--color-surface-tertiary);
border: 1px solid var(--color-border-secondary);
}
.status-pill {
padding: var(--space-1) var(--space-2-5);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
border: 1px solid var(--color-border-primary);
}
.status-pill--healthy {
border-color: var(--color-status-success);
color: var(--color-status-success);
}
.status-pill--warning {
border-color: var(--color-status-warning);
color: var(--color-status-warning);
}
.status-pill--error {
border-color: var(--color-status-error);
color: var(--color-status-error);
}
.channel-health__details p {
margin: 0;
font-size: var(--font-size-sm);
}
.channel-health__details small {
color: var(--color-text-muted);
}
.test-form h3 {
margin: 0;
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
.test-preview {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: var(--space-3);
background: var(--color-surface-tertiary);
header {
display: flex;
justify-content: space-between;
font-size: var(--font-size-sm);
}
p {
margin: var(--space-1) 0;
font-size: var(--font-size-sm);
}
span {
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
}
.preview-body {
font-family: var(--font-family-mono);
background: var(--color-surface-primary);
border-radius: var(--radius-md);
padding: var(--space-2-5);
}
}
.deliveries-controls {
display: flex;
justify-content: flex-start;
gap: var(--space-3);
}
.deliveries-controls label {
min-width: 140px;
}
.deliveries-table {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
}
thead th {
text-align: left;
font-weight: var(--font-weight-semibold);
padding-bottom: var(--space-2);
color: var(--color-text-secondary);
}
tbody td {
padding: var(--space-2) var(--space-1);
border-top: 1px solid var(--color-border-primary);
}
.empty-row {
text-align: center;
color: var(--color-text-muted);
padding: var(--space-3) 0;
}
.status-badge {
display: inline-block;
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-md);
font-size: var(--font-size-xs);
text-transform: uppercase;
border: 1px solid var(--color-border-primary);
}
.status-badge--sent {
border-color: var(--color-status-success);
color: var(--color-status-success);
}
.status-badge--failed {
border-color: var(--color-status-error);
color: var(--color-status-error);
}
.status-badge--throttled {
border-color: var(--color-status-warning);
color: var(--color-status-warning);
}
@include screen-below-md {
.notify-panel {
padding: var(--space-4);
}
.notify-panel__header {
flex-direction: column;
align-items: flex-start;
}
}
@use 'tokens/breakpoints' as *;
:host {
display: block;
color: var(--color-text-primary);
}
.notify-panel {
background: var(--color-surface-primary);
border-radius: var(--radius-xl);
padding: var(--space-6);
box-shadow: var(--shadow-xl);
}
.notify-panel__header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-6);
h1 {
margin: var(--space-1) 0;
font-size: var(--font-size-2xl);
}
p {
margin: 0;
color: var(--color-text-secondary);
}
}
.eyebrow {
text-transform: uppercase;
font-size: var(--font-size-xs);
letter-spacing: 0.1em;
color: var(--color-text-muted);
margin: 0;
}
.notify-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.notify-card {
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xl);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
min-height: 100%;
}
.notify-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
h2 {
margin: 0;
font-size: var(--font-size-xl);
}
p {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
}
.ghost-button {
border: 1px solid var(--color-border-primary);
background: transparent;
color: var(--color-text-primary);
border-radius: var(--radius-full);
padding: var(--space-1) var(--space-3);
font-size: var(--font-size-sm);
cursor: pointer;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
border-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover,
&:focus-visible {
border-color: var(--color-brand-primary);
background: var(--color-brand-light);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.notify-message {
margin: 0;
padding: var(--space-2) var(--space-2-5);
border-radius: var(--radius-md);
background: var(--color-status-info-bg);
color: var(--color-status-info);
font-size: var(--font-size-sm);
}
.channel-list,
.rule-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.channel-item,
.rule-item {
width: 100%;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
color: inherit;
border-radius: var(--radius-lg);
padding: var(--space-2) var(--space-3);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
cursor: pointer;
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
transform var(--motion-duration-fast) var(--motion-ease-default);
&.active {
border-color: var(--color-brand-primary);
background: var(--color-brand-light);
transform: translateY(-1px);
}
}
.channel-meta,
.rule-meta {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.channel-status {
font-size: var(--font-size-xs);
text-transform: uppercase;
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-full);
border: 1px solid var(--color-border-primary);
}
.channel-status--enabled {
border-color: var(--color-status-success);
color: var(--color-status-success);
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-3);
width: 100%;
}
label {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--font-size-sm);
}
label span {
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
}
input,
textarea,
select {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
color: inherit;
padding: var(--space-2);
font-size: var(--font-size-base);
font-family: inherit;
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
}
.checkbox {
flex-direction: row;
align-items: center;
gap: var(--space-2);
font-weight: var(--font-weight-medium);
input {
width: auto;
accent-color: var(--color-brand-primary);
}
}
.full-width {
grid-column: 1 / -1;
}
.notify-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.notify-actions button {
border: none;
border-radius: var(--radius-full);
padding: var(--space-1-5) var(--space-4);
font-weight: var(--font-weight-semibold);
cursor: pointer;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
transition: opacity var(--motion-duration-fast) var(--motion-ease-default);
&:hover:not(:disabled) {
background: var(--color-brand-primary-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.notify-actions .ghost-button {
background: transparent;
color: var(--color-text-primary);
}
.channel-health {
display: flex;
gap: var(--space-2);
align-items: center;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-lg);
background: var(--color-surface-tertiary);
border: 1px solid var(--color-border-secondary);
}
.status-pill {
padding: var(--space-1) var(--space-2-5);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
border: 1px solid var(--color-border-primary);
}
.status-pill--healthy {
border-color: var(--color-status-success);
color: var(--color-status-success);
}
.status-pill--warning {
border-color: var(--color-status-warning);
color: var(--color-status-warning);
}
.status-pill--error {
border-color: var(--color-status-error);
color: var(--color-status-error);
}
.channel-health__details p {
margin: 0;
font-size: var(--font-size-sm);
}
.channel-health__details small {
color: var(--color-text-muted);
}
.test-form h3 {
margin: 0;
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
.test-preview {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: var(--space-3);
background: var(--color-surface-tertiary);
header {
display: flex;
justify-content: space-between;
font-size: var(--font-size-sm);
}
p {
margin: var(--space-1) 0;
font-size: var(--font-size-sm);
}
span {
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
}
.preview-body {
font-family: var(--font-family-mono);
background: var(--color-surface-primary);
border-radius: var(--radius-md);
padding: var(--space-2-5);
}
}
.deliveries-controls {
display: flex;
justify-content: flex-start;
gap: var(--space-3);
}
.deliveries-controls label {
min-width: 140px;
}
.deliveries-table {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
}
thead th {
text-align: left;
font-weight: var(--font-weight-semibold);
padding-bottom: var(--space-2);
color: var(--color-text-secondary);
}
tbody td {
padding: var(--space-2) var(--space-1);
border-top: 1px solid var(--color-border-primary);
}
.empty-row {
text-align: center;
color: var(--color-text-muted);
padding: var(--space-3) 0;
}
.status-badge {
display: inline-block;
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-md);
font-size: var(--font-size-xs);
text-transform: uppercase;
border: 1px solid var(--color-border-primary);
}
.status-badge--sent {
border-color: var(--color-status-success);
color: var(--color-status-success);
}
.status-badge--failed {
border-color: var(--color-status-error);
color: var(--color-status-error);
}
.status-badge--throttled {
border-color: var(--color-status-warning);
color: var(--color-status-warning);
}
@include screen-below-md {
.notify-panel {
padding: var(--space-4);
}
.notify-panel__header {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -1,66 +1,66 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NOTIFY_API } from '../../core/api/notify.client';
import { MockNotifyApiService } from '../../testing/mock-notify-api.service';
import { NotifyPanelComponent } from './notify-panel.component';
describe('NotifyPanelComponent', () => {
let fixture: ComponentFixture<NotifyPanelComponent>;
let component: NotifyPanelComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NotifyPanelComponent],
providers: [
MockNotifyApiService,
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
],
}).compileComponents();
fixture = TestBed.createComponent(NotifyPanelComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('renders channels from the mocked API', async () => {
await component.refreshAll();
fixture.detectChanges();
const items: NodeListOf<HTMLButtonElement> =
fixture.nativeElement.querySelectorAll('[data-testid="channel-item"]');
expect(items.length).toBeGreaterThan(0);
});
it('persists a new rule via the mocked API', async () => {
await component.refreshAll();
fixture.detectChanges();
component.createRuleDraft();
component.ruleForm.patchValue({
name: 'Notify preview rule',
channel: component.channels()[0]?.channelId ?? '',
eventKindsText: 'scanner.report.ready',
labelsText: 'kev',
});
await component.saveRule();
fixture.detectChanges();
const ruleButtons: HTMLElement[] = Array.from(
fixture.nativeElement.querySelectorAll('[data-testid="rule-item"]')
);
expect(
ruleButtons.some((el) => el.textContent?.includes('Notify preview rule'))
).toBeTrue();
});
it('shows a test preview after sending', async () => {
await component.refreshAll();
fixture.detectChanges();
await component.sendTestPreview();
fixture.detectChanges();
const preview = fixture.nativeElement.querySelector('[data-testid="test-preview"]');
expect(preview).toBeTruthy();
});
});
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NOTIFY_API } from '../../core/api/notify.client';
import { MockNotifyApiService } from '../../testing/mock-notify-api.service';
import { NotifyPanelComponent } from './notify-panel.component';
describe('NotifyPanelComponent', () => {
let fixture: ComponentFixture<NotifyPanelComponent>;
let component: NotifyPanelComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NotifyPanelComponent],
providers: [
MockNotifyApiService,
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
],
}).compileComponents();
fixture = TestBed.createComponent(NotifyPanelComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('renders channels from the mocked API', async () => {
await component.refreshAll();
fixture.detectChanges();
const items: NodeListOf<HTMLButtonElement> =
fixture.nativeElement.querySelectorAll('[data-testid="channel-item"]');
expect(items.length).toBeGreaterThan(0);
});
it('persists a new rule via the mocked API', async () => {
await component.refreshAll();
fixture.detectChanges();
component.createRuleDraft();
component.ruleForm.patchValue({
name: 'Notify preview rule',
channel: component.channels()[0]?.channelId ?? '',
eventKindsText: 'scanner.report.ready',
labelsText: 'kev',
});
await component.saveRule();
fixture.detectChanges();
const ruleButtons: HTMLElement[] = Array.from(
fixture.nativeElement.querySelectorAll('[data-testid="rule-item"]')
);
expect(
ruleButtons.some((el) => el.textContent?.includes('Notify preview rule'))
).toBeTrue();
});
it('shows a test preview after sending', async () => {
await component.refreshAll();
fixture.detectChanges();
await component.sendTestPreview();
fixture.detectChanges();
const preview = fixture.nativeElement.querySelector('[data-testid="test-preview"]');
expect(preview).toBeTruthy();
});
});

View File

@@ -109,7 +109,7 @@ import { ShadowModeConfig } from '../../core/api/policy-simulation.models';
display: flex;
align-items: center;
justify-content: center;
color: #a5b4fc;
color: #FFCF70;
}
.shadow-indicator--enabled .shadow-indicator__icon {

View File

@@ -222,7 +222,7 @@ import { PolicyApiService } from '../services/policy-api.service';
.approvals__eyebrow {
margin: 0;
color: #a5b4fc;
color: #FFCF70;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.8rem;

View File

@@ -440,7 +440,7 @@ import type { PolicyParseResult, PolicyIntent } from '../../../core/api/advisory
}
.intent-badge.type-ScopeRestriction {
color: #4f46e5;
color: #F5A623;
background: #e0e7ff;
}

View File

@@ -217,7 +217,7 @@ import { PolicyApiService } from '../services/policy-api.service';
.sim__eyebrow {
margin: 0;
color: #a5b4fc;
color: #FFCF70;
font-size: 0.8rem;
letter-spacing: 0.05em;
text-transform: uppercase;

View File

@@ -109,7 +109,7 @@ import { PolicyPackStore } from '../services/policy-pack.store';
.workspace__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
.pack-card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; box-shadow: 0 12px 30px rgba(0,0,0,0.28); display: grid; gap: 0.6rem; }
.pack-card__head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
.pack-card__eyebrow { margin: 0; color: #a5b4fc; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
.pack-card__eyebrow { margin: 0; color: #FFCF70; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
.pack-card__desc { margin: 0.2rem 0 0; color: #cbd5e1; }
.pack-card__meta { display: grid; justify-items: end; gap: 0.2rem; color: #94a3b8; font-size: 0.9rem; }
.pack-card__tags { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }

View File

@@ -563,8 +563,8 @@ type SortOrder = 'asc' | 'desc';
}
&--active {
color: #4f46e5;
border-bottom-color: #4f46e5;
color: #F5A623;
border-bottom-color: #F5A623;
}
}
@@ -582,7 +582,7 @@ type SortOrder = 'asc' | 'desc';
width: 16px;
height: 16px;
border: 2px solid #e2e8f0;
border-top-color: #4f46e5;
border-top-color: #F5A623;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@@ -675,7 +675,7 @@ type SortOrder = 'asc' | 'desc';
}
.policy-studio__link {
color: #4f46e5;
color: #F5A623;
text-decoration: none;
font-weight: 500;
@@ -720,13 +720,13 @@ type SortOrder = 'asc' | 'desc';
}
&--primary {
background: #4f46e5;
border-color: #4f46e5;
background: #F5A623;
border-color: #F5A623;
color: white;
&:hover:not(:disabled) {
background: #4338ca;
border-color: #4338ca;
background: #E09115;
border-color: #E09115;
}
}
@@ -762,8 +762,8 @@ type SortOrder = 'asc' | 'desc';
&:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
border-color: #F5A623;
box-shadow: 0 0 0 3px rgba(245, 166, 35, 0.1);
}
&--sm {

View File

@@ -1,9 +1,9 @@
/**
* Releases Feature Module
* Sprint: SPRINT_20260118_004_FE_releases_feature
*/
export * from './releases.routes';
export { ReleaseFlowComponent } from './release-flow.component';
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
export { RemediationHintsComponent } from './remediation-hints.component';
/**
* Releases Feature Module
* Sprint: SPRINT_20260118_004_FE_releases_feature
*/
export * from './releases.routes';
export { ReleaseFlowComponent } from './release-flow.component';
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
export { RemediationHintsComponent } from './remediation-hints.component';

View File

@@ -1,328 +1,328 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
signal,
} from '@angular/core';
import {
PolicyGateResult,
PolicyGateStatus,
DeterminismFeatureFlags,
} from '../../core/api/release.models';
@Component({
selector: 'app-policy-gate-indicator',
standalone: true,
imports: [CommonModule],
template: `
<div
class="gate-indicator"
[class.gate-indicator--expanded]="expanded()"
[class]="'gate-indicator--' + gate().status"
>
<button
type="button"
class="gate-header"
(click)="toggleExpanded()"
[attr.aria-expanded]="expanded()"
[attr.aria-controls]="'gate-details-' + gate().gateId"
>
<div class="gate-status">
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
@switch (gate().status) {
@case ('passed') { <span>&#10003;</span> }
@case ('failed') { <span>&#10007;</span> }
@case ('warning') { <span>!</span> }
@case ('pending') { <span>&#8987;</span> }
@case ('skipped') { <span>-</span> }
}
</span>
<span class="status-text">{{ getStatusLabel() }}</span>
</div>
<div class="gate-info">
<span class="gate-name">{{ gate().name }}</span>
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
}
@if (gate().blockingPublish) {
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
}
</div>
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '&#9650;' : '&#9660;' }}</span>
</button>
@if (expanded()) {
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
<p class="gate-message">{{ gate().message }}</p>
<div class="gate-meta">
<span class="meta-item">
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
</span>
@if (gate().evidence?.url) {
<a
[href]="gate().evidence?.url"
class="evidence-link"
target="_blank"
rel="noopener"
>
View Evidence
</a>
}
</div>
<!-- Determinism-specific info when feature flag shows it -->
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
<div class="feature-flag-info">
@if (featureFlags()?.blockOnFailure) {
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
} @else if (featureFlags()?.warnOnly) {
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
}
</div>
}
</div>
}
</div>
`,
styles: [`
.gate-indicator {
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
overflow: hidden;
transition: border-color 0.15s;
&--passed {
border-left: 3px solid #22c55e;
}
&--failed {
border-left: 3px solid #ef4444;
}
&--warning {
border-left: 3px solid #f97316;
}
&--pending {
border-left: 3px solid #eab308;
}
&--skipped {
border-left: 3px solid #64748b;
}
&--expanded {
border-color: #475569;
}
}
.gate-header {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
color: #e2e8f0;
cursor: pointer;
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
}
.gate-status {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 80px;
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 0.75rem;
font-weight: bold;
}
.gate-indicator--passed .status-icon {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.gate-indicator--failed .status-icon {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.gate-indicator--warning .status-icon {
background: rgba(249, 115, 22, 0.2);
color: #f97316;
}
.gate-indicator--pending .status-icon {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.gate-indicator--skipped .status-icon {
background: rgba(100, 116, 139, 0.2);
color: #64748b;
}
.status-text {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.gate-indicator--passed .status-text { color: #22c55e; }
.gate-indicator--failed .status-text { color: #ef4444; }
.gate-indicator--warning .status-text { color: #f97316; }
.gate-indicator--pending .status-text { color: #eab308; }
.gate-indicator--skipped .status-text { color: #64748b; }
.gate-info {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
}
.gate-name {
font-weight: 500;
}
.gate-type-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
&--determinism {
background: rgba(147, 51, 234, 0.2);
color: #a855f7;
}
}
.blocking-badge {
padding: 0.125rem 0.5rem;
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
}
.expand-icon {
color: #64748b;
font-size: 0.625rem;
}
.gate-details {
padding: 0 1rem 1rem 1rem;
border-top: 1px solid #334155;
margin-top: 0;
}
.gate-message {
margin: 0.75rem 0;
color: #94a3b8;
font-size: 0.875rem;
line-height: 1.5;
}
.gate-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
font-size: 0.8125rem;
color: #64748b;
strong {
color: #94a3b8;
}
}
.evidence-link {
color: #3b82f6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.feature-flag-info {
margin-top: 0.75rem;
}
.flag-badge {
display: inline-block;
padding: 0.25rem 0.625rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
&--active {
background: rgba(147, 51, 234, 0.2);
color: #a855f7;
}
&--warn {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PolicyGateIndicatorComponent {
readonly gate = input.required<PolicyGateResult>();
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
readonly expanded = signal(false);
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
toggleExpanded(): void {
this.expanded.update((v) => !v);
}
getStatusLabel(): string {
const labels: Record<PolicyGateStatus, string> = {
passed: 'Passed',
failed: 'Failed',
pending: 'Pending',
warning: 'Warning',
skipped: 'Skipped',
};
return labels[this.gate().status] ?? 'Unknown';
}
getStatusIconClass(): string {
return `status-icon--${this.gate().status}`;
}
formatDate(isoString: string): string {
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
signal,
} from '@angular/core';
import {
PolicyGateResult,
PolicyGateStatus,
DeterminismFeatureFlags,
} from '../../core/api/release.models';
@Component({
selector: 'app-policy-gate-indicator',
standalone: true,
imports: [CommonModule],
template: `
<div
class="gate-indicator"
[class.gate-indicator--expanded]="expanded()"
[class]="'gate-indicator--' + gate().status"
>
<button
type="button"
class="gate-header"
(click)="toggleExpanded()"
[attr.aria-expanded]="expanded()"
[attr.aria-controls]="'gate-details-' + gate().gateId"
>
<div class="gate-status">
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
@switch (gate().status) {
@case ('passed') { <span>&#10003;</span> }
@case ('failed') { <span>&#10007;</span> }
@case ('warning') { <span>!</span> }
@case ('pending') { <span>&#8987;</span> }
@case ('skipped') { <span>-</span> }
}
</span>
<span class="status-text">{{ getStatusLabel() }}</span>
</div>
<div class="gate-info">
<span class="gate-name">{{ gate().name }}</span>
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
}
@if (gate().blockingPublish) {
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
}
</div>
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '&#9650;' : '&#9660;' }}</span>
</button>
@if (expanded()) {
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
<p class="gate-message">{{ gate().message }}</p>
<div class="gate-meta">
<span class="meta-item">
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
</span>
@if (gate().evidence?.url) {
<a
[href]="gate().evidence?.url"
class="evidence-link"
target="_blank"
rel="noopener"
>
View Evidence
</a>
}
</div>
<!-- Determinism-specific info when feature flag shows it -->
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
<div class="feature-flag-info">
@if (featureFlags()?.blockOnFailure) {
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
} @else if (featureFlags()?.warnOnly) {
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
}
</div>
}
</div>
}
</div>
`,
styles: [`
.gate-indicator {
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
overflow: hidden;
transition: border-color 0.15s;
&--passed {
border-left: 3px solid #22c55e;
}
&--failed {
border-left: 3px solid #ef4444;
}
&--warning {
border-left: 3px solid #f97316;
}
&--pending {
border-left: 3px solid #eab308;
}
&--skipped {
border-left: 3px solid #64748b;
}
&--expanded {
border-color: #475569;
}
}
.gate-header {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
color: #e2e8f0;
cursor: pointer;
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
}
.gate-status {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 80px;
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 0.75rem;
font-weight: bold;
}
.gate-indicator--passed .status-icon {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.gate-indicator--failed .status-icon {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.gate-indicator--warning .status-icon {
background: rgba(249, 115, 22, 0.2);
color: #f97316;
}
.gate-indicator--pending .status-icon {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.gate-indicator--skipped .status-icon {
background: rgba(100, 116, 139, 0.2);
color: #64748b;
}
.status-text {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.gate-indicator--passed .status-text { color: #22c55e; }
.gate-indicator--failed .status-text { color: #ef4444; }
.gate-indicator--warning .status-text { color: #f97316; }
.gate-indicator--pending .status-text { color: #eab308; }
.gate-indicator--skipped .status-text { color: #64748b; }
.gate-info {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
}
.gate-name {
font-weight: 500;
}
.gate-type-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
&--determinism {
background: rgba(147, 51, 234, 0.2);
color: #a855f7;
}
}
.blocking-badge {
padding: 0.125rem 0.5rem;
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
}
.expand-icon {
color: #64748b;
font-size: 0.625rem;
}
.gate-details {
padding: 0 1rem 1rem 1rem;
border-top: 1px solid #334155;
margin-top: 0;
}
.gate-message {
margin: 0.75rem 0;
color: #94a3b8;
font-size: 0.875rem;
line-height: 1.5;
}
.gate-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
font-size: 0.8125rem;
color: #64748b;
strong {
color: #94a3b8;
}
}
.evidence-link {
color: #3b82f6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.feature-flag-info {
margin-top: 0.75rem;
}
.flag-badge {
display: inline-block;
padding: 0.25rem 0.625rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
&--active {
background: rgba(147, 51, 234, 0.2);
color: #a855f7;
}
&--warn {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PolicyGateIndicatorComponent {
readonly gate = input.required<PolicyGateResult>();
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
readonly expanded = signal(false);
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
toggleExpanded(): void {
this.expanded.update((v) => !v);
}
getStatusLabel(): string {
const labels: Record<PolicyGateStatus, string> = {
passed: 'Passed',
failed: 'Failed',
pending: 'Pending',
warning: 'Warning',
skipped: 'Skipped',
};
return labels[this.gate().status] ?? 'Unknown';
}
getStatusIconClass(): string {
return `status-icon--${this.gate().status}`;
}
formatDate(isoString: string): string {
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
}
}

Some files were not shown because too many files have changed in this diff Show More