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) public EvidenceLockerIntegrationTests(EvidenceLockerWebApplicationFactory factory)
{ {
_factory = factory; _factory = factory;
_factory.ResetTestState();
_client = _factory.CreateClient(); _client = _factory.CreateClient();
} }

View File

@@ -50,6 +50,18 @@ public sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<
public TestTimelinePublisher TimelinePublisher => Services.GetRequiredService<TestTimelinePublisher>(); 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() private static SigningKeyMaterialOptions GenerateKeyMaterial()
{ {
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); 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> PublishedEvents { get; } = new();
public List<string> IncidentEvents { get; } = new(); public List<string> IncidentEvents { get; } = new();
public void Reset()
{
PublishedEvents.Clear();
IncidentEvents.Clear();
}
public Task PublishBundleSealedAsync( public Task PublishBundleSealedAsync(
EvidenceBundleSignature signature, EvidenceBundleSignature signature,
EvidenceBundleManifest manifest, EvidenceBundleManifest manifest,
@@ -196,6 +214,12 @@ public sealed class TestEvidenceObjectStore : IEvidenceObjectStore
public void SeedExisting(string storageKey) => _preExisting.Add(storageKey); 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) public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
{ {
using var memory = new MemoryStream(); using var memory = new MemoryStream();
@@ -235,6 +259,13 @@ public sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
public bool HoldConflict { get; set; } public bool HoldConflict { get; set; }
public void Reset()
{
_signatures.Clear();
_bundles.Clear();
HoldConflict = false;
}
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken) public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
{ {
_bundles[(bundle.Id.Value, bundle.TenantId.Value)] = bundle; _bundles[(bundle.Id.Value, bundle.TenantId.Value)] = bundle;

View File

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

View File

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

View File

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

View File

@@ -20,19 +20,18 @@ namespace StellaOps.EvidenceLocker.Tests;
/// <summary> /// <summary>
/// Integration tests for export API endpoints. /// Integration tests for export API endpoints.
/// Uses the shared EvidenceLockerWebApplicationFactory (via Collection fixture) /// Uses a single derived WebApplicationFactory for the entire class (via IClassFixture)
/// instead of raw WebApplicationFactory&lt;Program&gt; to avoid loading real /// to avoid creating a new TestServer per test, which previously leaked memory.
/// infrastructure services (database, auth, background services) which causes
/// the test process to hang and consume excessive memory.
/// </summary> /// </summary>
[Collection(EvidenceLockerTestCollection.Name)] public sealed class ExportEndpointsTests : IClassFixture<ExportEndpointsTests.ExportTestFixture>, IDisposable
public sealed class ExportEndpointsTests
{ {
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] [Fact]
@@ -52,11 +51,11 @@ public sealed class ExportEndpointsTests
EstimatedSize = 1024 EstimatedSize = 1024
}); });
using var scope = CreateClientWithMock(mockService.Object); _fixture.CurrentMock = mockService.Object;
var request = new ExportTriggerRequest(); var request = new ExportTriggerRequest();
// Act // 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
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
@@ -79,11 +78,11 @@ public sealed class ExportEndpointsTests
It.IsAny<CancellationToken>())) It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportJobResult { IsNotFound = true }); .ReturnsAsync(new ExportJobResult { IsNotFound = true });
using var scope = CreateClientWithMock(mockService.Object); _fixture.CurrentMock = mockService.Object;
var request = new ExportTriggerRequest(); var request = new ExportTriggerRequest();
// Act // 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
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
@@ -104,10 +103,10 @@ public sealed class ExportEndpointsTests
CompletedAt = DateTimeOffset.Parse("2026-01-07T12:00:00Z") CompletedAt = DateTimeOffset.Parse("2026-01-07T12:00:00Z")
}); });
using var scope = CreateClientWithMock(mockService.Object); _fixture.CurrentMock = mockService.Object;
// Act // 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
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -133,10 +132,10 @@ public sealed class ExportEndpointsTests
EstimatedTimeRemaining = "30s" EstimatedTimeRemaining = "30s"
}); });
using var scope = CreateClientWithMock(mockService.Object); _fixture.CurrentMock = mockService.Object;
// Act // 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
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
@@ -156,10 +155,10 @@ public sealed class ExportEndpointsTests
.Setup(s => s.GetExportStatusAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>())) .Setup(s => s.GetExportStatusAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
.ReturnsAsync((ExportJobStatus?)null); .ReturnsAsync((ExportJobStatus?)null);
using var scope = CreateClientWithMock(mockService.Object); _fixture.CurrentMock = mockService.Object;
// Act // 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
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
@@ -181,10 +180,10 @@ public sealed class ExportEndpointsTests
FileName = "evidence-bundle-123.tar.gz" FileName = "evidence-bundle-123.tar.gz"
}); });
using var scope = CreateClientWithMock(mockService.Object); _fixture.CurrentMock = mockService.Object;
// Act // 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
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -203,10 +202,10 @@ public sealed class ExportEndpointsTests
Status = ExportJobStatusEnum.Processing Status = ExportJobStatusEnum.Processing
}); });
using var scope = CreateClientWithMock(mockService.Object); _fixture.CurrentMock = mockService.Object;
// Act // 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
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
@@ -221,10 +220,10 @@ public sealed class ExportEndpointsTests
.Setup(s => s.GetExportFileAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>())) .Setup(s => s.GetExportFileAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
.ReturnsAsync((ExportFileResult?)null); .ReturnsAsync((ExportFileResult?)null);
using var scope = CreateClientWithMock(mockService.Object); _fixture.CurrentMock = mockService.Object;
// Act // 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
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
@@ -248,7 +247,7 @@ public sealed class ExportEndpointsTests
Status = "pending" Status = "pending"
}); });
using var scope = CreateClientWithMock(mockService.Object); _fixture.CurrentMock = mockService.Object;
var request = new ExportTriggerRequest var request = new ExportTriggerRequest
{ {
CompressionLevel = 9, CompressionLevel = 9,
@@ -257,7 +256,7 @@ public sealed class ExportEndpointsTests
}; };
// Act // Act
await scope.Client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request); await _client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
// Assert // Assert
Assert.NotNull(capturedRequest); Assert.NotNull(capturedRequest);
@@ -266,52 +265,76 @@ public sealed class ExportEndpointsTests
Assert.False(capturedRequest.IncludeRekorProofs); Assert.False(capturedRequest.IncludeRekorProofs);
} }
/// <summary> public void Dispose()
/// 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
{ {
private readonly WebApplicationFactory<EvidenceLockerProgram> _derivedFactory; _client.Dispose();
public HttpClient Client { get; } }
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; // Create ONE derived factory whose IExportJobService delegates to CurrentMock.
Client = derivedFactory.CreateClient(); // 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() 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 => private readonly ExportTestFixture _fixture;
{
builder.ConfigureServices(services =>
{
// Remove existing registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IExportJobService));
if (descriptor != null)
{
services.Remove(descriptor);
}
// Add mock public DelegatingExportJobService(ExportTestFixture fixture)
services.AddSingleton(mockService); {
}); _fixture = fixture;
}); }
return new MockScope(derivedFactory);
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)) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{ {
// On Windows, try to open the Docker named pipe with a short timeout. // Check if the Docker daemon is actually running by looking for its process.
// File.Exists does not work for named pipes. // NamedPipeClientStream.Connect() hangs indefinitely when Docker Desktop is
using var pipe = new System.IO.Pipes.NamedPipeClientStream(".", "docker_engine", System.IO.Pipes.PipeDirection.InOut, System.IO.Pipes.PipeOptions.None); // installed but not running (the pipe exists but nobody reads from it).
pipe.Connect(2000); // 2 second timeout // Testcontainers' own Docker client also hangs in this scenario.
return true; // 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 // On Linux/macOS, check for the Docker socket

View File

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

View File

@@ -1,54 +1,54 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { AuthorityAuthService } from './core/auth/authority-auth.service'; import { AuthorityAuthService } from './core/auth/authority-auth.service';
import { AuthSessionStore } from './core/auth/auth-session.store'; import { AuthSessionStore } from './core/auth/auth-session.store';
import { AUTH_SERVICE, AuthService } from './core/auth'; import { AUTH_SERVICE, AuthService } from './core/auth';
import { ConsoleSessionStore } from './core/console/console-session.store'; import { ConsoleSessionStore } from './core/console/console-session.store';
import { AppConfigService } from './core/config/app-config.service'; import { AppConfigService } from './core/config/app-config.service';
import { PolicyPackStore } from './features/policy-studio/services/policy-pack.store'; import { PolicyPackStore } from './features/policy-studio/services/policy-pack.store';
class AuthorityAuthServiceStub { class AuthorityAuthServiceStub {
beginLogin = jasmine.createSpy('beginLogin'); beginLogin = jasmine.createSpy('beginLogin');
logout = jasmine.createSpy('logout'); logout = jasmine.createSpy('logout');
} }
describe('AppComponent', () => { describe('AppComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [AppComponent, RouterTestingModule, HttpClientTestingModule], imports: [AppComponent, RouterTestingModule, HttpClientTestingModule],
providers: [ providers: [
AuthSessionStore, AuthSessionStore,
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub }, { provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService }, { provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
ConsoleSessionStore, ConsoleSessionStore,
{ provide: AppConfigService, useValue: { config: { quickstartMode: false, apiBaseUrls: { authority: '', policy: '' } } } }, { provide: AppConfigService, useValue: { config: { quickstartMode: false, apiBaseUrls: { authority: '', policy: '' } } } },
{ {
provide: PolicyPackStore, provide: PolicyPackStore,
useValue: { useValue: {
getPacks: () => getPacks: () =>
of([ of([
{ id: 'pack-1', name: 'Pack One', description: '', version: '1.0', status: 'active', createdAt: '', modifiedAt: '', createdBy: '', modifiedBy: '', tags: [] }, { id: 'pack-1', name: 'Pack One', description: '', version: '1.0', status: 'active', createdAt: '', modifiedAt: '', createdBy: '', modifiedBy: '', tags: [] },
]), ]),
}, },
}, },
], ],
}).compileComponents(); }).compileComponents();
}); });
it('creates the root component', () => { it('creates the root component', () => {
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance; const app = fixture.componentInstance;
expect(app).toBeTruthy(); expect(app).toBeTruthy();
}); });
it('renders a router outlet for child routes', () => { it('renders a router outlet for child routes', () => {
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('router-outlet')).not.toBeNull(); 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. * AOC (Authorization of Containers) models for dashboard metrics.
*/ */
export interface AocMetrics { export interface AocMetrics {
/** Pass/fail counts for the time window */ /** Pass/fail counts for the time window */
passCount: number; passCount: number;
failCount: number; failCount: number;
totalCount: number; totalCount: number;
passRate: number; passRate: number;
/** Recent violations grouped by code */ /** Recent violations grouped by code */
recentViolations: AocViolationSummary[]; recentViolations: AocViolationSummary[];
/** Ingest throughput metrics */ /** Ingest throughput metrics */
ingestThroughput: AocIngestThroughput; ingestThroughput: AocIngestThroughput;
/** Time window for these metrics */ /** Time window for these metrics */
timeWindow: { timeWindow: {
start: string; start: string;
end: string; end: string;
durationMinutes: number; durationMinutes: number;
}; };
} }
export interface AocViolationSummary { export interface AocViolationSummary {
code: string; code: string;
description: string; description: string;
count: number; count: number;
severity: 'critical' | 'high' | 'medium' | 'low'; severity: 'critical' | 'high' | 'medium' | 'low';
lastSeen: string; lastSeen: string;
} }
export interface AocIngestThroughput { export interface AocIngestThroughput {
/** Documents processed per minute */ /** Documents processed per minute */
docsPerMinute: number; docsPerMinute: number;
/** Average processing latency in milliseconds */ /** Average processing latency in milliseconds */
avgLatencyMs: number; avgLatencyMs: number;
/** P95 latency in milliseconds */ /** P95 latency in milliseconds */
p95LatencyMs: number; p95LatencyMs: number;
/** Current queue depth */ /** Current queue depth */
queueDepth: number; queueDepth: number;
/** Error rate percentage */ /** Error rate percentage */
errorRate: number; errorRate: number;
} }
export interface AocVerificationRequest { export interface AocVerificationRequest {
tenantId: string; tenantId: string;
since?: string; since?: string;
limit?: number; limit?: number;
} }
export interface AocVerificationResult { export interface AocVerificationResult {
verificationId: string; verificationId: string;
status: 'passed' | 'failed' | 'partial'; status: 'passed' | 'failed' | 'partial';
checkedCount: number; checkedCount: number;
passedCount: number; passedCount: number;
failedCount: number; failedCount: number;
violations: AocViolationDetail[]; violations: AocViolationDetail[];
completedAt: string; completedAt: string;
} }
export interface AocViolationDetail { export interface AocViolationDetail {
documentId: string; documentId: string;
violationCode: string; violationCode: string;
field?: string; field?: string;
expected?: string; expected?: string;
actual?: string; actual?: string;
provenance?: AocProvenance; provenance?: AocProvenance;
} }
export interface AocProvenance { export interface AocProvenance {
sourceId: string; sourceId: string;
ingestedAt: string; ingestedAt: string;
digest: string; digest: string;
sourceType?: 'registry' | 'git' | 'upload' | 'api'; sourceType?: 'registry' | 'git' | 'upload' | 'api';
sourceUrl?: string; sourceUrl?: string;
submitter?: string; submitter?: string;
} }
export interface AocViolationGroup { export interface AocViolationGroup {
code: string; code: string;
description: string; description: string;
severity: 'critical' | 'high' | 'medium' | 'low'; severity: 'critical' | 'high' | 'medium' | 'low';
violations: AocViolationDetail[]; violations: AocViolationDetail[];
affectedDocuments: number; affectedDocuments: number;
remediation?: string; remediation?: string;
} }
export interface AocDocumentView { export interface AocDocumentView {
documentId: string; documentId: string;
documentType: string; documentType: string;
violations: AocViolationDetail[]; violations: AocViolationDetail[];
provenance: AocProvenance; provenance: AocProvenance;
rawContent?: Record<string, unknown>; rawContent?: Record<string, unknown>;
highlightedFields: string[]; highlightedFields: string[];
} }
/** /**
* Violation severity levels. * Violation severity levels.
*/ */
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low'; export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low';
/** /**
* AOC source configuration. * AOC source configuration.
*/ */
export interface AocSource { export interface AocSource {
id: string; id: string;
sourceId: string; sourceId: string;
name: string; name: string;
type: 'registry' | 'git' | 'upload' | 'api'; type: 'registry' | 'git' | 'upload' | 'api';
url?: string; url?: string;
enabled: boolean; enabled: boolean;
lastSync?: string; lastSync?: string;
status: 'healthy' | 'degraded' | 'offline'; status: 'healthy' | 'degraded' | 'offline';
} }
/** /**
* Violation code definition. * Violation code definition.
*/ */
export interface AocViolationCode { export interface AocViolationCode {
code: string; code: string;
description: string; description: string;
severity: ViolationSeverity; severity: ViolationSeverity;
category: string; category: string;
remediation?: string; remediation?: string;
} }
/** /**
* Dashboard summary data. * Dashboard summary data.
*/ */
export interface AocDashboardSummary { export interface AocDashboardSummary {
/** Pass/fail metrics */ /** Pass/fail metrics */
passFail: { passFail: {
passCount: number; passCount: number;
failCount: number; failCount: number;
totalCount: number; totalCount: number;
passRate: number; passRate: number;
trend?: 'improving' | 'degrading' | 'stable'; trend?: 'improving' | 'degrading' | 'stable';
history?: { timestamp: string; value: number }[]; history?: { timestamp: string; value: number }[];
}; };
/** Recent violations */ /** Recent violations */
recentViolations: AocViolationSummary[]; recentViolations: AocViolationSummary[];
/** Ingest throughput */ /** Ingest throughput */
throughput: AocIngestThroughput; throughput: AocIngestThroughput;
/** Throughput by tenant */ /** Throughput by tenant */
throughputByTenant: TenantThroughput[]; throughputByTenant: TenantThroughput[];
/** Configured sources */ /** Configured sources */
sources: AocSource[]; sources: AocSource[];
/** Time window */ /** Time window */
timeWindow: { timeWindow: {
start: string; start: string;
end: string; end: string;
}; };
} }
/** /**
* Tenant-level throughput metrics. * Tenant-level throughput metrics.
*/ */
export interface TenantThroughput { export interface TenantThroughput {
tenantId: string; tenantId: string;
tenantName?: string; tenantName?: string;
documentsIngested: number; documentsIngested: number;
bytesIngested: number; bytesIngested: number;
} }
/** /**
* Field that caused a violation. * Field that caused a violation.
*/ */
export interface OffendingField { export interface OffendingField {
path: string; path: string;
expected?: string; expected?: string;
actual?: string; actual?: string;
expectedValue?: string; expectedValue?: string;
actualValue?: string; actualValue?: string;
reason: string; reason: string;
suggestion?: string; suggestion?: string;
} }
/** /**
* Detailed violation record for display. * Detailed violation record for display.
*/ */
export interface ViolationDetail { export interface ViolationDetail {
violationId: string; violationId: string;
documentType: string; documentType: string;
documentId: string; documentId: string;
severity: ViolationSeverity; severity: ViolationSeverity;
detectedAt: string; detectedAt: string;
offendingFields: OffendingField[]; offendingFields: OffendingField[];
provenance: ViolationProvenance; provenance: ViolationProvenance;
suggestion?: string; suggestion?: string;
} }
/** /**
* Provenance metadata for a violation. * Provenance metadata for a violation.
*/ */
export interface ViolationProvenance { export interface ViolationProvenance {
sourceType: string; sourceType: string;
sourceUri: string; sourceUri: string;
ingestedAt: string; ingestedAt: string;
ingestedBy: string; ingestedBy: string;
buildId?: string; buildId?: string;
commitSha?: string; commitSha?: string;
pipelineUrl?: string; pipelineUrl?: string;
} }
// Type aliases for backwards compatibility // Type aliases for backwards compatibility
export type IngestThroughput = AocIngestThroughput; export type IngestThroughput = AocIngestThroughput;
export type VerificationRequest = AocVerificationRequest; export type VerificationRequest = AocVerificationRequest;
// ============================================================================= // =============================================================================
// Sprint 027: AOC Compliance Dashboard Extensions // Sprint 027: AOC Compliance Dashboard Extensions
// ============================================================================= // =============================================================================
// Guard violation types for AOC ingestion // Guard violation types for AOC ingestion
export type GuardViolationReason = export type GuardViolationReason =
| 'schema_invalid' | 'schema_invalid'
| 'untrusted_source' | 'untrusted_source'
| 'duplicate' | 'duplicate'
| 'malformed_timestamp' | 'malformed_timestamp'
| 'missing_required_fields' | 'missing_required_fields'
| 'hash_mismatch' | 'hash_mismatch'
| 'unknown'; | 'unknown';
export interface GuardViolation { export interface GuardViolation {
id: string; id: string;
timestamp: string; timestamp: string;
source: string; source: string;
reason: GuardViolationReason; reason: GuardViolationReason;
message: string; message: string;
payloadSample?: string; payloadSample?: string;
module: 'concelier' | 'excititor'; module: 'concelier' | 'excititor';
canRetry: boolean; canRetry: boolean;
} }
// Ingestion flow metrics // Ingestion flow metrics
export interface IngestionSourceMetrics { export interface IngestionSourceMetrics {
sourceId: string; sourceId: string;
sourceName: string; sourceName: string;
module: 'concelier' | 'excititor'; module: 'concelier' | 'excititor';
throughputPerMinute: number; throughputPerMinute: number;
latencyP50Ms: number; latencyP50Ms: number;
latencyP95Ms: number; latencyP95Ms: number;
latencyP99Ms: number; latencyP99Ms: number;
errorRate: number; errorRate: number;
backlogDepth: number; backlogDepth: number;
lastIngestionAt: string; lastIngestionAt: string;
status: 'healthy' | 'degraded' | 'unhealthy'; status: 'healthy' | 'degraded' | 'unhealthy';
} }
export interface IngestionFlowSummary { export interface IngestionFlowSummary {
sources: IngestionSourceMetrics[]; sources: IngestionSourceMetrics[];
totalThroughput: number; totalThroughput: number;
avgLatencyP95Ms: number; avgLatencyP95Ms: number;
overallErrorRate: number; overallErrorRate: number;
lastUpdatedAt: string; lastUpdatedAt: string;
} }
// Provenance chain types // Provenance chain types
export type ProvenanceStepType = export type ProvenanceStepType =
| 'source' | 'source'
| 'advisory_raw' | 'advisory_raw'
| 'normalized' | 'normalized'
| 'vex_decision' | 'vex_decision'
| 'finding' | 'finding'
| 'policy_verdict' | 'policy_verdict'
| 'attestation'; | 'attestation';
export interface ProvenanceStep { export interface ProvenanceStep {
stepType: ProvenanceStepType; stepType: ProvenanceStepType;
label: string; label: string;
timestamp: string; timestamp: string;
hash?: string; hash?: string;
linkedFromHash?: string; linkedFromHash?: string;
status: 'valid' | 'warning' | 'error' | 'pending'; status: 'valid' | 'warning' | 'error' | 'pending';
details: Record<string, unknown>; details: Record<string, unknown>;
errorMessage?: string; errorMessage?: string;
} }
export interface ProvenanceChain { export interface ProvenanceChain {
inputType: 'advisory_id' | 'finding_id' | 'cve_id'; inputType: 'advisory_id' | 'finding_id' | 'cve_id';
inputValue: string; inputValue: string;
steps: ProvenanceStep[]; steps: ProvenanceStep[];
isComplete: boolean; isComplete: boolean;
validationErrors: string[]; validationErrors: string[];
validatedAt: string; validatedAt: string;
} }
// AOC compliance metrics // AOC compliance metrics
export interface AocComplianceMetrics { export interface AocComplianceMetrics {
guardViolations: { guardViolations: {
count: number; count: number;
percentage: number; percentage: number;
byReason: Record<string, number>; byReason: Record<string, number>;
trend: 'up' | 'down' | 'stable'; trend: 'up' | 'down' | 'stable';
}; };
provenanceCompleteness: { provenanceCompleteness: {
percentage: number; percentage: number;
recordsWithValidHash: number; recordsWithValidHash: number;
totalRecords: number; totalRecords: number;
trend: 'up' | 'down' | 'stable'; trend: 'up' | 'down' | 'stable';
}; };
deduplicationRate: { deduplicationRate: {
percentage: number; percentage: number;
duplicatesDetected: number; duplicatesDetected: number;
totalIngested: number; totalIngested: number;
trend: 'up' | 'down' | 'stable'; trend: 'up' | 'down' | 'stable';
}; };
ingestionLatency: { ingestionLatency: {
p50Ms: number; p50Ms: number;
p95Ms: number; p95Ms: number;
p99Ms: number; p99Ms: number;
meetsSla: boolean; meetsSla: boolean;
slaTargetP95Ms: number; slaTargetP95Ms: number;
}; };
supersedesDepth: { supersedesDepth: {
maxDepth: number; maxDepth: number;
avgDepth: number; avgDepth: number;
distribution: { depth: number; count: number }[]; distribution: { depth: number; count: number }[];
}; };
periodStart: string; periodStart: string;
periodEnd: string; periodEnd: string;
} }
// Compliance report // Compliance report
export type ComplianceReportFormat = 'csv' | 'json'; export type ComplianceReportFormat = 'csv' | 'json';
export interface ComplianceReportRequest { export interface ComplianceReportRequest {
startDate: string; startDate: string;
endDate: string; endDate: string;
sources?: string[]; sources?: string[];
format: ComplianceReportFormat; format: ComplianceReportFormat;
includeViolationDetails: boolean; includeViolationDetails: boolean;
} }
export interface ComplianceReportSummary { export interface ComplianceReportSummary {
reportId: string; reportId: string;
generatedAt: string; generatedAt: string;
period: { start: string; end: string }; period: { start: string; end: string };
guardViolationSummary: { guardViolationSummary: {
total: number; total: number;
bySource: Record<string, number>; bySource: Record<string, number>;
byReason: Record<string, number>; byReason: Record<string, number>;
}; };
provenanceCompliance: { provenanceCompliance: {
percentage: number; percentage: number;
bySource: Record<string, number>; bySource: Record<string, number>;
}; };
deduplicationMetrics: { deduplicationMetrics: {
rate: number; rate: number;
bySource: Record<string, number>; bySource: Record<string, number>;
}; };
latencyMetrics: { latencyMetrics: {
p50Ms: number; p50Ms: number;
p95Ms: number; p95Ms: number;
p99Ms: number; p99Ms: number;
bySource: Record<string, { p50: number; p95: number; p99: number }>; bySource: Record<string, { p50: number; p95: number; p99: number }>;
}; };
} }
// API response wrappers // API response wrappers
export interface AocComplianceDashboardData { export interface AocComplianceDashboardData {
metrics: AocComplianceMetrics; metrics: AocComplianceMetrics;
recentViolations: GuardViolation[]; recentViolations: GuardViolation[];
ingestionFlow: IngestionFlowSummary; ingestionFlow: IngestionFlowSummary;
} }
export interface GuardViolationsPagedResponse { export interface GuardViolationsPagedResponse {
items: GuardViolation[]; items: GuardViolation[];
totalCount: number; totalCount: number;
page: number; page: number;
pageSize: number; pageSize: number;
hasMore: boolean; hasMore: boolean;
} }
// Filter options // Filter options
export interface AocDashboardFilters { export interface AocDashboardFilters {
dateRange: { start: string; end: string }; dateRange: { start: string; end: string };
sources?: string[]; sources?: string[];
modules?: ('concelier' | 'excititor')[]; modules?: ('concelier' | 'excititor')[];
violationReasons?: GuardViolationReason[]; violationReasons?: GuardViolationReason[];
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -239,7 +239,7 @@ interface ConfigSubTab {
.sent-icon { background: #10b981; } .sent-icon { background: #10b981; }
.failed-icon { background: #ef4444; } .failed-icon { background: #ef4444; }
.pending-icon { background: #f59e0b; } .pending-icon { background: #f59e0b; }
.rate-icon { background: #6366f1; } .rate-icon { background: #D4920A; }
.stat-content { .stat-content {
display: flex; 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' }, reach: { icon: 'git-branch', color: '#8b5cf6', label: 'Reachability' },
runtime: { icon: 'activity', color: '#f59e0b', label: 'Runtime' }, runtime: { icon: 'activity', color: '#f59e0b', label: 'Runtime' },
vex: { icon: 'shield', color: '#10b981', label: 'VEX' }, 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' }, auth: { icon: 'key', color: '#ef4444', label: 'Auth' },
docs: { icon: 'book', color: '#64748b', label: 'Docs' }, docs: { icon: 'book', color: '#64748b', label: 'Docs' },
finding: { icon: 'alert-triangle', color: '#f97316', label: 'Finding' }, 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--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--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--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--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--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); } .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 { .evidence-type-badge.type-patch {
color: #4f46e5; color: #F5A623;
background: #e0e7ff; background: #e0e7ff;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -217,7 +217,7 @@ import { PolicyApiService } from '../services/policy-api.service';
.sim__eyebrow { .sim__eyebrow {
margin: 0; margin: 0;
color: #a5b4fc; color: #FFCF70;
font-size: 0.8rem; font-size: 0.8rem;
letter-spacing: 0.05em; letter-spacing: 0.05em;
text-transform: uppercase; 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; } .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 { 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__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__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__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; } .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 { &--active {
color: #4f46e5; color: #F5A623;
border-bottom-color: #4f46e5; border-bottom-color: #F5A623;
} }
} }
@@ -582,7 +582,7 @@ type SortOrder = 'asc' | 'desc';
width: 16px; width: 16px;
height: 16px; height: 16px;
border: 2px solid #e2e8f0; border: 2px solid #e2e8f0;
border-top-color: #4f46e5; border-top-color: #F5A623;
border-radius: 50%; border-radius: 50%;
animation: spin 0.6s linear infinite; animation: spin 0.6s linear infinite;
} }
@@ -675,7 +675,7 @@ type SortOrder = 'asc' | 'desc';
} }
.policy-studio__link { .policy-studio__link {
color: #4f46e5; color: #F5A623;
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
@@ -720,13 +720,13 @@ type SortOrder = 'asc' | 'desc';
} }
&--primary { &--primary {
background: #4f46e5; background: #F5A623;
border-color: #4f46e5; border-color: #F5A623;
color: white; color: white;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: #4338ca; background: #E09115;
border-color: #4338ca; border-color: #E09115;
} }
} }
@@ -762,8 +762,8 @@ type SortOrder = 'asc' | 'desc';
&:focus { &:focus {
outline: none; outline: none;
border-color: #4f46e5; border-color: #F5A623;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); box-shadow: 0 0 0 3px rgba(245, 166, 35, 0.1);
} }
&--sm { &--sm {

View File

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

View File

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

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