up tests and theme
This commit is contained in:
@@ -33,6 +33,7 @@ public sealed class EvidenceLockerIntegrationTests : IDisposable
|
||||
public EvidenceLockerIntegrationTests(EvidenceLockerWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_factory.ResetTestState();
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,18 @@ public sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<
|
||||
|
||||
public TestTimelinePublisher TimelinePublisher => Services.GetRequiredService<TestTimelinePublisher>();
|
||||
|
||||
/// <summary>
|
||||
/// Resets all singleton test doubles to prevent accumulated state from
|
||||
/// leaking memory across test classes sharing this factory instance.
|
||||
/// Call from each test class constructor.
|
||||
/// </summary>
|
||||
public void ResetTestState()
|
||||
{
|
||||
Repository.Reset();
|
||||
ObjectStore.Reset();
|
||||
TimelinePublisher.Reset();
|
||||
}
|
||||
|
||||
private static SigningKeyMaterialOptions GenerateKeyMaterial()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
@@ -164,6 +176,12 @@ public sealed class TestTimelinePublisher : IEvidenceTimelinePublisher
|
||||
public List<string> PublishedEvents { get; } = new();
|
||||
public List<string> IncidentEvents { get; } = new();
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
PublishedEvents.Clear();
|
||||
IncidentEvents.Clear();
|
||||
}
|
||||
|
||||
public Task PublishBundleSealedAsync(
|
||||
EvidenceBundleSignature signature,
|
||||
EvidenceBundleManifest manifest,
|
||||
@@ -196,6 +214,12 @@ public sealed class TestEvidenceObjectStore : IEvidenceObjectStore
|
||||
|
||||
public void SeedExisting(string storageKey) => _preExisting.Add(storageKey);
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_objects.Clear();
|
||||
_preExisting.Clear();
|
||||
}
|
||||
|
||||
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
using var memory = new MemoryStream();
|
||||
@@ -235,6 +259,13 @@ public sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
|
||||
|
||||
public bool HoldConflict { get; set; }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_signatures.Clear();
|
||||
_bundles.Clear();
|
||||
HoldConflict = false;
|
||||
}
|
||||
|
||||
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
|
||||
{
|
||||
_bundles[(bundle.Id.Value, bundle.TenantId.Value)] = bundle;
|
||||
|
||||
@@ -38,6 +38,7 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
|
||||
public EvidenceLockerWebServiceContractTests(EvidenceLockerWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_factory.ResetTestState();
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
@@ -322,7 +323,7 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceCreate);
|
||||
|
||||
var listener = new ActivityListener
|
||||
using var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = source => source.Name.Contains("StellaOps", StringComparison.OrdinalIgnoreCase),
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
@@ -359,8 +360,6 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
|
||||
.FirstOrDefault(e => e.Contains(bundleId!, StringComparison.Ordinal));
|
||||
timelineEvent.Should().NotBeNull($"expected a timeline event containing bundleId {bundleId}");
|
||||
timelineEvent.Should().Contain(bundleId!);
|
||||
|
||||
listener.Dispose();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
|
||||
@@ -34,6 +34,7 @@ public sealed class EvidenceLockerWebServiceTests : IDisposable
|
||||
public EvidenceLockerWebServiceTests(EvidenceLockerWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_factory.ResetTestState();
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ public sealed class EvidenceReindexIntegrationTests : IDisposable
|
||||
public EvidenceReindexIntegrationTests(EvidenceLockerWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_factory.ResetTestState();
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,19 +20,18 @@ namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for export API endpoints.
|
||||
/// Uses the shared EvidenceLockerWebApplicationFactory (via Collection fixture)
|
||||
/// instead of raw WebApplicationFactory<Program> to avoid loading real
|
||||
/// infrastructure services (database, auth, background services) which causes
|
||||
/// the test process to hang and consume excessive memory.
|
||||
/// Uses a single derived WebApplicationFactory for the entire class (via IClassFixture)
|
||||
/// to avoid creating a new TestServer per test, which previously leaked memory.
|
||||
/// </summary>
|
||||
[Collection(EvidenceLockerTestCollection.Name)]
|
||||
public sealed class ExportEndpointsTests
|
||||
public sealed class ExportEndpointsTests : IClassFixture<ExportEndpointsTests.ExportTestFixture>, IDisposable
|
||||
{
|
||||
private readonly EvidenceLockerWebApplicationFactory _factory;
|
||||
private readonly ExportTestFixture _fixture;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ExportEndpointsTests(EvidenceLockerWebApplicationFactory factory)
|
||||
public ExportEndpointsTests(ExportTestFixture fixture)
|
||||
{
|
||||
_factory = factory;
|
||||
_fixture = fixture;
|
||||
_client = fixture.DerivedFactory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -52,11 +51,11 @@ public sealed class ExportEndpointsTests
|
||||
EstimatedSize = 1024
|
||||
});
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
var request = new ExportTriggerRequest();
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
@@ -79,11 +78,11 @@ public sealed class ExportEndpointsTests
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ExportJobResult { IsNotFound = true });
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
var request = new ExportTriggerRequest();
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.PostAsJsonAsync("/api/v1/bundles/nonexistent/export", request);
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/bundles/nonexistent/export", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
@@ -104,10 +103,10 @@ public sealed class ExportEndpointsTests
|
||||
CompletedAt = DateTimeOffset.Parse("2026-01-07T12:00:00Z")
|
||||
});
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
|
||||
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -133,10 +132,10 @@ public sealed class ExportEndpointsTests
|
||||
EstimatedTimeRemaining = "30s"
|
||||
});
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
|
||||
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
@@ -156,10 +155,10 @@ public sealed class ExportEndpointsTests
|
||||
.Setup(s => s.GetExportStatusAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ExportJobStatus?)null);
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent");
|
||||
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
@@ -181,10 +180,10 @@ public sealed class ExportEndpointsTests
|
||||
FileName = "evidence-bundle-123.tar.gz"
|
||||
});
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
|
||||
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -203,10 +202,10 @@ public sealed class ExportEndpointsTests
|
||||
Status = ExportJobStatusEnum.Processing
|
||||
});
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
|
||||
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
@@ -221,10 +220,10 @@ public sealed class ExportEndpointsTests
|
||||
.Setup(s => s.GetExportFileAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ExportFileResult?)null);
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent/download");
|
||||
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent/download");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
@@ -248,7 +247,7 @@ public sealed class ExportEndpointsTests
|
||||
Status = "pending"
|
||||
});
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
var request = new ExportTriggerRequest
|
||||
{
|
||||
CompressionLevel = 9,
|
||||
@@ -257,7 +256,7 @@ public sealed class ExportEndpointsTests
|
||||
};
|
||||
|
||||
// Act
|
||||
await scope.Client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
|
||||
await _client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedRequest);
|
||||
@@ -266,52 +265,76 @@ public sealed class ExportEndpointsTests
|
||||
Assert.False(capturedRequest.IncludeRekorProofs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a derived WebApplicationFactory and HttpClient so both are disposed together.
|
||||
/// Previously, WithWebHostBuilder() created a new factory per test that was never disposed,
|
||||
/// leaking TestServer instances and consuming gigabytes of memory.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Wraps a derived WebApplicationFactory and HttpClient so both are disposed together.
|
||||
/// Previously, WithWebHostBuilder() created a new factory per test that was never disposed,
|
||||
/// leaking TestServer instances and consuming gigabytes of memory.
|
||||
/// </summary>
|
||||
private sealed class MockScope : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
private readonly WebApplicationFactory<EvidenceLockerProgram> _derivedFactory;
|
||||
public HttpClient Client { get; }
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
public MockScope(WebApplicationFactory<EvidenceLockerProgram> derivedFactory)
|
||||
/// <summary>
|
||||
/// Fixture that creates a single derived WebApplicationFactory with a swappable
|
||||
/// IExportJobService mock. Tests set <see cref="CurrentMock"/> before each request
|
||||
/// instead of creating a new factory per test. This eliminates 9 TestServer instances
|
||||
/// that were previously leaking memory.
|
||||
/// </summary>
|
||||
public sealed class ExportTestFixture : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The current mock to delegate to. Set by each test before making requests.
|
||||
/// </summary>
|
||||
public IExportJobService CurrentMock { get; set; } = new Mock<IExportJobService>().Object;
|
||||
|
||||
public WebApplicationFactory<EvidenceLockerProgram> DerivedFactory { get; }
|
||||
|
||||
public ExportTestFixture()
|
||||
{
|
||||
_derivedFactory = derivedFactory;
|
||||
Client = derivedFactory.CreateClient();
|
||||
// Create ONE derived factory whose IExportJobService delegates to CurrentMock.
|
||||
// This avoids creating a new TestServer per test.
|
||||
var baseFactory = new EvidenceLockerWebApplicationFactory();
|
||||
DerivedFactory = baseFactory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var descriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(IExportJobService));
|
||||
if (descriptor != null)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
|
||||
// Register a delegating wrapper that forwards to CurrentMock,
|
||||
// allowing each test to swap the mock without a new factory.
|
||||
services.AddSingleton<IExportJobService>(sp =>
|
||||
new DelegatingExportJobService(this));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Client.Dispose();
|
||||
_derivedFactory.Dispose();
|
||||
DerivedFactory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private MockScope CreateClientWithMock(IExportJobService mockService)
|
||||
/// <summary>
|
||||
/// Thin delegate that forwards all calls to the fixture's current mock,
|
||||
/// allowing per-test mock swapping without creating new WebApplicationFactory instances.
|
||||
/// </summary>
|
||||
private sealed class DelegatingExportJobService : IExportJobService
|
||||
{
|
||||
var derivedFactory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove existing registration
|
||||
var descriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(IExportJobService));
|
||||
if (descriptor != null)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
private readonly ExportTestFixture _fixture;
|
||||
|
||||
// Add mock
|
||||
services.AddSingleton(mockService);
|
||||
});
|
||||
});
|
||||
return new MockScope(derivedFactory);
|
||||
public DelegatingExportJobService(ExportTestFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public Task<ExportJobResult> CreateExportJobAsync(string bundleId, ExportTriggerRequest request, CancellationToken cancellationToken)
|
||||
=> _fixture.CurrentMock.CreateExportJobAsync(bundleId, request, cancellationToken);
|
||||
|
||||
public Task<ExportJobStatus?> GetExportStatusAsync(string bundleId, string exportId, CancellationToken cancellationToken)
|
||||
=> _fixture.CurrentMock.GetExportStatusAsync(bundleId, exportId, cancellationToken);
|
||||
|
||||
public Task<ExportFileResult?> GetExportFileAsync(string bundleId, string exportId, CancellationToken cancellationToken)
|
||||
=> _fixture.CurrentMock.GetExportFileAsync(bundleId, exportId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +56,16 @@ public sealed class PostgreSqlFixture : IAsyncLifetime
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// On Windows, try to open the Docker named pipe with a short timeout.
|
||||
// File.Exists does not work for named pipes.
|
||||
using var pipe = new System.IO.Pipes.NamedPipeClientStream(".", "docker_engine", System.IO.Pipes.PipeDirection.InOut, System.IO.Pipes.PipeOptions.None);
|
||||
pipe.Connect(2000); // 2 second timeout
|
||||
return true;
|
||||
// Check if the Docker daemon is actually running by looking for its process.
|
||||
// NamedPipeClientStream.Connect() hangs indefinitely when Docker Desktop is
|
||||
// installed but not running (the pipe exists but nobody reads from it).
|
||||
// Testcontainers' own Docker client also hangs in this scenario.
|
||||
// Checking for a running process is instant and avoids the hang entirely.
|
||||
var dockerProcesses = System.Diagnostics.Process.GetProcessesByName("com.docker.backend");
|
||||
if (dockerProcesses.Length == 0)
|
||||
dockerProcesses = System.Diagnostics.Process.GetProcessesByName("dockerd");
|
||||
foreach (var p in dockerProcesses) p.Dispose();
|
||||
return dockerProcesses.Length > 0;
|
||||
}
|
||||
|
||||
// On Linux/macOS, check for the Docker socket
|
||||
|
||||
@@ -1,174 +1,175 @@
|
||||
/**
|
||||
* App Component Styles
|
||||
* Migrated to design system tokens
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-family-base);
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.quickstart-banner {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
font-size: var(--font-size-sm);
|
||||
border-bottom: 1px solid var(--color-status-warning-border);
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
background: var(--color-header-bg);
|
||||
color: var(--color-header-text);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
// Navigation takes remaining space
|
||||
app-navigation-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-left: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.02em;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.app-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
|
||||
.app-tenant {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--color-header-text-muted);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app-fresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-0-5) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.7rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.03em;
|
||||
background-color: var(--color-fresh-active-bg);
|
||||
color: var(--color-fresh-active-text);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.app-fresh--stale {
|
||||
background-color: var(--color-fresh-stale-bg);
|
||||
color: var(--color-fresh-stale-text);
|
||||
}
|
||||
}
|
||||
|
||||
&__signin {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
color: var(--color-surface-inverse);
|
||||
background-color: rgba(248, 250, 252, 0.9);
|
||||
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-accent-yellow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
padding: var(--space-6) var(--space-6) var(--space-8);
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
// Breadcrumb styling
|
||||
app-breadcrumb {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
// Page container with transition animations
|
||||
.page-container {
|
||||
animation: page-fade-in var(--motion-duration-slow) var(--motion-ease-spring);
|
||||
}
|
||||
|
||||
@keyframes page-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Respect reduced motion preference
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-container {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.app-auth__signin {
|
||||
transition: none;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* App Component Styles
|
||||
* Migrated to design system tokens
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-family-base);
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.quickstart-banner {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
font-size: var(--font-size-sm);
|
||||
border-bottom: 1px solid var(--color-status-warning-border);
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
background: var(--color-header-bg);
|
||||
color: var(--color-header-text);
|
||||
box-shadow: var(--shadow-md);
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
|
||||
// Navigation takes remaining space
|
||||
app-navigation-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-left: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.02em;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.app-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
|
||||
.app-tenant {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--color-header-text-muted);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app-fresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-0-5) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.7rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.03em;
|
||||
background-color: var(--color-fresh-active-bg);
|
||||
color: var(--color-fresh-active-text);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.app-fresh--stale {
|
||||
background-color: var(--color-fresh-stale-bg);
|
||||
color: var(--color-fresh-stale-text);
|
||||
}
|
||||
}
|
||||
|
||||
&__signin {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
color: var(--color-surface-inverse);
|
||||
background-color: rgba(248, 250, 252, 0.9);
|
||||
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-accent-yellow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
padding: var(--space-6) var(--space-6) var(--space-8);
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
// Breadcrumb styling
|
||||
app-breadcrumb {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
// Page container with transition animations
|
||||
.page-container {
|
||||
animation: page-fade-in var(--motion-duration-slow) var(--motion-ease-spring);
|
||||
}
|
||||
|
||||
@keyframes page-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Respect reduced motion preference
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-container {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.app-auth__signin {
|
||||
transition: none;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { AuthorityAuthService } from './core/auth/authority-auth.service';
|
||||
import { AuthSessionStore } from './core/auth/auth-session.store';
|
||||
import { AUTH_SERVICE, AuthService } from './core/auth';
|
||||
import { ConsoleSessionStore } from './core/console/console-session.store';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
import { PolicyPackStore } from './features/policy-studio/services/policy-pack.store';
|
||||
|
||||
class AuthorityAuthServiceStub {
|
||||
beginLogin = jasmine.createSpy('beginLogin');
|
||||
logout = jasmine.createSpy('logout');
|
||||
}
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent, RouterTestingModule, HttpClientTestingModule],
|
||||
providers: [
|
||||
AuthSessionStore,
|
||||
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
|
||||
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
|
||||
ConsoleSessionStore,
|
||||
{ provide: AppConfigService, useValue: { config: { quickstartMode: false, apiBaseUrls: { authority: '', policy: '' } } } },
|
||||
{
|
||||
provide: PolicyPackStore,
|
||||
useValue: {
|
||||
getPacks: () =>
|
||||
of([
|
||||
{ id: 'pack-1', name: 'Pack One', description: '', version: '1.0', status: 'active', createdAt: '', modifiedAt: '', createdBy: '', modifiedBy: '', tags: [] },
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('creates the root component', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a router outlet for child routes', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('router-outlet')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { AuthorityAuthService } from './core/auth/authority-auth.service';
|
||||
import { AuthSessionStore } from './core/auth/auth-session.store';
|
||||
import { AUTH_SERVICE, AuthService } from './core/auth';
|
||||
import { ConsoleSessionStore } from './core/console/console-session.store';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
import { PolicyPackStore } from './features/policy-studio/services/policy-pack.store';
|
||||
|
||||
class AuthorityAuthServiceStub {
|
||||
beginLogin = jasmine.createSpy('beginLogin');
|
||||
logout = jasmine.createSpy('logout');
|
||||
}
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent, RouterTestingModule, HttpClientTestingModule],
|
||||
providers: [
|
||||
AuthSessionStore,
|
||||
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
|
||||
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
|
||||
ConsoleSessionStore,
|
||||
{ provide: AppConfigService, useValue: { config: { quickstartMode: false, apiBaseUrls: { authority: '', policy: '' } } } },
|
||||
{
|
||||
provide: PolicyPackStore,
|
||||
useValue: {
|
||||
getPacks: () =>
|
||||
of([
|
||||
{ id: 'pack-1', name: 'Pack One', description: '', version: '1.0', status: 'active', createdAt: '', modifiedAt: '', createdBy: '', modifiedBy: '', tags: [] },
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('creates the root component', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a router outlet for child routes', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('router-outlet')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,382 +1,382 @@
|
||||
/**
|
||||
* AOC (Authorization of Containers) models for dashboard metrics.
|
||||
*/
|
||||
|
||||
export interface AocMetrics {
|
||||
/** Pass/fail counts for the time window */
|
||||
passCount: number;
|
||||
failCount: number;
|
||||
totalCount: number;
|
||||
passRate: number;
|
||||
|
||||
/** Recent violations grouped by code */
|
||||
recentViolations: AocViolationSummary[];
|
||||
|
||||
/** Ingest throughput metrics */
|
||||
ingestThroughput: AocIngestThroughput;
|
||||
|
||||
/** Time window for these metrics */
|
||||
timeWindow: {
|
||||
start: string;
|
||||
end: string;
|
||||
durationMinutes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AocViolationSummary {
|
||||
code: string;
|
||||
description: string;
|
||||
count: number;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface AocIngestThroughput {
|
||||
/** Documents processed per minute */
|
||||
docsPerMinute: number;
|
||||
/** Average processing latency in milliseconds */
|
||||
avgLatencyMs: number;
|
||||
/** P95 latency in milliseconds */
|
||||
p95LatencyMs: number;
|
||||
/** Current queue depth */
|
||||
queueDepth: number;
|
||||
/** Error rate percentage */
|
||||
errorRate: number;
|
||||
}
|
||||
|
||||
export interface AocVerificationRequest {
|
||||
tenantId: string;
|
||||
since?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AocVerificationResult {
|
||||
verificationId: string;
|
||||
status: 'passed' | 'failed' | 'partial';
|
||||
checkedCount: number;
|
||||
passedCount: number;
|
||||
failedCount: number;
|
||||
violations: AocViolationDetail[];
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
export interface AocViolationDetail {
|
||||
documentId: string;
|
||||
violationCode: string;
|
||||
field?: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
provenance?: AocProvenance;
|
||||
}
|
||||
|
||||
export interface AocProvenance {
|
||||
sourceId: string;
|
||||
ingestedAt: string;
|
||||
digest: string;
|
||||
sourceType?: 'registry' | 'git' | 'upload' | 'api';
|
||||
sourceUrl?: string;
|
||||
submitter?: string;
|
||||
}
|
||||
|
||||
export interface AocViolationGroup {
|
||||
code: string;
|
||||
description: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
violations: AocViolationDetail[];
|
||||
affectedDocuments: number;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
export interface AocDocumentView {
|
||||
documentId: string;
|
||||
documentType: string;
|
||||
violations: AocViolationDetail[];
|
||||
provenance: AocProvenance;
|
||||
rawContent?: Record<string, unknown>;
|
||||
highlightedFields: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Violation severity levels.
|
||||
*/
|
||||
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/**
|
||||
* AOC source configuration.
|
||||
*/
|
||||
export interface AocSource {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
name: string;
|
||||
type: 'registry' | 'git' | 'upload' | 'api';
|
||||
url?: string;
|
||||
enabled: boolean;
|
||||
lastSync?: string;
|
||||
status: 'healthy' | 'degraded' | 'offline';
|
||||
}
|
||||
|
||||
/**
|
||||
* Violation code definition.
|
||||
*/
|
||||
export interface AocViolationCode {
|
||||
code: string;
|
||||
description: string;
|
||||
severity: ViolationSeverity;
|
||||
category: string;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard summary data.
|
||||
*/
|
||||
export interface AocDashboardSummary {
|
||||
/** Pass/fail metrics */
|
||||
passFail: {
|
||||
passCount: number;
|
||||
failCount: number;
|
||||
totalCount: number;
|
||||
passRate: number;
|
||||
trend?: 'improving' | 'degrading' | 'stable';
|
||||
history?: { timestamp: string; value: number }[];
|
||||
};
|
||||
/** Recent violations */
|
||||
recentViolations: AocViolationSummary[];
|
||||
/** Ingest throughput */
|
||||
throughput: AocIngestThroughput;
|
||||
/** Throughput by tenant */
|
||||
throughputByTenant: TenantThroughput[];
|
||||
/** Configured sources */
|
||||
sources: AocSource[];
|
||||
/** Time window */
|
||||
timeWindow: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant-level throughput metrics.
|
||||
*/
|
||||
export interface TenantThroughput {
|
||||
tenantId: string;
|
||||
tenantName?: string;
|
||||
documentsIngested: number;
|
||||
bytesIngested: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field that caused a violation.
|
||||
*/
|
||||
export interface OffendingField {
|
||||
path: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
expectedValue?: string;
|
||||
actualValue?: string;
|
||||
reason: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed violation record for display.
|
||||
*/
|
||||
export interface ViolationDetail {
|
||||
violationId: string;
|
||||
documentType: string;
|
||||
documentId: string;
|
||||
severity: ViolationSeverity;
|
||||
detectedAt: string;
|
||||
offendingFields: OffendingField[];
|
||||
provenance: ViolationProvenance;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance metadata for a violation.
|
||||
*/
|
||||
export interface ViolationProvenance {
|
||||
sourceType: string;
|
||||
sourceUri: string;
|
||||
ingestedAt: string;
|
||||
ingestedBy: string;
|
||||
buildId?: string;
|
||||
commitSha?: string;
|
||||
pipelineUrl?: string;
|
||||
}
|
||||
|
||||
// Type aliases for backwards compatibility
|
||||
export type IngestThroughput = AocIngestThroughput;
|
||||
export type VerificationRequest = AocVerificationRequest;
|
||||
|
||||
// =============================================================================
|
||||
// Sprint 027: AOC Compliance Dashboard Extensions
|
||||
// =============================================================================
|
||||
|
||||
// Guard violation types for AOC ingestion
|
||||
export type GuardViolationReason =
|
||||
| 'schema_invalid'
|
||||
| 'untrusted_source'
|
||||
| 'duplicate'
|
||||
| 'malformed_timestamp'
|
||||
| 'missing_required_fields'
|
||||
| 'hash_mismatch'
|
||||
| 'unknown';
|
||||
|
||||
export interface GuardViolation {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
source: string;
|
||||
reason: GuardViolationReason;
|
||||
message: string;
|
||||
payloadSample?: string;
|
||||
module: 'concelier' | 'excititor';
|
||||
canRetry: boolean;
|
||||
}
|
||||
|
||||
// Ingestion flow metrics
|
||||
export interface IngestionSourceMetrics {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
module: 'concelier' | 'excititor';
|
||||
throughputPerMinute: number;
|
||||
latencyP50Ms: number;
|
||||
latencyP95Ms: number;
|
||||
latencyP99Ms: number;
|
||||
errorRate: number;
|
||||
backlogDepth: number;
|
||||
lastIngestionAt: string;
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
}
|
||||
|
||||
export interface IngestionFlowSummary {
|
||||
sources: IngestionSourceMetrics[];
|
||||
totalThroughput: number;
|
||||
avgLatencyP95Ms: number;
|
||||
overallErrorRate: number;
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
// Provenance chain types
|
||||
export type ProvenanceStepType =
|
||||
| 'source'
|
||||
| 'advisory_raw'
|
||||
| 'normalized'
|
||||
| 'vex_decision'
|
||||
| 'finding'
|
||||
| 'policy_verdict'
|
||||
| 'attestation';
|
||||
|
||||
export interface ProvenanceStep {
|
||||
stepType: ProvenanceStepType;
|
||||
label: string;
|
||||
timestamp: string;
|
||||
hash?: string;
|
||||
linkedFromHash?: string;
|
||||
status: 'valid' | 'warning' | 'error' | 'pending';
|
||||
details: Record<string, unknown>;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface ProvenanceChain {
|
||||
inputType: 'advisory_id' | 'finding_id' | 'cve_id';
|
||||
inputValue: string;
|
||||
steps: ProvenanceStep[];
|
||||
isComplete: boolean;
|
||||
validationErrors: string[];
|
||||
validatedAt: string;
|
||||
}
|
||||
|
||||
// AOC compliance metrics
|
||||
export interface AocComplianceMetrics {
|
||||
guardViolations: {
|
||||
count: number;
|
||||
percentage: number;
|
||||
byReason: Record<string, number>;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
provenanceCompleteness: {
|
||||
percentage: number;
|
||||
recordsWithValidHash: number;
|
||||
totalRecords: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
deduplicationRate: {
|
||||
percentage: number;
|
||||
duplicatesDetected: number;
|
||||
totalIngested: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
ingestionLatency: {
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
p99Ms: number;
|
||||
meetsSla: boolean;
|
||||
slaTargetP95Ms: number;
|
||||
};
|
||||
supersedesDepth: {
|
||||
maxDepth: number;
|
||||
avgDepth: number;
|
||||
distribution: { depth: number; count: number }[];
|
||||
};
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
// Compliance report
|
||||
export type ComplianceReportFormat = 'csv' | 'json';
|
||||
|
||||
export interface ComplianceReportRequest {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
sources?: string[];
|
||||
format: ComplianceReportFormat;
|
||||
includeViolationDetails: boolean;
|
||||
}
|
||||
|
||||
export interface ComplianceReportSummary {
|
||||
reportId: string;
|
||||
generatedAt: string;
|
||||
period: { start: string; end: string };
|
||||
guardViolationSummary: {
|
||||
total: number;
|
||||
bySource: Record<string, number>;
|
||||
byReason: Record<string, number>;
|
||||
};
|
||||
provenanceCompliance: {
|
||||
percentage: number;
|
||||
bySource: Record<string, number>;
|
||||
};
|
||||
deduplicationMetrics: {
|
||||
rate: number;
|
||||
bySource: Record<string, number>;
|
||||
};
|
||||
latencyMetrics: {
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
p99Ms: number;
|
||||
bySource: Record<string, { p50: number; p95: number; p99: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
// API response wrappers
|
||||
export interface AocComplianceDashboardData {
|
||||
metrics: AocComplianceMetrics;
|
||||
recentViolations: GuardViolation[];
|
||||
ingestionFlow: IngestionFlowSummary;
|
||||
}
|
||||
|
||||
export interface GuardViolationsPagedResponse {
|
||||
items: GuardViolation[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
// Filter options
|
||||
export interface AocDashboardFilters {
|
||||
dateRange: { start: string; end: string };
|
||||
sources?: string[];
|
||||
modules?: ('concelier' | 'excititor')[];
|
||||
violationReasons?: GuardViolationReason[];
|
||||
}
|
||||
/**
|
||||
* AOC (Authorization of Containers) models for dashboard metrics.
|
||||
*/
|
||||
|
||||
export interface AocMetrics {
|
||||
/** Pass/fail counts for the time window */
|
||||
passCount: number;
|
||||
failCount: number;
|
||||
totalCount: number;
|
||||
passRate: number;
|
||||
|
||||
/** Recent violations grouped by code */
|
||||
recentViolations: AocViolationSummary[];
|
||||
|
||||
/** Ingest throughput metrics */
|
||||
ingestThroughput: AocIngestThroughput;
|
||||
|
||||
/** Time window for these metrics */
|
||||
timeWindow: {
|
||||
start: string;
|
||||
end: string;
|
||||
durationMinutes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AocViolationSummary {
|
||||
code: string;
|
||||
description: string;
|
||||
count: number;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface AocIngestThroughput {
|
||||
/** Documents processed per minute */
|
||||
docsPerMinute: number;
|
||||
/** Average processing latency in milliseconds */
|
||||
avgLatencyMs: number;
|
||||
/** P95 latency in milliseconds */
|
||||
p95LatencyMs: number;
|
||||
/** Current queue depth */
|
||||
queueDepth: number;
|
||||
/** Error rate percentage */
|
||||
errorRate: number;
|
||||
}
|
||||
|
||||
export interface AocVerificationRequest {
|
||||
tenantId: string;
|
||||
since?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AocVerificationResult {
|
||||
verificationId: string;
|
||||
status: 'passed' | 'failed' | 'partial';
|
||||
checkedCount: number;
|
||||
passedCount: number;
|
||||
failedCount: number;
|
||||
violations: AocViolationDetail[];
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
export interface AocViolationDetail {
|
||||
documentId: string;
|
||||
violationCode: string;
|
||||
field?: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
provenance?: AocProvenance;
|
||||
}
|
||||
|
||||
export interface AocProvenance {
|
||||
sourceId: string;
|
||||
ingestedAt: string;
|
||||
digest: string;
|
||||
sourceType?: 'registry' | 'git' | 'upload' | 'api';
|
||||
sourceUrl?: string;
|
||||
submitter?: string;
|
||||
}
|
||||
|
||||
export interface AocViolationGroup {
|
||||
code: string;
|
||||
description: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
violations: AocViolationDetail[];
|
||||
affectedDocuments: number;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
export interface AocDocumentView {
|
||||
documentId: string;
|
||||
documentType: string;
|
||||
violations: AocViolationDetail[];
|
||||
provenance: AocProvenance;
|
||||
rawContent?: Record<string, unknown>;
|
||||
highlightedFields: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Violation severity levels.
|
||||
*/
|
||||
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/**
|
||||
* AOC source configuration.
|
||||
*/
|
||||
export interface AocSource {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
name: string;
|
||||
type: 'registry' | 'git' | 'upload' | 'api';
|
||||
url?: string;
|
||||
enabled: boolean;
|
||||
lastSync?: string;
|
||||
status: 'healthy' | 'degraded' | 'offline';
|
||||
}
|
||||
|
||||
/**
|
||||
* Violation code definition.
|
||||
*/
|
||||
export interface AocViolationCode {
|
||||
code: string;
|
||||
description: string;
|
||||
severity: ViolationSeverity;
|
||||
category: string;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard summary data.
|
||||
*/
|
||||
export interface AocDashboardSummary {
|
||||
/** Pass/fail metrics */
|
||||
passFail: {
|
||||
passCount: number;
|
||||
failCount: number;
|
||||
totalCount: number;
|
||||
passRate: number;
|
||||
trend?: 'improving' | 'degrading' | 'stable';
|
||||
history?: { timestamp: string; value: number }[];
|
||||
};
|
||||
/** Recent violations */
|
||||
recentViolations: AocViolationSummary[];
|
||||
/** Ingest throughput */
|
||||
throughput: AocIngestThroughput;
|
||||
/** Throughput by tenant */
|
||||
throughputByTenant: TenantThroughput[];
|
||||
/** Configured sources */
|
||||
sources: AocSource[];
|
||||
/** Time window */
|
||||
timeWindow: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant-level throughput metrics.
|
||||
*/
|
||||
export interface TenantThroughput {
|
||||
tenantId: string;
|
||||
tenantName?: string;
|
||||
documentsIngested: number;
|
||||
bytesIngested: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field that caused a violation.
|
||||
*/
|
||||
export interface OffendingField {
|
||||
path: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
expectedValue?: string;
|
||||
actualValue?: string;
|
||||
reason: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed violation record for display.
|
||||
*/
|
||||
export interface ViolationDetail {
|
||||
violationId: string;
|
||||
documentType: string;
|
||||
documentId: string;
|
||||
severity: ViolationSeverity;
|
||||
detectedAt: string;
|
||||
offendingFields: OffendingField[];
|
||||
provenance: ViolationProvenance;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance metadata for a violation.
|
||||
*/
|
||||
export interface ViolationProvenance {
|
||||
sourceType: string;
|
||||
sourceUri: string;
|
||||
ingestedAt: string;
|
||||
ingestedBy: string;
|
||||
buildId?: string;
|
||||
commitSha?: string;
|
||||
pipelineUrl?: string;
|
||||
}
|
||||
|
||||
// Type aliases for backwards compatibility
|
||||
export type IngestThroughput = AocIngestThroughput;
|
||||
export type VerificationRequest = AocVerificationRequest;
|
||||
|
||||
// =============================================================================
|
||||
// Sprint 027: AOC Compliance Dashboard Extensions
|
||||
// =============================================================================
|
||||
|
||||
// Guard violation types for AOC ingestion
|
||||
export type GuardViolationReason =
|
||||
| 'schema_invalid'
|
||||
| 'untrusted_source'
|
||||
| 'duplicate'
|
||||
| 'malformed_timestamp'
|
||||
| 'missing_required_fields'
|
||||
| 'hash_mismatch'
|
||||
| 'unknown';
|
||||
|
||||
export interface GuardViolation {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
source: string;
|
||||
reason: GuardViolationReason;
|
||||
message: string;
|
||||
payloadSample?: string;
|
||||
module: 'concelier' | 'excititor';
|
||||
canRetry: boolean;
|
||||
}
|
||||
|
||||
// Ingestion flow metrics
|
||||
export interface IngestionSourceMetrics {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
module: 'concelier' | 'excititor';
|
||||
throughputPerMinute: number;
|
||||
latencyP50Ms: number;
|
||||
latencyP95Ms: number;
|
||||
latencyP99Ms: number;
|
||||
errorRate: number;
|
||||
backlogDepth: number;
|
||||
lastIngestionAt: string;
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
}
|
||||
|
||||
export interface IngestionFlowSummary {
|
||||
sources: IngestionSourceMetrics[];
|
||||
totalThroughput: number;
|
||||
avgLatencyP95Ms: number;
|
||||
overallErrorRate: number;
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
// Provenance chain types
|
||||
export type ProvenanceStepType =
|
||||
| 'source'
|
||||
| 'advisory_raw'
|
||||
| 'normalized'
|
||||
| 'vex_decision'
|
||||
| 'finding'
|
||||
| 'policy_verdict'
|
||||
| 'attestation';
|
||||
|
||||
export interface ProvenanceStep {
|
||||
stepType: ProvenanceStepType;
|
||||
label: string;
|
||||
timestamp: string;
|
||||
hash?: string;
|
||||
linkedFromHash?: string;
|
||||
status: 'valid' | 'warning' | 'error' | 'pending';
|
||||
details: Record<string, unknown>;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface ProvenanceChain {
|
||||
inputType: 'advisory_id' | 'finding_id' | 'cve_id';
|
||||
inputValue: string;
|
||||
steps: ProvenanceStep[];
|
||||
isComplete: boolean;
|
||||
validationErrors: string[];
|
||||
validatedAt: string;
|
||||
}
|
||||
|
||||
// AOC compliance metrics
|
||||
export interface AocComplianceMetrics {
|
||||
guardViolations: {
|
||||
count: number;
|
||||
percentage: number;
|
||||
byReason: Record<string, number>;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
provenanceCompleteness: {
|
||||
percentage: number;
|
||||
recordsWithValidHash: number;
|
||||
totalRecords: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
deduplicationRate: {
|
||||
percentage: number;
|
||||
duplicatesDetected: number;
|
||||
totalIngested: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
ingestionLatency: {
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
p99Ms: number;
|
||||
meetsSla: boolean;
|
||||
slaTargetP95Ms: number;
|
||||
};
|
||||
supersedesDepth: {
|
||||
maxDepth: number;
|
||||
avgDepth: number;
|
||||
distribution: { depth: number; count: number }[];
|
||||
};
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
// Compliance report
|
||||
export type ComplianceReportFormat = 'csv' | 'json';
|
||||
|
||||
export interface ComplianceReportRequest {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
sources?: string[];
|
||||
format: ComplianceReportFormat;
|
||||
includeViolationDetails: boolean;
|
||||
}
|
||||
|
||||
export interface ComplianceReportSummary {
|
||||
reportId: string;
|
||||
generatedAt: string;
|
||||
period: { start: string; end: string };
|
||||
guardViolationSummary: {
|
||||
total: number;
|
||||
bySource: Record<string, number>;
|
||||
byReason: Record<string, number>;
|
||||
};
|
||||
provenanceCompliance: {
|
||||
percentage: number;
|
||||
bySource: Record<string, number>;
|
||||
};
|
||||
deduplicationMetrics: {
|
||||
rate: number;
|
||||
bySource: Record<string, number>;
|
||||
};
|
||||
latencyMetrics: {
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
p99Ms: number;
|
||||
bySource: Record<string, { p50: number; p95: number; p99: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
// API response wrappers
|
||||
export interface AocComplianceDashboardData {
|
||||
metrics: AocComplianceMetrics;
|
||||
recentViolations: GuardViolation[];
|
||||
ingestionFlow: IngestionFlowSummary;
|
||||
}
|
||||
|
||||
export interface GuardViolationsPagedResponse {
|
||||
items: GuardViolation[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
// Filter options
|
||||
export interface AocDashboardFilters {
|
||||
dateRange: { start: string; end: string };
|
||||
sources?: string[];
|
||||
modules?: ('concelier' | 'excititor')[];
|
||||
violationReasons?: GuardViolationReason[];
|
||||
}
|
||||
|
||||
@@ -1,113 +1,113 @@
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
|
||||
export interface AuthorityTenantViewDto {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly status: string;
|
||||
readonly isolationMode: string;
|
||||
readonly defaultRoles: readonly string[];
|
||||
}
|
||||
|
||||
export interface TenantCatalogResponseDto {
|
||||
readonly tenants: readonly AuthorityTenantViewDto[];
|
||||
}
|
||||
|
||||
export interface ConsoleProfileDto {
|
||||
readonly subjectId: string | null;
|
||||
readonly username: string | null;
|
||||
readonly displayName: string | null;
|
||||
readonly tenant: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationMethods: readonly string[];
|
||||
readonly issuedAt: string | null;
|
||||
readonly authenticationTime: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleTokenIntrospectionDto {
|
||||
readonly active: boolean;
|
||||
readonly tenant: string;
|
||||
readonly subject: string | null;
|
||||
readonly clientId: string | null;
|
||||
readonly tokenId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly issuedAt: string | null;
|
||||
readonly authenticationTime: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface AuthorityConsoleApi {
|
||||
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto>;
|
||||
getProfile(tenantId?: string): Observable<ConsoleProfileDto>;
|
||||
introspectToken(
|
||||
tenantId?: string
|
||||
): Observable<ConsoleTokenIntrospectionDto>;
|
||||
}
|
||||
|
||||
export const AUTHORITY_CONSOLE_API = new InjectionToken<AuthorityConsoleApi>(
|
||||
'AUTHORITY_CONSOLE_API'
|
||||
);
|
||||
|
||||
export const AUTHORITY_CONSOLE_API_BASE_URL = new InjectionToken<string>(
|
||||
'AUTHORITY_CONSOLE_API_BASE_URL'
|
||||
);
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthorityConsoleApiHttpClient implements AuthorityConsoleApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
@Inject(AUTHORITY_CONSOLE_API_BASE_URL) private readonly baseUrl: string,
|
||||
private readonly authSession: AuthSessionStore
|
||||
) {}
|
||||
|
||||
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto> {
|
||||
return this.http.get<TenantCatalogResponseDto>(`${this.baseUrl}/tenants`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
getProfile(tenantId?: string): Observable<ConsoleProfileDto> {
|
||||
return this.http.get<ConsoleProfileDto>(`${this.baseUrl}/profile`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
introspectToken(
|
||||
tenantId?: string
|
||||
): Observable<ConsoleTokenIntrospectionDto> {
|
||||
return this.http.post<ConsoleTokenIntrospectionDto>(
|
||||
`${this.baseUrl}/token/introspect`,
|
||||
{},
|
||||
{
|
||||
headers: this.buildHeaders(tenantId),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(tenantOverride?: string): HttpHeaders {
|
||||
const tenantId =
|
||||
(tenantOverride && tenantOverride.trim()) ||
|
||||
this.authSession.getActiveTenantId();
|
||||
if (!tenantId) {
|
||||
throw new Error(
|
||||
'AuthorityConsoleApiHttpClient requires an active tenant identifier.'
|
||||
);
|
||||
}
|
||||
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
|
||||
export interface AuthorityTenantViewDto {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly status: string;
|
||||
readonly isolationMode: string;
|
||||
readonly defaultRoles: readonly string[];
|
||||
}
|
||||
|
||||
export interface TenantCatalogResponseDto {
|
||||
readonly tenants: readonly AuthorityTenantViewDto[];
|
||||
}
|
||||
|
||||
export interface ConsoleProfileDto {
|
||||
readonly subjectId: string | null;
|
||||
readonly username: string | null;
|
||||
readonly displayName: string | null;
|
||||
readonly tenant: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationMethods: readonly string[];
|
||||
readonly issuedAt: string | null;
|
||||
readonly authenticationTime: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleTokenIntrospectionDto {
|
||||
readonly active: boolean;
|
||||
readonly tenant: string;
|
||||
readonly subject: string | null;
|
||||
readonly clientId: string | null;
|
||||
readonly tokenId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly issuedAt: string | null;
|
||||
readonly authenticationTime: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface AuthorityConsoleApi {
|
||||
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto>;
|
||||
getProfile(tenantId?: string): Observable<ConsoleProfileDto>;
|
||||
introspectToken(
|
||||
tenantId?: string
|
||||
): Observable<ConsoleTokenIntrospectionDto>;
|
||||
}
|
||||
|
||||
export const AUTHORITY_CONSOLE_API = new InjectionToken<AuthorityConsoleApi>(
|
||||
'AUTHORITY_CONSOLE_API'
|
||||
);
|
||||
|
||||
export const AUTHORITY_CONSOLE_API_BASE_URL = new InjectionToken<string>(
|
||||
'AUTHORITY_CONSOLE_API_BASE_URL'
|
||||
);
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthorityConsoleApiHttpClient implements AuthorityConsoleApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
@Inject(AUTHORITY_CONSOLE_API_BASE_URL) private readonly baseUrl: string,
|
||||
private readonly authSession: AuthSessionStore
|
||||
) {}
|
||||
|
||||
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto> {
|
||||
return this.http.get<TenantCatalogResponseDto>(`${this.baseUrl}/tenants`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
getProfile(tenantId?: string): Observable<ConsoleProfileDto> {
|
||||
return this.http.get<ConsoleProfileDto>(`${this.baseUrl}/profile`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
introspectToken(
|
||||
tenantId?: string
|
||||
): Observable<ConsoleTokenIntrospectionDto> {
|
||||
return this.http.post<ConsoleTokenIntrospectionDto>(
|
||||
`${this.baseUrl}/token/introspect`,
|
||||
{},
|
||||
{
|
||||
headers: this.buildHeaders(tenantId),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(tenantOverride?: string): HttpHeaders {
|
||||
const tenantId =
|
||||
(tenantOverride && tenantOverride.trim()) ||
|
||||
this.authSession.getActiveTenantId();
|
||||
if (!tenantId) {
|
||||
throw new Error(
|
||||
'AuthorityConsoleApiHttpClient requires an active tenant identifier.'
|
||||
);
|
||||
}
|
||||
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
Injectable,
|
||||
InjectionToken,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface TrivyDbSettingsDto {
|
||||
publishFull: boolean;
|
||||
publishDelta: boolean;
|
||||
includeFull: boolean;
|
||||
includeDelta: boolean;
|
||||
}
|
||||
|
||||
export interface TrivyDbRunResponseDto {
|
||||
exportId: string;
|
||||
triggeredAt: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export const CONCELIER_EXPORTER_API_BASE_URL = new InjectionToken<string>(
|
||||
'CONCELIER_EXPORTER_API_BASE_URL'
|
||||
);
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConcelierExporterClient {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = inject(CONCELIER_EXPORTER_API_BASE_URL);
|
||||
|
||||
getTrivyDbSettings(): Observable<TrivyDbSettingsDto> {
|
||||
return this.http.get<TrivyDbSettingsDto>(`${this.baseUrl}/settings`);
|
||||
}
|
||||
|
||||
updateTrivyDbSettings(
|
||||
settings: TrivyDbSettingsDto
|
||||
): Observable<TrivyDbSettingsDto> {
|
||||
return this.http.put<TrivyDbSettingsDto>(`${this.baseUrl}/settings`, settings);
|
||||
}
|
||||
|
||||
runTrivyDbExport(
|
||||
settings: TrivyDbSettingsDto
|
||||
): Observable<TrivyDbRunResponseDto> {
|
||||
return this.http.post<TrivyDbRunResponseDto>(`${this.baseUrl}/run`, {
|
||||
trigger: 'ui',
|
||||
parameters: settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
Injectable,
|
||||
InjectionToken,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface TrivyDbSettingsDto {
|
||||
publishFull: boolean;
|
||||
publishDelta: boolean;
|
||||
includeFull: boolean;
|
||||
includeDelta: boolean;
|
||||
}
|
||||
|
||||
export interface TrivyDbRunResponseDto {
|
||||
exportId: string;
|
||||
triggeredAt: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export const CONCELIER_EXPORTER_API_BASE_URL = new InjectionToken<string>(
|
||||
'CONCELIER_EXPORTER_API_BASE_URL'
|
||||
);
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConcelierExporterClient {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = inject(CONCELIER_EXPORTER_API_BASE_URL);
|
||||
|
||||
getTrivyDbSettings(): Observable<TrivyDbSettingsDto> {
|
||||
return this.http.get<TrivyDbSettingsDto>(`${this.baseUrl}/settings`);
|
||||
}
|
||||
|
||||
updateTrivyDbSettings(
|
||||
settings: TrivyDbSettingsDto
|
||||
): Observable<TrivyDbSettingsDto> {
|
||||
return this.http.put<TrivyDbSettingsDto>(`${this.baseUrl}/settings`, settings);
|
||||
}
|
||||
|
||||
runTrivyDbExport(
|
||||
settings: TrivyDbSettingsDto
|
||||
): Observable<TrivyDbRunResponseDto> {
|
||||
return this.http.post<TrivyDbRunResponseDto>(`${this.baseUrl}/run`, {
|
||||
trigger: 'ui',
|
||||
parameters: settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,77 @@
|
||||
/**
|
||||
* Determinism verification models for SBOM scan details.
|
||||
*/
|
||||
|
||||
export interface DeterminismStatus {
|
||||
/** Overall determinism status */
|
||||
status: 'verified' | 'warning' | 'failed' | 'unknown';
|
||||
|
||||
/** Merkle root from _composition.json */
|
||||
merkleRoot: string | null;
|
||||
|
||||
/** Whether Merkle root matches computed hash */
|
||||
merkleConsistent: boolean;
|
||||
|
||||
/** Fragment hashes with verification status */
|
||||
fragments: DeterminismFragment[];
|
||||
|
||||
/** Composition metadata */
|
||||
composition: CompositionMeta | null;
|
||||
|
||||
/** Timestamp of verification */
|
||||
verifiedAt: string;
|
||||
|
||||
/** Any issues found */
|
||||
issues: DeterminismIssue[];
|
||||
}
|
||||
|
||||
export interface DeterminismFragment {
|
||||
/** Fragment identifier (e.g., layer digest) */
|
||||
id: string;
|
||||
|
||||
/** Fragment type */
|
||||
type: 'layer' | 'metadata' | 'attestation' | 'sbom';
|
||||
|
||||
/** Expected hash from composition */
|
||||
expectedHash: string;
|
||||
|
||||
/** Computed hash */
|
||||
computedHash: string;
|
||||
|
||||
/** Whether hashes match */
|
||||
matches: boolean;
|
||||
|
||||
/** Size in bytes */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface CompositionMeta {
|
||||
/** Composition schema version */
|
||||
schemaVersion: string;
|
||||
|
||||
/** Scanner version that produced this */
|
||||
scannerVersion: string;
|
||||
|
||||
/** Build timestamp */
|
||||
buildTimestamp: string;
|
||||
|
||||
/** Total fragments */
|
||||
fragmentCount: number;
|
||||
|
||||
/** Composition file hash */
|
||||
compositionHash: string;
|
||||
}
|
||||
|
||||
export interface DeterminismIssue {
|
||||
/** Issue severity */
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
|
||||
/** Issue code */
|
||||
code: string;
|
||||
|
||||
/** Human-readable message */
|
||||
message: string;
|
||||
|
||||
/** Affected fragment ID if applicable */
|
||||
fragmentId?: string;
|
||||
}
|
||||
/**
|
||||
* Determinism verification models for SBOM scan details.
|
||||
*/
|
||||
|
||||
export interface DeterminismStatus {
|
||||
/** Overall determinism status */
|
||||
status: 'verified' | 'warning' | 'failed' | 'unknown';
|
||||
|
||||
/** Merkle root from _composition.json */
|
||||
merkleRoot: string | null;
|
||||
|
||||
/** Whether Merkle root matches computed hash */
|
||||
merkleConsistent: boolean;
|
||||
|
||||
/** Fragment hashes with verification status */
|
||||
fragments: DeterminismFragment[];
|
||||
|
||||
/** Composition metadata */
|
||||
composition: CompositionMeta | null;
|
||||
|
||||
/** Timestamp of verification */
|
||||
verifiedAt: string;
|
||||
|
||||
/** Any issues found */
|
||||
issues: DeterminismIssue[];
|
||||
}
|
||||
|
||||
export interface DeterminismFragment {
|
||||
/** Fragment identifier (e.g., layer digest) */
|
||||
id: string;
|
||||
|
||||
/** Fragment type */
|
||||
type: 'layer' | 'metadata' | 'attestation' | 'sbom';
|
||||
|
||||
/** Expected hash from composition */
|
||||
expectedHash: string;
|
||||
|
||||
/** Computed hash */
|
||||
computedHash: string;
|
||||
|
||||
/** Whether hashes match */
|
||||
matches: boolean;
|
||||
|
||||
/** Size in bytes */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface CompositionMeta {
|
||||
/** Composition schema version */
|
||||
schemaVersion: string;
|
||||
|
||||
/** Scanner version that produced this */
|
||||
scannerVersion: string;
|
||||
|
||||
/** Build timestamp */
|
||||
buildTimestamp: string;
|
||||
|
||||
/** Total fragments */
|
||||
fragmentCount: number;
|
||||
|
||||
/** Composition file hash */
|
||||
compositionHash: string;
|
||||
}
|
||||
|
||||
export interface DeterminismIssue {
|
||||
/** Issue severity */
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
|
||||
/** Issue code */
|
||||
code: string;
|
||||
|
||||
/** Human-readable message */
|
||||
message: string;
|
||||
|
||||
/** Affected fragment ID if applicable */
|
||||
fragmentId?: string;
|
||||
}
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
/**
|
||||
* Entropy analysis models for image security visualization.
|
||||
*/
|
||||
|
||||
export interface EntropyAnalysis {
|
||||
/** Image digest */
|
||||
imageDigest: string;
|
||||
|
||||
/** Overall entropy score (0-10, higher = more suspicious) */
|
||||
overallScore: number;
|
||||
|
||||
/** Risk level classification */
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
/** Per-layer entropy breakdown */
|
||||
layers: LayerEntropy[];
|
||||
|
||||
/** Files with high entropy (potential secrets/malware) */
|
||||
highEntropyFiles: HighEntropyFile[];
|
||||
|
||||
/** Detector hints for suspicious patterns */
|
||||
detectorHints: DetectorHint[];
|
||||
|
||||
/** Analysis timestamp */
|
||||
analyzedAt: string;
|
||||
|
||||
/** Link to raw entropy report */
|
||||
reportUrl: string;
|
||||
}
|
||||
|
||||
export interface LayerEntropy {
|
||||
/** Layer digest */
|
||||
digest: string;
|
||||
|
||||
/** Layer command (e.g., COPY, RUN) */
|
||||
command: string;
|
||||
|
||||
/** Layer size in bytes */
|
||||
size: number;
|
||||
|
||||
/** Average entropy for this layer (0-8 bits) */
|
||||
avgEntropy: number;
|
||||
|
||||
/** Percentage of opaque bytes (high entropy) */
|
||||
opaqueByteRatio: number;
|
||||
|
||||
/** Number of high-entropy files */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Risk contribution to overall score */
|
||||
riskContribution: number;
|
||||
}
|
||||
|
||||
export interface HighEntropyFile {
|
||||
/** File path in container */
|
||||
path: string;
|
||||
|
||||
/** Layer where file was added */
|
||||
layerDigest: string;
|
||||
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
|
||||
/** File entropy (0-8 bits) */
|
||||
entropy: number;
|
||||
|
||||
/** Classification */
|
||||
classification: 'encrypted' | 'compressed' | 'binary' | 'suspicious' | 'unknown';
|
||||
|
||||
/** Why this file is flagged */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DetectorHint {
|
||||
/** Hint ID */
|
||||
id: string;
|
||||
|
||||
/** Severity */
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/** Pattern type */
|
||||
type: 'credential' | 'key' | 'token' | 'obfuscated' | 'packed' | 'crypto';
|
||||
|
||||
/** Human-readable description */
|
||||
description: string;
|
||||
|
||||
/** Affected file paths */
|
||||
affectedPaths: string[];
|
||||
|
||||
/** Confidence (0-100) */
|
||||
confidence: number;
|
||||
|
||||
/** Remediation suggestion */
|
||||
remediation: string;
|
||||
}
|
||||
/**
|
||||
* Entropy analysis models for image security visualization.
|
||||
*/
|
||||
|
||||
export interface EntropyAnalysis {
|
||||
/** Image digest */
|
||||
imageDigest: string;
|
||||
|
||||
/** Overall entropy score (0-10, higher = more suspicious) */
|
||||
overallScore: number;
|
||||
|
||||
/** Risk level classification */
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
/** Per-layer entropy breakdown */
|
||||
layers: LayerEntropy[];
|
||||
|
||||
/** Files with high entropy (potential secrets/malware) */
|
||||
highEntropyFiles: HighEntropyFile[];
|
||||
|
||||
/** Detector hints for suspicious patterns */
|
||||
detectorHints: DetectorHint[];
|
||||
|
||||
/** Analysis timestamp */
|
||||
analyzedAt: string;
|
||||
|
||||
/** Link to raw entropy report */
|
||||
reportUrl: string;
|
||||
}
|
||||
|
||||
export interface LayerEntropy {
|
||||
/** Layer digest */
|
||||
digest: string;
|
||||
|
||||
/** Layer command (e.g., COPY, RUN) */
|
||||
command: string;
|
||||
|
||||
/** Layer size in bytes */
|
||||
size: number;
|
||||
|
||||
/** Average entropy for this layer (0-8 bits) */
|
||||
avgEntropy: number;
|
||||
|
||||
/** Percentage of opaque bytes (high entropy) */
|
||||
opaqueByteRatio: number;
|
||||
|
||||
/** Number of high-entropy files */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Risk contribution to overall score */
|
||||
riskContribution: number;
|
||||
}
|
||||
|
||||
export interface HighEntropyFile {
|
||||
/** File path in container */
|
||||
path: string;
|
||||
|
||||
/** Layer where file was added */
|
||||
layerDigest: string;
|
||||
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
|
||||
/** File entropy (0-8 bits) */
|
||||
entropy: number;
|
||||
|
||||
/** Classification */
|
||||
classification: 'encrypted' | 'compressed' | 'binary' | 'suspicious' | 'unknown';
|
||||
|
||||
/** Why this file is flagged */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DetectorHint {
|
||||
/** Hint ID */
|
||||
id: string;
|
||||
|
||||
/** Severity */
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/** Pattern type */
|
||||
type: 'credential' | 'key' | 'token' | 'obfuscated' | 'packed' | 'crypto';
|
||||
|
||||
/** Human-readable description */
|
||||
description: string;
|
||||
|
||||
/** Affected file paths */
|
||||
affectedPaths: string[];
|
||||
|
||||
/** Confidence (0-100) */
|
||||
confidence: number;
|
||||
|
||||
/** Remediation suggestion */
|
||||
remediation: string;
|
||||
}
|
||||
|
||||
@@ -1,352 +1,352 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
EvidenceData,
|
||||
Linkset,
|
||||
Observation,
|
||||
PolicyEvidence,
|
||||
} from './evidence.models';
|
||||
|
||||
export interface EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
|
||||
getObservation(observationId: string): Observable<Observation>;
|
||||
getLinkset(linksetId: string): Observable<Linkset>;
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
|
||||
/**
|
||||
* Export full evidence bundle as tar.gz or zip
|
||||
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
||||
*/
|
||||
exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob>;
|
||||
}
|
||||
|
||||
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
|
||||
|
||||
// Mock data for development
|
||||
const MOCK_OBSERVATIONS: Observation[] = [
|
||||
{
|
||||
observationId: 'obs-ghsa-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'ghsa',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Log4j Remote Code Execution (Log4Shell)',
|
||||
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
|
||||
'https://logging.apache.org/log4j/2.x/security.html',
|
||||
],
|
||||
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2024-01-15T10:30:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:abc123def456...',
|
||||
fetchedAt: '2024-11-20T08:00:00Z',
|
||||
ingestJobId: 'job-ghsa-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:05:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-nvd-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'nvd',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
|
||||
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
|
||||
],
|
||||
relationships: [
|
||||
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
|
||||
],
|
||||
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
|
||||
published: '2021-12-10T10:15:00Z',
|
||||
modified: '2024-02-20T15:45:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:def789ghi012...',
|
||||
fetchedAt: '2024-11-20T08:10:00Z',
|
||||
ingestJobId: 'job-nvd-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:15:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-osv-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'osv',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Remote code injection in Log4j',
|
||||
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'Maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.3.1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.4' },
|
||||
{ fixed: '2.12.2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.13.0' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
|
||||
],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2023-06-15T09:00:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:ghi345jkl678...',
|
||||
fetchedAt: '2024-11-20T08:20:00Z',
|
||||
ingestJobId: 'job-osv-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:25:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_LINKSET: Linkset = {
|
||||
linksetId: 'ls-log4shell-001',
|
||||
tenantId: 'tenant-1',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
source: 'aggregated',
|
||||
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
|
||||
normalized: {
|
||||
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
},
|
||||
confidence: 0.95,
|
||||
conflicts: [
|
||||
{
|
||||
field: 'affected.ranges',
|
||||
reason: 'Different fixed version ranges reported by sources',
|
||||
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
|
||||
sourceIds: ['ghsa', 'osv'],
|
||||
},
|
||||
{
|
||||
field: 'weaknesses',
|
||||
reason: 'Different CWE identifiers reported',
|
||||
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
|
||||
sourceIds: ['ghsa', 'nvd'],
|
||||
},
|
||||
],
|
||||
createdAt: '2024-11-20T08:30:00Z',
|
||||
builtByJobId: 'linkset-build-2024-1120',
|
||||
provenance: {
|
||||
observationHashes: [
|
||||
'sha256:abc123...',
|
||||
'sha256:def789...',
|
||||
'sha256:ghi345...',
|
||||
],
|
||||
toolVersion: 'concelier-lnm-1.2.0',
|
||||
policyHash: 'sha256:policy-hash-001',
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
|
||||
policyId: 'pol-critical-vuln-001',
|
||||
policyName: 'Critical Vulnerability Policy',
|
||||
decision: 'block',
|
||||
decidedAt: '2024-11-20T08:35:00Z',
|
||||
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'rule-cvss-critical',
|
||||
ruleName: 'Block Critical CVSS',
|
||||
passed: false,
|
||||
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
|
||||
matchedItems: ['CVE-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-known-exploit',
|
||||
ruleName: 'Known Exploit Check',
|
||||
passed: false,
|
||||
reason: 'Active exploitation reported by CISA',
|
||||
matchedItems: ['KEV-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-fix-available',
|
||||
ruleName: 'Fix Available',
|
||||
passed: true,
|
||||
reason: 'Fixed version 2.15.0+ available',
|
||||
},
|
||||
],
|
||||
linksetIds: ['ls-log4shell-001'],
|
||||
aocChain: [
|
||||
{
|
||||
attestationId: 'aoc-obs-ghsa-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:abc123def456...',
|
||||
timestamp: '2024-11-20T08:05:00Z',
|
||||
parentHash: undefined,
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-nvd-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:def789ghi012...',
|
||||
timestamp: '2024-11-20T08:15:00Z',
|
||||
parentHash: 'sha256:abc123def456...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-osv-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:ghi345jkl678...',
|
||||
timestamp: '2024-11-20T08:25:00Z',
|
||||
parentHash: 'sha256:def789ghi012...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-ls-001',
|
||||
type: 'linkset',
|
||||
hash: 'sha256:linkset-hash-001...',
|
||||
timestamp: '2024-11-20T08:30:00Z',
|
||||
parentHash: 'sha256:ghi345jkl678...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-policy-001',
|
||||
type: 'policy',
|
||||
hash: 'sha256:policy-decision-hash...',
|
||||
timestamp: '2024-11-20T08:35:00Z',
|
||||
signer: 'policy-engine-v1',
|
||||
parentHash: 'sha256:linkset-hash-001...',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockEvidenceApiService implements EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
|
||||
// Filter observations related to the advisory
|
||||
const observations = MOCK_OBSERVATIONS.filter(
|
||||
(o) =>
|
||||
o.advisoryId === advisoryId ||
|
||||
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
|
||||
);
|
||||
|
||||
const linkset = MOCK_LINKSET;
|
||||
const policyEvidence = MOCK_POLICY_EVIDENCE;
|
||||
|
||||
const data: EvidenceData = {
|
||||
advisoryId,
|
||||
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
|
||||
observations,
|
||||
linkset,
|
||||
policyEvidence,
|
||||
hasConflicts: linkset.conflicts.length > 0,
|
||||
conflictCount: linkset.conflicts.length,
|
||||
};
|
||||
|
||||
return of(data).pipe(delay(300));
|
||||
}
|
||||
|
||||
getObservation(observationId: string): Observable<Observation> {
|
||||
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
|
||||
if (!observation) {
|
||||
throw new Error(`Observation not found: ${observationId}`);
|
||||
}
|
||||
return of(observation).pipe(delay(100));
|
||||
}
|
||||
|
||||
getLinkset(linksetId: string): Observable<Linkset> {
|
||||
if (linksetId === MOCK_LINKSET.linksetId) {
|
||||
return of(MOCK_LINKSET).pipe(delay(100));
|
||||
}
|
||||
throw new Error(`Linkset not found: ${linksetId}`);
|
||||
}
|
||||
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
|
||||
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
|
||||
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
|
||||
}
|
||||
return of(null).pipe(delay(100));
|
||||
}
|
||||
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
|
||||
let data: object;
|
||||
if (type === 'observation') {
|
||||
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
|
||||
} else {
|
||||
data = MOCK_LINKSET;
|
||||
}
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
return of(blob).pipe(delay(100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export full evidence bundle as tar.gz or zip
|
||||
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
||||
*/
|
||||
async exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob> {
|
||||
// In mock implementation, return a JSON blob with all evidence data
|
||||
const data = {
|
||||
advisoryId,
|
||||
exportedAt: new Date().toISOString(),
|
||||
format,
|
||||
observations: MOCK_OBSERVATIONS,
|
||||
linkset: MOCK_LINKSET,
|
||||
policyEvidence: MOCK_POLICY_EVIDENCE,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const mimeType = format === 'tar.gz' ? 'application/gzip' : 'application/zip';
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
return new Blob([json], { type: mimeType });
|
||||
}
|
||||
}
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
EvidenceData,
|
||||
Linkset,
|
||||
Observation,
|
||||
PolicyEvidence,
|
||||
} from './evidence.models';
|
||||
|
||||
export interface EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
|
||||
getObservation(observationId: string): Observable<Observation>;
|
||||
getLinkset(linksetId: string): Observable<Linkset>;
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
|
||||
/**
|
||||
* Export full evidence bundle as tar.gz or zip
|
||||
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
||||
*/
|
||||
exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob>;
|
||||
}
|
||||
|
||||
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
|
||||
|
||||
// Mock data for development
|
||||
const MOCK_OBSERVATIONS: Observation[] = [
|
||||
{
|
||||
observationId: 'obs-ghsa-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'ghsa',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Log4j Remote Code Execution (Log4Shell)',
|
||||
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
|
||||
'https://logging.apache.org/log4j/2.x/security.html',
|
||||
],
|
||||
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2024-01-15T10:30:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:abc123def456...',
|
||||
fetchedAt: '2024-11-20T08:00:00Z',
|
||||
ingestJobId: 'job-ghsa-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:05:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-nvd-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'nvd',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
|
||||
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
|
||||
],
|
||||
relationships: [
|
||||
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
|
||||
],
|
||||
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
|
||||
published: '2021-12-10T10:15:00Z',
|
||||
modified: '2024-02-20T15:45:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:def789ghi012...',
|
||||
fetchedAt: '2024-11-20T08:10:00Z',
|
||||
ingestJobId: 'job-nvd-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:15:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-osv-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'osv',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Remote code injection in Log4j',
|
||||
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'Maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.3.1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.4' },
|
||||
{ fixed: '2.12.2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.13.0' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
|
||||
],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2023-06-15T09:00:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:ghi345jkl678...',
|
||||
fetchedAt: '2024-11-20T08:20:00Z',
|
||||
ingestJobId: 'job-osv-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:25:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_LINKSET: Linkset = {
|
||||
linksetId: 'ls-log4shell-001',
|
||||
tenantId: 'tenant-1',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
source: 'aggregated',
|
||||
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
|
||||
normalized: {
|
||||
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
},
|
||||
confidence: 0.95,
|
||||
conflicts: [
|
||||
{
|
||||
field: 'affected.ranges',
|
||||
reason: 'Different fixed version ranges reported by sources',
|
||||
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
|
||||
sourceIds: ['ghsa', 'osv'],
|
||||
},
|
||||
{
|
||||
field: 'weaknesses',
|
||||
reason: 'Different CWE identifiers reported',
|
||||
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
|
||||
sourceIds: ['ghsa', 'nvd'],
|
||||
},
|
||||
],
|
||||
createdAt: '2024-11-20T08:30:00Z',
|
||||
builtByJobId: 'linkset-build-2024-1120',
|
||||
provenance: {
|
||||
observationHashes: [
|
||||
'sha256:abc123...',
|
||||
'sha256:def789...',
|
||||
'sha256:ghi345...',
|
||||
],
|
||||
toolVersion: 'concelier-lnm-1.2.0',
|
||||
policyHash: 'sha256:policy-hash-001',
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
|
||||
policyId: 'pol-critical-vuln-001',
|
||||
policyName: 'Critical Vulnerability Policy',
|
||||
decision: 'block',
|
||||
decidedAt: '2024-11-20T08:35:00Z',
|
||||
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'rule-cvss-critical',
|
||||
ruleName: 'Block Critical CVSS',
|
||||
passed: false,
|
||||
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
|
||||
matchedItems: ['CVE-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-known-exploit',
|
||||
ruleName: 'Known Exploit Check',
|
||||
passed: false,
|
||||
reason: 'Active exploitation reported by CISA',
|
||||
matchedItems: ['KEV-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-fix-available',
|
||||
ruleName: 'Fix Available',
|
||||
passed: true,
|
||||
reason: 'Fixed version 2.15.0+ available',
|
||||
},
|
||||
],
|
||||
linksetIds: ['ls-log4shell-001'],
|
||||
aocChain: [
|
||||
{
|
||||
attestationId: 'aoc-obs-ghsa-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:abc123def456...',
|
||||
timestamp: '2024-11-20T08:05:00Z',
|
||||
parentHash: undefined,
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-nvd-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:def789ghi012...',
|
||||
timestamp: '2024-11-20T08:15:00Z',
|
||||
parentHash: 'sha256:abc123def456...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-osv-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:ghi345jkl678...',
|
||||
timestamp: '2024-11-20T08:25:00Z',
|
||||
parentHash: 'sha256:def789ghi012...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-ls-001',
|
||||
type: 'linkset',
|
||||
hash: 'sha256:linkset-hash-001...',
|
||||
timestamp: '2024-11-20T08:30:00Z',
|
||||
parentHash: 'sha256:ghi345jkl678...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-policy-001',
|
||||
type: 'policy',
|
||||
hash: 'sha256:policy-decision-hash...',
|
||||
timestamp: '2024-11-20T08:35:00Z',
|
||||
signer: 'policy-engine-v1',
|
||||
parentHash: 'sha256:linkset-hash-001...',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockEvidenceApiService implements EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
|
||||
// Filter observations related to the advisory
|
||||
const observations = MOCK_OBSERVATIONS.filter(
|
||||
(o) =>
|
||||
o.advisoryId === advisoryId ||
|
||||
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
|
||||
);
|
||||
|
||||
const linkset = MOCK_LINKSET;
|
||||
const policyEvidence = MOCK_POLICY_EVIDENCE;
|
||||
|
||||
const data: EvidenceData = {
|
||||
advisoryId,
|
||||
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
|
||||
observations,
|
||||
linkset,
|
||||
policyEvidence,
|
||||
hasConflicts: linkset.conflicts.length > 0,
|
||||
conflictCount: linkset.conflicts.length,
|
||||
};
|
||||
|
||||
return of(data).pipe(delay(300));
|
||||
}
|
||||
|
||||
getObservation(observationId: string): Observable<Observation> {
|
||||
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
|
||||
if (!observation) {
|
||||
throw new Error(`Observation not found: ${observationId}`);
|
||||
}
|
||||
return of(observation).pipe(delay(100));
|
||||
}
|
||||
|
||||
getLinkset(linksetId: string): Observable<Linkset> {
|
||||
if (linksetId === MOCK_LINKSET.linksetId) {
|
||||
return of(MOCK_LINKSET).pipe(delay(100));
|
||||
}
|
||||
throw new Error(`Linkset not found: ${linksetId}`);
|
||||
}
|
||||
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
|
||||
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
|
||||
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
|
||||
}
|
||||
return of(null).pipe(delay(100));
|
||||
}
|
||||
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
|
||||
let data: object;
|
||||
if (type === 'observation') {
|
||||
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
|
||||
} else {
|
||||
data = MOCK_LINKSET;
|
||||
}
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
return of(blob).pipe(delay(100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export full evidence bundle as tar.gz or zip
|
||||
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
||||
*/
|
||||
async exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob> {
|
||||
// In mock implementation, return a JSON blob with all evidence data
|
||||
const data = {
|
||||
advisoryId,
|
||||
exportedAt: new Date().toISOString(),
|
||||
format,
|
||||
observations: MOCK_OBSERVATIONS,
|
||||
linkset: MOCK_LINKSET,
|
||||
policyEvidence: MOCK_POLICY_EVIDENCE,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const mimeType = format === 'tar.gz' ? 'application/gzip' : 'application/zip';
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
return new Blob([json], { type: mimeType });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,355 +1,355 @@
|
||||
/**
|
||||
* Link-Not-Merge Evidence Models
|
||||
* Based on docs/modules/concelier/link-not-merge-schema.md
|
||||
*/
|
||||
|
||||
// Severity from advisory sources
|
||||
export interface AdvisorySeverity {
|
||||
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
|
||||
readonly score: number;
|
||||
readonly vector?: string;
|
||||
}
|
||||
|
||||
// Affected package information
|
||||
export interface AffectedPackage {
|
||||
readonly purl: string;
|
||||
readonly package?: string;
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly ecosystem?: string;
|
||||
readonly cpe?: readonly string[];
|
||||
}
|
||||
|
||||
export interface VersionRange {
|
||||
readonly type: string;
|
||||
readonly events: readonly VersionEvent[];
|
||||
}
|
||||
|
||||
export interface VersionEvent {
|
||||
readonly introduced?: string;
|
||||
readonly fixed?: string;
|
||||
readonly last_affected?: string;
|
||||
}
|
||||
|
||||
// Relationship between advisories
|
||||
export interface AdvisoryRelationship {
|
||||
readonly type: string;
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly provenance?: string;
|
||||
}
|
||||
|
||||
// Provenance tracking
|
||||
export interface ObservationProvenance {
|
||||
readonly sourceArtifactSha: string;
|
||||
readonly fetchedAt: string;
|
||||
readonly ingestJobId?: string;
|
||||
readonly signature?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Raw observation from a single source
|
||||
export interface Observation {
|
||||
readonly observationId: string;
|
||||
readonly tenantId: string;
|
||||
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly summary?: string;
|
||||
readonly severities: readonly AdvisorySeverity[];
|
||||
readonly affected: readonly AffectedPackage[];
|
||||
readonly references?: readonly string[];
|
||||
readonly scopes?: readonly string[];
|
||||
readonly relationships?: readonly AdvisoryRelationship[];
|
||||
readonly weaknesses?: readonly string[];
|
||||
readonly published?: string;
|
||||
readonly modified?: string;
|
||||
readonly provenance: ObservationProvenance;
|
||||
readonly ingestedAt: string;
|
||||
}
|
||||
|
||||
// Conflict when sources disagree
|
||||
export interface LinksetConflict {
|
||||
readonly field: string;
|
||||
readonly reason: string;
|
||||
readonly values?: readonly string[];
|
||||
readonly sourceIds?: readonly string[];
|
||||
}
|
||||
|
||||
// Linkset provenance
|
||||
export interface LinksetProvenance {
|
||||
readonly observationHashes: readonly string[];
|
||||
readonly toolVersion?: string;
|
||||
readonly policyHash?: string;
|
||||
}
|
||||
|
||||
// Normalized linkset aggregating multiple observations
|
||||
export interface Linkset {
|
||||
readonly linksetId: string;
|
||||
readonly tenantId: string;
|
||||
readonly advisoryId: string;
|
||||
readonly source: string;
|
||||
readonly observations: readonly string[]; // observation IDs
|
||||
readonly normalized?: {
|
||||
readonly purls?: readonly string[];
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly severities?: readonly AdvisorySeverity[];
|
||||
};
|
||||
readonly confidence?: number; // 0-1
|
||||
readonly conflicts: readonly LinksetConflict[];
|
||||
readonly createdAt: string;
|
||||
readonly builtByJobId?: string;
|
||||
readonly provenance?: LinksetProvenance;
|
||||
// Artifact and verification fields (SPRINT_0341_0001_0001)
|
||||
readonly artifactRef?: string; // e.g., registry.example.com/image:tag
|
||||
readonly artifactDigest?: string; // e.g., sha256:abc123...
|
||||
readonly sbomDigest?: string; // SBOM attestation digest
|
||||
readonly rekorLogIndex?: number; // Rekor transparency log index
|
||||
}
|
||||
|
||||
// Policy decision result
|
||||
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
|
||||
|
||||
// Policy decision with evidence
|
||||
export interface PolicyEvidence {
|
||||
readonly policyId: string;
|
||||
readonly policyName: string;
|
||||
readonly decision: PolicyDecision;
|
||||
readonly decidedAt: string;
|
||||
readonly reason?: string;
|
||||
readonly rules: readonly PolicyRuleResult[];
|
||||
readonly linksetIds: readonly string[];
|
||||
readonly aocChain?: AocChainEntry[];
|
||||
// Decision verification fields (SPRINT_0341_0001_0001)
|
||||
readonly decisionDigest?: string; // Hash of the decision for verification
|
||||
readonly rekorLogIndex?: number; // Rekor log index if logged
|
||||
}
|
||||
|
||||
export interface PolicyRuleResult {
|
||||
readonly ruleId: string;
|
||||
readonly ruleName: string;
|
||||
readonly passed: boolean;
|
||||
readonly reason?: string;
|
||||
readonly matchedItems?: readonly string[];
|
||||
// Confidence metadata (UI-POLICY-13-007)
|
||||
readonly unknownConfidence?: number | null;
|
||||
readonly confidenceBand?: string | null;
|
||||
readonly unknownAgeDays?: number | null;
|
||||
readonly sourceTrust?: string | null;
|
||||
readonly reachability?: string | null;
|
||||
readonly quietedBy?: string | null;
|
||||
readonly quiet?: boolean | null;
|
||||
}
|
||||
|
||||
// AOC (Attestation of Compliance) chain entry
|
||||
export interface AocChainEntry {
|
||||
readonly attestationId: string;
|
||||
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
|
||||
readonly hash: string;
|
||||
readonly timestamp: string;
|
||||
readonly signer?: string;
|
||||
readonly parentHash?: string;
|
||||
}
|
||||
|
||||
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
|
||||
export type VexStatus =
|
||||
| 'NOT_AFFECTED'
|
||||
| 'UNDER_INVESTIGATION'
|
||||
| 'AFFECTED_MITIGATED'
|
||||
| 'AFFECTED_UNMITIGATED'
|
||||
| 'FIXED';
|
||||
|
||||
export type VexJustificationType =
|
||||
| 'CODE_NOT_PRESENT'
|
||||
| 'CODE_NOT_REACHABLE'
|
||||
| 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH'
|
||||
| 'CONFIGURATION_NOT_AFFECTED'
|
||||
| 'OS_NOT_AFFECTED'
|
||||
| 'RUNTIME_MITIGATION_PRESENT'
|
||||
| 'COMPENSATING_CONTROLS'
|
||||
| 'ACCEPTED_BUSINESS_RISK'
|
||||
| 'OTHER';
|
||||
|
||||
export interface VexSubjectRef {
|
||||
readonly type: 'IMAGE' | 'REPO' | 'SBOM_COMPONENT' | 'OTHER';
|
||||
readonly name: string;
|
||||
readonly digest: Record<string, string>;
|
||||
readonly sbomNodeId?: string;
|
||||
}
|
||||
|
||||
export interface VexEvidenceRef {
|
||||
readonly type: 'PR' | 'TICKET' | 'DOC' | 'COMMIT' | 'OTHER';
|
||||
readonly title?: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface VexScope {
|
||||
readonly environments?: readonly string[];
|
||||
readonly projects?: readonly string[];
|
||||
}
|
||||
|
||||
export interface VexValidFor {
|
||||
readonly notBefore?: string;
|
||||
readonly notAfter?: string;
|
||||
}
|
||||
|
||||
export interface VexActorRef {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signature metadata for signed VEX decisions.
|
||||
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui (FE-RISK-005)
|
||||
*/
|
||||
export interface VexDecisionSignatureInfo {
|
||||
/** Whether the decision is cryptographically signed */
|
||||
readonly isSigned: boolean;
|
||||
/** DSSE envelope digest (base64-encoded) */
|
||||
readonly dsseDigest?: string;
|
||||
/** Signature algorithm used (e.g., 'ecdsa-p256', 'rsa-sha256') */
|
||||
readonly signatureAlgorithm?: string;
|
||||
/** Key ID used for signing */
|
||||
readonly signingKeyId?: string;
|
||||
/** Signer identity (e.g., email, OIDC subject) */
|
||||
readonly signerIdentity?: string;
|
||||
/** Timestamp when signed (ISO-8601) */
|
||||
readonly signedAt?: string;
|
||||
/** Signature verification status */
|
||||
readonly verificationStatus?: 'verified' | 'failed' | 'pending' | 'unknown';
|
||||
/** Rekor transparency log entry if logged */
|
||||
readonly rekorEntry?: VexRekorEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rekor transparency log entry for VEX decisions.
|
||||
*/
|
||||
export interface VexRekorEntry {
|
||||
/** Rekor log index */
|
||||
readonly logIndex: number;
|
||||
/** Rekor log ID (tree hash) */
|
||||
readonly logId?: string;
|
||||
/** Entry UUID in Rekor */
|
||||
readonly entryUuid?: string;
|
||||
/** Time integrated into the log (ISO-8601) */
|
||||
readonly integratedTime?: string;
|
||||
/** URL to view/verify the entry */
|
||||
readonly verifyUrl?: string;
|
||||
}
|
||||
|
||||
export interface VexDecision {
|
||||
readonly id: string;
|
||||
readonly vulnerabilityId: string;
|
||||
readonly subject: VexSubjectRef;
|
||||
readonly status: VexStatus;
|
||||
readonly justificationType: VexJustificationType;
|
||||
readonly justificationText?: string;
|
||||
readonly evidenceRefs?: readonly VexEvidenceRef[];
|
||||
readonly scope?: VexScope;
|
||||
readonly validFor?: VexValidFor;
|
||||
readonly supersedesDecisionId?: string;
|
||||
readonly createdBy: VexActorRef;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
/** Signature metadata for signed decisions (FE-RISK-005) */
|
||||
readonly signatureInfo?: VexDecisionSignatureInfo;
|
||||
}
|
||||
|
||||
// VEX status summary for UI display
|
||||
export interface VexStatusSummary {
|
||||
readonly notAffected: number;
|
||||
readonly underInvestigation: number;
|
||||
readonly affectedMitigated: number;
|
||||
readonly affectedUnmitigated: number;
|
||||
readonly fixed: number;
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
// VEX conflict indicator
|
||||
export interface VexConflict {
|
||||
readonly vulnerabilityId: string;
|
||||
readonly conflictingStatuses: readonly VexStatus[];
|
||||
readonly decisionIds: readonly string[];
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
// Evidence panel data combining all elements
|
||||
export interface EvidenceData {
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly observations: readonly Observation[];
|
||||
readonly linkset?: Linkset;
|
||||
readonly policyEvidence?: PolicyEvidence;
|
||||
readonly vexDecisions?: readonly VexDecision[];
|
||||
readonly vexConflicts?: readonly VexConflict[];
|
||||
readonly hasConflicts: boolean;
|
||||
readonly conflictCount: number;
|
||||
}
|
||||
|
||||
// Source metadata for display
|
||||
export interface SourceInfo {
|
||||
readonly sourceId: string;
|
||||
readonly name: string;
|
||||
readonly icon?: string;
|
||||
readonly url?: string;
|
||||
readonly lastUpdated?: string;
|
||||
}
|
||||
|
||||
// Filter configuration for observations/linksets
|
||||
export type SeverityBucket = 'critical' | 'high' | 'medium' | 'low' | 'all';
|
||||
|
||||
export interface ObservationFilters {
|
||||
readonly sources: readonly string[]; // Filter by source IDs
|
||||
readonly severityBucket: SeverityBucket; // Filter by severity level
|
||||
readonly conflictOnly: boolean; // Show only observations with conflicts
|
||||
readonly hasCvssVector: boolean | null; // null = all, true = has vector, false = no vector
|
||||
}
|
||||
|
||||
export const DEFAULT_OBSERVATION_FILTERS: ObservationFilters = {
|
||||
sources: [],
|
||||
severityBucket: 'all',
|
||||
conflictOnly: false,
|
||||
hasCvssVector: null,
|
||||
};
|
||||
|
||||
// Pagination configuration
|
||||
export interface PaginationState {
|
||||
readonly pageSize: number;
|
||||
readonly currentPage: number;
|
||||
readonly totalItems: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export const SOURCE_INFO: Record<string, SourceInfo> = {
|
||||
ghsa: {
|
||||
sourceId: 'ghsa',
|
||||
name: 'GitHub Security Advisories',
|
||||
icon: 'github',
|
||||
url: 'https://github.com/advisories',
|
||||
},
|
||||
nvd: {
|
||||
sourceId: 'nvd',
|
||||
name: 'National Vulnerability Database',
|
||||
icon: 'database',
|
||||
url: 'https://nvd.nist.gov',
|
||||
},
|
||||
'cert-bund': {
|
||||
sourceId: 'cert-bund',
|
||||
name: 'CERT-Bund',
|
||||
icon: 'shield',
|
||||
url: 'https://www.cert-bund.de',
|
||||
},
|
||||
osv: {
|
||||
sourceId: 'osv',
|
||||
name: 'Open Source Vulnerabilities',
|
||||
icon: 'box',
|
||||
url: 'https://osv.dev',
|
||||
},
|
||||
cve: {
|
||||
sourceId: 'cve',
|
||||
name: 'CVE Program',
|
||||
icon: 'alert-triangle',
|
||||
url: 'https://cve.mitre.org',
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Link-Not-Merge Evidence Models
|
||||
* Based on docs/modules/concelier/link-not-merge-schema.md
|
||||
*/
|
||||
|
||||
// Severity from advisory sources
|
||||
export interface AdvisorySeverity {
|
||||
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
|
||||
readonly score: number;
|
||||
readonly vector?: string;
|
||||
}
|
||||
|
||||
// Affected package information
|
||||
export interface AffectedPackage {
|
||||
readonly purl: string;
|
||||
readonly package?: string;
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly ecosystem?: string;
|
||||
readonly cpe?: readonly string[];
|
||||
}
|
||||
|
||||
export interface VersionRange {
|
||||
readonly type: string;
|
||||
readonly events: readonly VersionEvent[];
|
||||
}
|
||||
|
||||
export interface VersionEvent {
|
||||
readonly introduced?: string;
|
||||
readonly fixed?: string;
|
||||
readonly last_affected?: string;
|
||||
}
|
||||
|
||||
// Relationship between advisories
|
||||
export interface AdvisoryRelationship {
|
||||
readonly type: string;
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly provenance?: string;
|
||||
}
|
||||
|
||||
// Provenance tracking
|
||||
export interface ObservationProvenance {
|
||||
readonly sourceArtifactSha: string;
|
||||
readonly fetchedAt: string;
|
||||
readonly ingestJobId?: string;
|
||||
readonly signature?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Raw observation from a single source
|
||||
export interface Observation {
|
||||
readonly observationId: string;
|
||||
readonly tenantId: string;
|
||||
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly summary?: string;
|
||||
readonly severities: readonly AdvisorySeverity[];
|
||||
readonly affected: readonly AffectedPackage[];
|
||||
readonly references?: readonly string[];
|
||||
readonly scopes?: readonly string[];
|
||||
readonly relationships?: readonly AdvisoryRelationship[];
|
||||
readonly weaknesses?: readonly string[];
|
||||
readonly published?: string;
|
||||
readonly modified?: string;
|
||||
readonly provenance: ObservationProvenance;
|
||||
readonly ingestedAt: string;
|
||||
}
|
||||
|
||||
// Conflict when sources disagree
|
||||
export interface LinksetConflict {
|
||||
readonly field: string;
|
||||
readonly reason: string;
|
||||
readonly values?: readonly string[];
|
||||
readonly sourceIds?: readonly string[];
|
||||
}
|
||||
|
||||
// Linkset provenance
|
||||
export interface LinksetProvenance {
|
||||
readonly observationHashes: readonly string[];
|
||||
readonly toolVersion?: string;
|
||||
readonly policyHash?: string;
|
||||
}
|
||||
|
||||
// Normalized linkset aggregating multiple observations
|
||||
export interface Linkset {
|
||||
readonly linksetId: string;
|
||||
readonly tenantId: string;
|
||||
readonly advisoryId: string;
|
||||
readonly source: string;
|
||||
readonly observations: readonly string[]; // observation IDs
|
||||
readonly normalized?: {
|
||||
readonly purls?: readonly string[];
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly severities?: readonly AdvisorySeverity[];
|
||||
};
|
||||
readonly confidence?: number; // 0-1
|
||||
readonly conflicts: readonly LinksetConflict[];
|
||||
readonly createdAt: string;
|
||||
readonly builtByJobId?: string;
|
||||
readonly provenance?: LinksetProvenance;
|
||||
// Artifact and verification fields (SPRINT_0341_0001_0001)
|
||||
readonly artifactRef?: string; // e.g., registry.example.com/image:tag
|
||||
readonly artifactDigest?: string; // e.g., sha256:abc123...
|
||||
readonly sbomDigest?: string; // SBOM attestation digest
|
||||
readonly rekorLogIndex?: number; // Rekor transparency log index
|
||||
}
|
||||
|
||||
// Policy decision result
|
||||
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
|
||||
|
||||
// Policy decision with evidence
|
||||
export interface PolicyEvidence {
|
||||
readonly policyId: string;
|
||||
readonly policyName: string;
|
||||
readonly decision: PolicyDecision;
|
||||
readonly decidedAt: string;
|
||||
readonly reason?: string;
|
||||
readonly rules: readonly PolicyRuleResult[];
|
||||
readonly linksetIds: readonly string[];
|
||||
readonly aocChain?: AocChainEntry[];
|
||||
// Decision verification fields (SPRINT_0341_0001_0001)
|
||||
readonly decisionDigest?: string; // Hash of the decision for verification
|
||||
readonly rekorLogIndex?: number; // Rekor log index if logged
|
||||
}
|
||||
|
||||
export interface PolicyRuleResult {
|
||||
readonly ruleId: string;
|
||||
readonly ruleName: string;
|
||||
readonly passed: boolean;
|
||||
readonly reason?: string;
|
||||
readonly matchedItems?: readonly string[];
|
||||
// Confidence metadata (UI-POLICY-13-007)
|
||||
readonly unknownConfidence?: number | null;
|
||||
readonly confidenceBand?: string | null;
|
||||
readonly unknownAgeDays?: number | null;
|
||||
readonly sourceTrust?: string | null;
|
||||
readonly reachability?: string | null;
|
||||
readonly quietedBy?: string | null;
|
||||
readonly quiet?: boolean | null;
|
||||
}
|
||||
|
||||
// AOC (Attestation of Compliance) chain entry
|
||||
export interface AocChainEntry {
|
||||
readonly attestationId: string;
|
||||
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
|
||||
readonly hash: string;
|
||||
readonly timestamp: string;
|
||||
readonly signer?: string;
|
||||
readonly parentHash?: string;
|
||||
}
|
||||
|
||||
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
|
||||
export type VexStatus =
|
||||
| 'NOT_AFFECTED'
|
||||
| 'UNDER_INVESTIGATION'
|
||||
| 'AFFECTED_MITIGATED'
|
||||
| 'AFFECTED_UNMITIGATED'
|
||||
| 'FIXED';
|
||||
|
||||
export type VexJustificationType =
|
||||
| 'CODE_NOT_PRESENT'
|
||||
| 'CODE_NOT_REACHABLE'
|
||||
| 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH'
|
||||
| 'CONFIGURATION_NOT_AFFECTED'
|
||||
| 'OS_NOT_AFFECTED'
|
||||
| 'RUNTIME_MITIGATION_PRESENT'
|
||||
| 'COMPENSATING_CONTROLS'
|
||||
| 'ACCEPTED_BUSINESS_RISK'
|
||||
| 'OTHER';
|
||||
|
||||
export interface VexSubjectRef {
|
||||
readonly type: 'IMAGE' | 'REPO' | 'SBOM_COMPONENT' | 'OTHER';
|
||||
readonly name: string;
|
||||
readonly digest: Record<string, string>;
|
||||
readonly sbomNodeId?: string;
|
||||
}
|
||||
|
||||
export interface VexEvidenceRef {
|
||||
readonly type: 'PR' | 'TICKET' | 'DOC' | 'COMMIT' | 'OTHER';
|
||||
readonly title?: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface VexScope {
|
||||
readonly environments?: readonly string[];
|
||||
readonly projects?: readonly string[];
|
||||
}
|
||||
|
||||
export interface VexValidFor {
|
||||
readonly notBefore?: string;
|
||||
readonly notAfter?: string;
|
||||
}
|
||||
|
||||
export interface VexActorRef {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signature metadata for signed VEX decisions.
|
||||
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui (FE-RISK-005)
|
||||
*/
|
||||
export interface VexDecisionSignatureInfo {
|
||||
/** Whether the decision is cryptographically signed */
|
||||
readonly isSigned: boolean;
|
||||
/** DSSE envelope digest (base64-encoded) */
|
||||
readonly dsseDigest?: string;
|
||||
/** Signature algorithm used (e.g., 'ecdsa-p256', 'rsa-sha256') */
|
||||
readonly signatureAlgorithm?: string;
|
||||
/** Key ID used for signing */
|
||||
readonly signingKeyId?: string;
|
||||
/** Signer identity (e.g., email, OIDC subject) */
|
||||
readonly signerIdentity?: string;
|
||||
/** Timestamp when signed (ISO-8601) */
|
||||
readonly signedAt?: string;
|
||||
/** Signature verification status */
|
||||
readonly verificationStatus?: 'verified' | 'failed' | 'pending' | 'unknown';
|
||||
/** Rekor transparency log entry if logged */
|
||||
readonly rekorEntry?: VexRekorEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rekor transparency log entry for VEX decisions.
|
||||
*/
|
||||
export interface VexRekorEntry {
|
||||
/** Rekor log index */
|
||||
readonly logIndex: number;
|
||||
/** Rekor log ID (tree hash) */
|
||||
readonly logId?: string;
|
||||
/** Entry UUID in Rekor */
|
||||
readonly entryUuid?: string;
|
||||
/** Time integrated into the log (ISO-8601) */
|
||||
readonly integratedTime?: string;
|
||||
/** URL to view/verify the entry */
|
||||
readonly verifyUrl?: string;
|
||||
}
|
||||
|
||||
export interface VexDecision {
|
||||
readonly id: string;
|
||||
readonly vulnerabilityId: string;
|
||||
readonly subject: VexSubjectRef;
|
||||
readonly status: VexStatus;
|
||||
readonly justificationType: VexJustificationType;
|
||||
readonly justificationText?: string;
|
||||
readonly evidenceRefs?: readonly VexEvidenceRef[];
|
||||
readonly scope?: VexScope;
|
||||
readonly validFor?: VexValidFor;
|
||||
readonly supersedesDecisionId?: string;
|
||||
readonly createdBy: VexActorRef;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
/** Signature metadata for signed decisions (FE-RISK-005) */
|
||||
readonly signatureInfo?: VexDecisionSignatureInfo;
|
||||
}
|
||||
|
||||
// VEX status summary for UI display
|
||||
export interface VexStatusSummary {
|
||||
readonly notAffected: number;
|
||||
readonly underInvestigation: number;
|
||||
readonly affectedMitigated: number;
|
||||
readonly affectedUnmitigated: number;
|
||||
readonly fixed: number;
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
// VEX conflict indicator
|
||||
export interface VexConflict {
|
||||
readonly vulnerabilityId: string;
|
||||
readonly conflictingStatuses: readonly VexStatus[];
|
||||
readonly decisionIds: readonly string[];
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
// Evidence panel data combining all elements
|
||||
export interface EvidenceData {
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly observations: readonly Observation[];
|
||||
readonly linkset?: Linkset;
|
||||
readonly policyEvidence?: PolicyEvidence;
|
||||
readonly vexDecisions?: readonly VexDecision[];
|
||||
readonly vexConflicts?: readonly VexConflict[];
|
||||
readonly hasConflicts: boolean;
|
||||
readonly conflictCount: number;
|
||||
}
|
||||
|
||||
// Source metadata for display
|
||||
export interface SourceInfo {
|
||||
readonly sourceId: string;
|
||||
readonly name: string;
|
||||
readonly icon?: string;
|
||||
readonly url?: string;
|
||||
readonly lastUpdated?: string;
|
||||
}
|
||||
|
||||
// Filter configuration for observations/linksets
|
||||
export type SeverityBucket = 'critical' | 'high' | 'medium' | 'low' | 'all';
|
||||
|
||||
export interface ObservationFilters {
|
||||
readonly sources: readonly string[]; // Filter by source IDs
|
||||
readonly severityBucket: SeverityBucket; // Filter by severity level
|
||||
readonly conflictOnly: boolean; // Show only observations with conflicts
|
||||
readonly hasCvssVector: boolean | null; // null = all, true = has vector, false = no vector
|
||||
}
|
||||
|
||||
export const DEFAULT_OBSERVATION_FILTERS: ObservationFilters = {
|
||||
sources: [],
|
||||
severityBucket: 'all',
|
||||
conflictOnly: false,
|
||||
hasCvssVector: null,
|
||||
};
|
||||
|
||||
// Pagination configuration
|
||||
export interface PaginationState {
|
||||
readonly pageSize: number;
|
||||
readonly currentPage: number;
|
||||
readonly totalItems: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export const SOURCE_INFO: Record<string, SourceInfo> = {
|
||||
ghsa: {
|
||||
sourceId: 'ghsa',
|
||||
name: 'GitHub Security Advisories',
|
||||
icon: 'github',
|
||||
url: 'https://github.com/advisories',
|
||||
},
|
||||
nvd: {
|
||||
sourceId: 'nvd',
|
||||
name: 'National Vulnerability Database',
|
||||
icon: 'database',
|
||||
url: 'https://nvd.nist.gov',
|
||||
},
|
||||
'cert-bund': {
|
||||
sourceId: 'cert-bund',
|
||||
name: 'CERT-Bund',
|
||||
icon: 'shield',
|
||||
url: 'https://www.cert-bund.de',
|
||||
},
|
||||
osv: {
|
||||
sourceId: 'osv',
|
||||
name: 'Open Source Vulnerabilities',
|
||||
icon: 'box',
|
||||
url: 'https://osv.dev',
|
||||
},
|
||||
cve: {
|
||||
sourceId: 'cve',
|
||||
name: 'CVE Program',
|
||||
icon: 'alert-triangle',
|
||||
url: 'https://cve.mitre.org',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -56,18 +56,18 @@ export class ExceptionApiHttpClient implements ExceptionApi {
|
||||
if (options?.severity) {
|
||||
params = params.set('severity', options.severity);
|
||||
}
|
||||
if (options?.search) {
|
||||
params = params.set('search', options.search);
|
||||
}
|
||||
if (options?.sortBy) {
|
||||
params = params.set('sortBy', options.sortBy);
|
||||
}
|
||||
if (options?.sortOrder) {
|
||||
params = params.set('sortOrder', options.sortOrder);
|
||||
}
|
||||
if (options?.limit) {
|
||||
params = params.set('limit', options.limit.toString());
|
||||
}
|
||||
if (options?.search) {
|
||||
params = params.set('search', options.search);
|
||||
}
|
||||
if (options?.sortBy) {
|
||||
params = params.set('sortBy', options.sortBy);
|
||||
}
|
||||
if (options?.sortOrder) {
|
||||
params = params.set('sortOrder', options.sortOrder);
|
||||
}
|
||||
if (options?.limit) {
|
||||
params = params.set('limit', options.limit.toString());
|
||||
}
|
||||
if (options?.continuationToken) {
|
||||
params = params.set('continuationToken', options.continuationToken);
|
||||
}
|
||||
@@ -190,198 +190,198 @@ export class ExceptionApiHttpClient implements ExceptionApi {
|
||||
return new HttpHeaders(headers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation for development and testing.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
||||
/**
|
||||
* Mock implementation for development and testing.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockExceptionApiService implements ExceptionApi {
|
||||
private readonly mockExceptions: Exception[] = [
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-001',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'log4j-temporary-exception',
|
||||
displayName: 'Log4j Temporary Exception',
|
||||
description: 'Temporary exception for legacy Log4j usage in internal tooling',
|
||||
status: 'approved',
|
||||
severity: 'high',
|
||||
scope: {
|
||||
type: 'component',
|
||||
componentPurls: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'],
|
||||
vulnIds: ['CVE-2021-44228'],
|
||||
},
|
||||
justification: {
|
||||
template: 'internal-only',
|
||||
text: 'Internal-only service with no external exposure. Migration planned for Q1.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-03-31T23:59:59Z',
|
||||
autoRenew: false,
|
||||
},
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-001',
|
||||
approvedBy: 'security-lead@example.com',
|
||||
approvedAt: '2024-12-15T10:30:00Z',
|
||||
comment: 'Approved with condition: migrate before Q2',
|
||||
},
|
||||
],
|
||||
labels: { team: 'platform', priority: 'P2' },
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-10T09:00:00Z',
|
||||
updatedBy: 'security-lead@example.com',
|
||||
updatedAt: '2024-12-15T10:30:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-002',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'openssl-vuln-exception',
|
||||
displayName: 'OpenSSL Vulnerability Exception',
|
||||
status: 'pending_review',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-nginx-prod'],
|
||||
vulnIds: ['CVE-2024-0001'],
|
||||
},
|
||||
justification: {
|
||||
template: 'compensating-control',
|
||||
text: 'Compensating controls in place: WAF rules, network segmentation, monitoring.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-15T00:00:00Z',
|
||||
endDate: '2025-02-15T23:59:59Z',
|
||||
},
|
||||
labels: { team: 'infrastructure' },
|
||||
createdBy: 'ops@example.com',
|
||||
createdAt: '2025-01-10T14:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-003',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'legacy-crypto-exception',
|
||||
displayName: 'Legacy Crypto Library',
|
||||
status: 'draft',
|
||||
severity: 'medium',
|
||||
scope: {
|
||||
type: 'tenant',
|
||||
tenantId: 'tenant-dev',
|
||||
},
|
||||
justification: {
|
||||
text: 'Migration in progress. ETA: 2 weeks.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-20T00:00:00Z',
|
||||
endDate: '2025-02-20T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2025-01-18T11:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-004',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'expired-cert-exception',
|
||||
displayName: 'Expired Certificate Exception',
|
||||
status: 'expired',
|
||||
severity: 'low',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-test-env'],
|
||||
},
|
||||
justification: {
|
||||
text: 'Test environment only, not production facing.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2024-10-01T00:00:00Z',
|
||||
endDate: '2024-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'qa@example.com',
|
||||
createdAt: '2024-09-25T08:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-005',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'rejected-exception',
|
||||
displayName: 'Rejected Risk Exception',
|
||||
status: 'rejected',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'global',
|
||||
},
|
||||
justification: {
|
||||
text: 'Blanket exception for all critical vulns.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-20T16:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly mockExceptions: Exception[] = [
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-001',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'log4j-temporary-exception',
|
||||
displayName: 'Log4j Temporary Exception',
|
||||
description: 'Temporary exception for legacy Log4j usage in internal tooling',
|
||||
status: 'approved',
|
||||
severity: 'high',
|
||||
scope: {
|
||||
type: 'component',
|
||||
componentPurls: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'],
|
||||
vulnIds: ['CVE-2021-44228'],
|
||||
},
|
||||
justification: {
|
||||
template: 'internal-only',
|
||||
text: 'Internal-only service with no external exposure. Migration planned for Q1.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-03-31T23:59:59Z',
|
||||
autoRenew: false,
|
||||
},
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-001',
|
||||
approvedBy: 'security-lead@example.com',
|
||||
approvedAt: '2024-12-15T10:30:00Z',
|
||||
comment: 'Approved with condition: migrate before Q2',
|
||||
},
|
||||
],
|
||||
labels: { team: 'platform', priority: 'P2' },
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-10T09:00:00Z',
|
||||
updatedBy: 'security-lead@example.com',
|
||||
updatedAt: '2024-12-15T10:30:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-002',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'openssl-vuln-exception',
|
||||
displayName: 'OpenSSL Vulnerability Exception',
|
||||
status: 'pending_review',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-nginx-prod'],
|
||||
vulnIds: ['CVE-2024-0001'],
|
||||
},
|
||||
justification: {
|
||||
template: 'compensating-control',
|
||||
text: 'Compensating controls in place: WAF rules, network segmentation, monitoring.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-15T00:00:00Z',
|
||||
endDate: '2025-02-15T23:59:59Z',
|
||||
},
|
||||
labels: { team: 'infrastructure' },
|
||||
createdBy: 'ops@example.com',
|
||||
createdAt: '2025-01-10T14:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-003',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'legacy-crypto-exception',
|
||||
displayName: 'Legacy Crypto Library',
|
||||
status: 'draft',
|
||||
severity: 'medium',
|
||||
scope: {
|
||||
type: 'tenant',
|
||||
tenantId: 'tenant-dev',
|
||||
},
|
||||
justification: {
|
||||
text: 'Migration in progress. ETA: 2 weeks.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-20T00:00:00Z',
|
||||
endDate: '2025-02-20T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2025-01-18T11:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-004',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'expired-cert-exception',
|
||||
displayName: 'Expired Certificate Exception',
|
||||
status: 'expired',
|
||||
severity: 'low',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-test-env'],
|
||||
},
|
||||
justification: {
|
||||
text: 'Test environment only, not production facing.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2024-10-01T00:00:00Z',
|
||||
endDate: '2024-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'qa@example.com',
|
||||
createdAt: '2024-09-25T08:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-005',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'rejected-exception',
|
||||
displayName: 'Rejected Risk Exception',
|
||||
status: 'rejected',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'global',
|
||||
},
|
||||
justification: {
|
||||
text: 'Blanket exception for all critical vulns.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-20T16:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
|
||||
let filtered = [...this.mockExceptions];
|
||||
|
||||
if (options?.status) {
|
||||
filtered = filtered.filter((e) => e.status === options.status);
|
||||
}
|
||||
if (options?.severity) {
|
||||
filtered = filtered.filter((e) => e.severity === options.severity);
|
||||
}
|
||||
if (options?.search) {
|
||||
const searchLower = options.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(searchLower) ||
|
||||
e.displayName?.toLowerCase().includes(searchLower) ||
|
||||
e.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
const sortBy = options?.sortBy ?? 'createdAt';
|
||||
const sortOrder = options?.sortOrder ?? 'desc';
|
||||
filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'severity':
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
||||
break;
|
||||
case 'status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
case 'updatedAt':
|
||||
comparison = (a.updatedAt ?? a.createdAt).localeCompare(b.updatedAt ?? b.createdAt);
|
||||
break;
|
||||
default:
|
||||
comparison = a.createdAt.localeCompare(b.createdAt);
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
const limit = options?.limit ?? 20;
|
||||
const items = filtered.slice(0, limit);
|
||||
|
||||
return new Observable((subscriber) => {
|
||||
setTimeout(() => {
|
||||
subscriber.next({
|
||||
items,
|
||||
count: filtered.length,
|
||||
continuationToken: filtered.length > limit ? 'next-page-token' : null,
|
||||
});
|
||||
subscriber.complete();
|
||||
}, 300);
|
||||
});
|
||||
let filtered = [...this.mockExceptions];
|
||||
|
||||
if (options?.status) {
|
||||
filtered = filtered.filter((e) => e.status === options.status);
|
||||
}
|
||||
if (options?.severity) {
|
||||
filtered = filtered.filter((e) => e.severity === options.severity);
|
||||
}
|
||||
if (options?.search) {
|
||||
const searchLower = options.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(searchLower) ||
|
||||
e.displayName?.toLowerCase().includes(searchLower) ||
|
||||
e.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
const sortBy = options?.sortBy ?? 'createdAt';
|
||||
const sortOrder = options?.sortOrder ?? 'desc';
|
||||
filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'severity':
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
||||
break;
|
||||
case 'status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
case 'updatedAt':
|
||||
comparison = (a.updatedAt ?? a.createdAt).localeCompare(b.updatedAt ?? b.createdAt);
|
||||
break;
|
||||
default:
|
||||
comparison = a.createdAt.localeCompare(b.createdAt);
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
const limit = options?.limit ?? 20;
|
||||
const items = filtered.slice(0, limit);
|
||||
|
||||
return new Observable((subscriber) => {
|
||||
setTimeout(() => {
|
||||
subscriber.next({
|
||||
items,
|
||||
count: filtered.length,
|
||||
continuationToken: filtered.length > limit ? 'next-page-token' : null,
|
||||
});
|
||||
subscriber.complete();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
getException(exceptionId: string): Observable<Exception> {
|
||||
@@ -390,11 +390,11 @@ export class MockExceptionApiService implements ExceptionApi {
|
||||
setTimeout(() => {
|
||||
if (exception) {
|
||||
subscriber.next(exception);
|
||||
} else {
|
||||
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
||||
}
|
||||
subscriber.complete();
|
||||
}, 100);
|
||||
} else {
|
||||
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
||||
}
|
||||
subscriber.complete();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -404,26 +404,26 @@ export class MockExceptionApiService implements ExceptionApi {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: `exc-${Math.random().toString(36).slice(2, 10)}`,
|
||||
tenantId: 'tenant-dev',
|
||||
name: exception.name ?? 'new-exception',
|
||||
status: 'draft',
|
||||
severity: exception.severity ?? 'medium',
|
||||
scope: exception.scope ?? { type: 'tenant' },
|
||||
justification: exception.justification ?? { text: '' },
|
||||
timebox: exception.timebox ?? {
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
createdBy: 'ui@stella-ops.local',
|
||||
createdAt: new Date().toISOString(),
|
||||
...exception,
|
||||
} as Exception;
|
||||
|
||||
this.mockExceptions.push(newException);
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next(newException);
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
name: exception.name ?? 'new-exception',
|
||||
status: 'draft',
|
||||
severity: exception.severity ?? 'medium',
|
||||
scope: exception.scope ?? { type: 'tenant' },
|
||||
justification: exception.justification ?? { text: '' },
|
||||
timebox: exception.timebox ?? {
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
createdBy: 'ui@stella-ops.local',
|
||||
createdAt: new Date().toISOString(),
|
||||
...exception,
|
||||
} as Exception;
|
||||
|
||||
this.mockExceptions.push(newException);
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next(newException);
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -433,20 +433,20 @@ export class MockExceptionApiService implements ExceptionApi {
|
||||
if (index === -1) {
|
||||
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...this.mockExceptions[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: 'ui@stella-ops.local',
|
||||
};
|
||||
this.mockExceptions[index] = updated;
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next(updated);
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...this.mockExceptions[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: 'ui@stella-ops.local',
|
||||
};
|
||||
this.mockExceptions[index] = updated;
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next(updated);
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -456,51 +456,51 @@ export class MockExceptionApiService implements ExceptionApi {
|
||||
if (index !== -1) {
|
||||
this.mockExceptions.splice(index, 1);
|
||||
}
|
||||
setTimeout(() => {
|
||||
subscriber.next();
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
setTimeout(() => {
|
||||
subscriber.next();
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
|
||||
return this.updateException(transition.exceptionId, {
|
||||
status: transition.newStatus,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
|
||||
return this.updateException(transition.exceptionId, {
|
||||
status: transition.newStatus,
|
||||
});
|
||||
}
|
||||
|
||||
getStats(): Observable<ExceptionStats> {
|
||||
return new Observable((subscriber) => {
|
||||
const byStatus: Record<string, number> = {
|
||||
draft: 0,
|
||||
pending_review: 0,
|
||||
approved: 0,
|
||||
rejected: 0,
|
||||
expired: 0,
|
||||
revoked: 0,
|
||||
};
|
||||
const bySeverity: Record<string, number> = {
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
|
||||
this.mockExceptions.forEach((e) => {
|
||||
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
|
||||
bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next({
|
||||
total: this.mockExceptions.length,
|
||||
byStatus: byStatus as Record<any, number>,
|
||||
bySeverity: bySeverity as Record<any, number>,
|
||||
expiringWithin7Days: 1,
|
||||
pendingApproval: byStatus['pending_review'],
|
||||
});
|
||||
subscriber.complete();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
rejected: 0,
|
||||
expired: 0,
|
||||
revoked: 0,
|
||||
};
|
||||
const bySeverity: Record<string, number> = {
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
|
||||
this.mockExceptions.forEach((e) => {
|
||||
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
|
||||
bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next({
|
||||
total: this.mockExceptions.length,
|
||||
byStatus: byStatus as Record<any, number>,
|
||||
bySeverity: bySeverity as Record<any, number>,
|
||||
expiringWithin7Days: 1,
|
||||
pendingApproval: byStatus['pending_review'],
|
||||
});
|
||||
subscriber.complete();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,252 +1,252 @@
|
||||
/**
|
||||
* Exception management models for the Exception Center.
|
||||
*/
|
||||
|
||||
export type ExceptionStatus =
|
||||
| 'draft'
|
||||
| 'pending_review'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'expired'
|
||||
| 'revoked';
|
||||
|
||||
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
|
||||
|
||||
export interface Exception {
|
||||
/** Unique exception ID */
|
||||
id: string;
|
||||
|
||||
/** Short title */
|
||||
title: string;
|
||||
|
||||
/** Detailed justification */
|
||||
justification: string;
|
||||
|
||||
/** Exception type */
|
||||
type: ExceptionType;
|
||||
|
||||
/** Current status */
|
||||
status: ExceptionStatus;
|
||||
|
||||
/** Severity being excepted */
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/** Scope definition */
|
||||
scope: ExceptionScope;
|
||||
|
||||
/** Time constraints */
|
||||
timebox: ExceptionTimebox;
|
||||
|
||||
/** Workflow history */
|
||||
workflow: ExceptionWorkflow;
|
||||
|
||||
/** Audit trail */
|
||||
auditLog: ExceptionAuditEntry[];
|
||||
|
||||
/** Associated findings/violations */
|
||||
findings: string[];
|
||||
|
||||
/** Tags for filtering */
|
||||
tags: string[];
|
||||
|
||||
/** Created timestamp */
|
||||
createdAt: string;
|
||||
|
||||
/** Last updated timestamp */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ExceptionScope {
|
||||
/** Affected images (glob patterns allowed) */
|
||||
images?: string[];
|
||||
|
||||
/** Affected CVEs */
|
||||
cves?: string[];
|
||||
|
||||
/** Affected packages */
|
||||
packages?: string[];
|
||||
|
||||
/** Affected licenses */
|
||||
licenses?: string[];
|
||||
|
||||
/** Affected policy rules */
|
||||
policyRules?: string[];
|
||||
|
||||
/** Tenant scope */
|
||||
tenantId?: string;
|
||||
|
||||
/** Environment scope */
|
||||
environments?: string[];
|
||||
}
|
||||
|
||||
export interface ExceptionTimebox {
|
||||
/** Start date */
|
||||
startsAt: string;
|
||||
|
||||
/** Expiration date */
|
||||
expiresAt: string;
|
||||
|
||||
/** Remaining days */
|
||||
remainingDays: number;
|
||||
|
||||
/** Is expired */
|
||||
isExpired: boolean;
|
||||
|
||||
/** Warning threshold (days before expiry) */
|
||||
warnDays: number;
|
||||
|
||||
/** Is in warning period */
|
||||
isWarning: boolean;
|
||||
}
|
||||
|
||||
export interface ExceptionWorkflow {
|
||||
/** Current workflow state */
|
||||
state: ExceptionStatus;
|
||||
|
||||
/** Requested by */
|
||||
requestedBy: string;
|
||||
|
||||
/** Requested at */
|
||||
requestedAt: string;
|
||||
|
||||
/** Approved by */
|
||||
approvedBy?: string;
|
||||
|
||||
/** Approved at */
|
||||
approvedAt?: string;
|
||||
|
||||
/** Revoked by */
|
||||
revokedBy?: string;
|
||||
|
||||
/** Revoked at */
|
||||
revokedAt?: string;
|
||||
|
||||
/** Revocation reason */
|
||||
revocationReason?: string;
|
||||
|
||||
/** Required approvers */
|
||||
requiredApprovers: string[];
|
||||
|
||||
/** Current approvals */
|
||||
approvals: ExceptionApproval[];
|
||||
}
|
||||
|
||||
export interface ExceptionApproval {
|
||||
/** Approver identity */
|
||||
approver: string;
|
||||
|
||||
/** Decision */
|
||||
decision: 'approved' | 'rejected';
|
||||
|
||||
/** Timestamp */
|
||||
at: string;
|
||||
|
||||
/** Optional comment */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionAuditEntry {
|
||||
/** Entry ID */
|
||||
id: string;
|
||||
|
||||
/** Action performed */
|
||||
action: 'created' | 'submitted' | 'approved' | 'rejected' | 'activated' | 'expired' | 'revoked' | 'edited';
|
||||
|
||||
/** Actor */
|
||||
actor: string;
|
||||
|
||||
/** Timestamp */
|
||||
at: string;
|
||||
|
||||
/** Details */
|
||||
details?: string;
|
||||
|
||||
/** Previous values (for edits) */
|
||||
previousValues?: Record<string, unknown>;
|
||||
|
||||
/** New values (for edits) */
|
||||
newValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExceptionFilter {
|
||||
status?: ExceptionStatus[];
|
||||
type?: ExceptionType[];
|
||||
severity?: string[];
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
expiringSoon?: boolean;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionSortOption {
|
||||
field: 'createdAt' | 'updatedAt' | 'expiresAt' | 'severity' | 'title';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ExceptionTransition {
|
||||
from: ExceptionStatus;
|
||||
to: ExceptionStatus;
|
||||
action: string;
|
||||
requiresApproval: boolean;
|
||||
allowedRoles: string[];
|
||||
}
|
||||
|
||||
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
|
||||
{ from: 'draft', to: 'pending_review', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
|
||||
{ from: 'pending_review', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'pending_review', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'pending_review', to: 'rejected', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'approved', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
|
||||
];
|
||||
|
||||
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
|
||||
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
|
||||
{ status: 'pending_review', label: 'Pending Review', color: '#f59e0b' },
|
||||
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
|
||||
{ status: 'rejected', label: 'Rejected', color: '#f472b6' },
|
||||
{ status: 'expired', label: 'Expired', color: '#6b7280' },
|
||||
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Exception ledger entry for timeline display.
|
||||
* @sprint SPRINT_20251226_004_FE_risk_dashboard
|
||||
* @task DASH-12
|
||||
*/
|
||||
export interface ExceptionLedgerEntry {
|
||||
/** Entry ID. */
|
||||
id: string;
|
||||
/** Exception ID. */
|
||||
exceptionId: string;
|
||||
/** Event type. */
|
||||
eventType: 'created' | 'approved' | 'rejected' | 'expired' | 'revoked' | 'extended' | 'modified';
|
||||
/** Event timestamp. */
|
||||
timestamp: string;
|
||||
/** Actor user ID. */
|
||||
actorId: string;
|
||||
/** Actor display name. */
|
||||
actorName?: string;
|
||||
/** Event details. */
|
||||
details?: Record<string, unknown>;
|
||||
/** Comment. */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception summary for risk budget dashboard.
|
||||
* @sprint SPRINT_20251226_004_FE_risk_dashboard
|
||||
* @task DASH-04
|
||||
*/
|
||||
export interface ExceptionSummary {
|
||||
/** Total active exceptions. */
|
||||
active: number;
|
||||
/** Pending approval. */
|
||||
pending: number;
|
||||
/** Expiring within 7 days. */
|
||||
expiringSoon: number;
|
||||
/** Total risk points covered. */
|
||||
riskPointsCovered: number;
|
||||
/** Trace ID. */
|
||||
traceId: string;
|
||||
}
|
||||
/**
|
||||
* Exception management models for the Exception Center.
|
||||
*/
|
||||
|
||||
export type ExceptionStatus =
|
||||
| 'draft'
|
||||
| 'pending_review'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'expired'
|
||||
| 'revoked';
|
||||
|
||||
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
|
||||
|
||||
export interface Exception {
|
||||
/** Unique exception ID */
|
||||
id: string;
|
||||
|
||||
/** Short title */
|
||||
title: string;
|
||||
|
||||
/** Detailed justification */
|
||||
justification: string;
|
||||
|
||||
/** Exception type */
|
||||
type: ExceptionType;
|
||||
|
||||
/** Current status */
|
||||
status: ExceptionStatus;
|
||||
|
||||
/** Severity being excepted */
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/** Scope definition */
|
||||
scope: ExceptionScope;
|
||||
|
||||
/** Time constraints */
|
||||
timebox: ExceptionTimebox;
|
||||
|
||||
/** Workflow history */
|
||||
workflow: ExceptionWorkflow;
|
||||
|
||||
/** Audit trail */
|
||||
auditLog: ExceptionAuditEntry[];
|
||||
|
||||
/** Associated findings/violations */
|
||||
findings: string[];
|
||||
|
||||
/** Tags for filtering */
|
||||
tags: string[];
|
||||
|
||||
/** Created timestamp */
|
||||
createdAt: string;
|
||||
|
||||
/** Last updated timestamp */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ExceptionScope {
|
||||
/** Affected images (glob patterns allowed) */
|
||||
images?: string[];
|
||||
|
||||
/** Affected CVEs */
|
||||
cves?: string[];
|
||||
|
||||
/** Affected packages */
|
||||
packages?: string[];
|
||||
|
||||
/** Affected licenses */
|
||||
licenses?: string[];
|
||||
|
||||
/** Affected policy rules */
|
||||
policyRules?: string[];
|
||||
|
||||
/** Tenant scope */
|
||||
tenantId?: string;
|
||||
|
||||
/** Environment scope */
|
||||
environments?: string[];
|
||||
}
|
||||
|
||||
export interface ExceptionTimebox {
|
||||
/** Start date */
|
||||
startsAt: string;
|
||||
|
||||
/** Expiration date */
|
||||
expiresAt: string;
|
||||
|
||||
/** Remaining days */
|
||||
remainingDays: number;
|
||||
|
||||
/** Is expired */
|
||||
isExpired: boolean;
|
||||
|
||||
/** Warning threshold (days before expiry) */
|
||||
warnDays: number;
|
||||
|
||||
/** Is in warning period */
|
||||
isWarning: boolean;
|
||||
}
|
||||
|
||||
export interface ExceptionWorkflow {
|
||||
/** Current workflow state */
|
||||
state: ExceptionStatus;
|
||||
|
||||
/** Requested by */
|
||||
requestedBy: string;
|
||||
|
||||
/** Requested at */
|
||||
requestedAt: string;
|
||||
|
||||
/** Approved by */
|
||||
approvedBy?: string;
|
||||
|
||||
/** Approved at */
|
||||
approvedAt?: string;
|
||||
|
||||
/** Revoked by */
|
||||
revokedBy?: string;
|
||||
|
||||
/** Revoked at */
|
||||
revokedAt?: string;
|
||||
|
||||
/** Revocation reason */
|
||||
revocationReason?: string;
|
||||
|
||||
/** Required approvers */
|
||||
requiredApprovers: string[];
|
||||
|
||||
/** Current approvals */
|
||||
approvals: ExceptionApproval[];
|
||||
}
|
||||
|
||||
export interface ExceptionApproval {
|
||||
/** Approver identity */
|
||||
approver: string;
|
||||
|
||||
/** Decision */
|
||||
decision: 'approved' | 'rejected';
|
||||
|
||||
/** Timestamp */
|
||||
at: string;
|
||||
|
||||
/** Optional comment */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionAuditEntry {
|
||||
/** Entry ID */
|
||||
id: string;
|
||||
|
||||
/** Action performed */
|
||||
action: 'created' | 'submitted' | 'approved' | 'rejected' | 'activated' | 'expired' | 'revoked' | 'edited';
|
||||
|
||||
/** Actor */
|
||||
actor: string;
|
||||
|
||||
/** Timestamp */
|
||||
at: string;
|
||||
|
||||
/** Details */
|
||||
details?: string;
|
||||
|
||||
/** Previous values (for edits) */
|
||||
previousValues?: Record<string, unknown>;
|
||||
|
||||
/** New values (for edits) */
|
||||
newValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExceptionFilter {
|
||||
status?: ExceptionStatus[];
|
||||
type?: ExceptionType[];
|
||||
severity?: string[];
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
expiringSoon?: boolean;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionSortOption {
|
||||
field: 'createdAt' | 'updatedAt' | 'expiresAt' | 'severity' | 'title';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ExceptionTransition {
|
||||
from: ExceptionStatus;
|
||||
to: ExceptionStatus;
|
||||
action: string;
|
||||
requiresApproval: boolean;
|
||||
allowedRoles: string[];
|
||||
}
|
||||
|
||||
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
|
||||
{ from: 'draft', to: 'pending_review', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
|
||||
{ from: 'pending_review', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'pending_review', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'pending_review', to: 'rejected', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'approved', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
|
||||
];
|
||||
|
||||
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
|
||||
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
|
||||
{ status: 'pending_review', label: 'Pending Review', color: '#f59e0b' },
|
||||
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
|
||||
{ status: 'rejected', label: 'Rejected', color: '#f472b6' },
|
||||
{ status: 'expired', label: 'Expired', color: '#6b7280' },
|
||||
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Exception ledger entry for timeline display.
|
||||
* @sprint SPRINT_20251226_004_FE_risk_dashboard
|
||||
* @task DASH-12
|
||||
*/
|
||||
export interface ExceptionLedgerEntry {
|
||||
/** Entry ID. */
|
||||
id: string;
|
||||
/** Exception ID. */
|
||||
exceptionId: string;
|
||||
/** Event type. */
|
||||
eventType: 'created' | 'approved' | 'rejected' | 'expired' | 'revoked' | 'extended' | 'modified';
|
||||
/** Event timestamp. */
|
||||
timestamp: string;
|
||||
/** Actor user ID. */
|
||||
actorId: string;
|
||||
/** Actor display name. */
|
||||
actorName?: string;
|
||||
/** Event details. */
|
||||
details?: Record<string, unknown>;
|
||||
/** Comment. */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception summary for risk budget dashboard.
|
||||
* @sprint SPRINT_20251226_004_FE_risk_dashboard
|
||||
* @task DASH-04
|
||||
*/
|
||||
export interface ExceptionSummary {
|
||||
/** Total active exceptions. */
|
||||
active: number;
|
||||
/** Pending approval. */
|
||||
pending: number;
|
||||
/** Expiring within 7 days. */
|
||||
expiringSoon: number;
|
||||
/** Total risk points covered. */
|
||||
riskPointsCovered: number;
|
||||
/** Trace ID. */
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,419 +1,419 @@
|
||||
export type NotifyChannelType =
|
||||
| 'Slack'
|
||||
| 'Teams'
|
||||
| 'Email'
|
||||
| 'Webhook'
|
||||
| 'Custom';
|
||||
|
||||
export type ChannelHealthStatus = 'Healthy' | 'Degraded' | 'Unhealthy';
|
||||
|
||||
export type NotifyDeliveryStatus =
|
||||
| 'Pending'
|
||||
| 'Sent'
|
||||
| 'Failed'
|
||||
| 'Throttled'
|
||||
| 'Digested'
|
||||
| 'Dropped';
|
||||
|
||||
export type NotifyDeliveryAttemptStatus =
|
||||
| 'Enqueued'
|
||||
| 'Sending'
|
||||
| 'Succeeded'
|
||||
| 'Failed'
|
||||
| 'Throttled'
|
||||
| 'Skipped';
|
||||
|
||||
export type NotifyDeliveryFormat =
|
||||
| 'Slack'
|
||||
| 'Teams'
|
||||
| 'Email'
|
||||
| 'Webhook'
|
||||
| 'Json';
|
||||
|
||||
export interface NotifyChannelLimits {
|
||||
readonly concurrency?: number | null;
|
||||
readonly requestsPerMinute?: number | null;
|
||||
readonly timeout?: string | null;
|
||||
readonly maxBatchSize?: number | null;
|
||||
}
|
||||
|
||||
export interface NotifyChannelConfig {
|
||||
readonly secretRef: string;
|
||||
readonly target?: string;
|
||||
readonly endpoint?: string;
|
||||
readonly properties?: Record<string, string>;
|
||||
readonly limits?: NotifyChannelLimits | null;
|
||||
}
|
||||
|
||||
export interface NotifyChannel {
|
||||
readonly schemaVersion?: string;
|
||||
readonly channelId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly displayName?: string;
|
||||
readonly description?: string;
|
||||
readonly type: NotifyChannelType;
|
||||
readonly enabled: boolean;
|
||||
readonly config: NotifyChannelConfig;
|
||||
readonly labels?: Record<string, string>;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdBy?: string;
|
||||
readonly createdAt?: string;
|
||||
readonly updatedBy?: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyRuleMatchVex {
|
||||
readonly includeAcceptedJustifications?: boolean;
|
||||
readonly includeRejectedJustifications?: boolean;
|
||||
readonly includeUnknownJustifications?: boolean;
|
||||
readonly justificationKinds?: readonly string[];
|
||||
}
|
||||
|
||||
export interface NotifyRuleMatch {
|
||||
readonly eventKinds?: readonly string[];
|
||||
readonly namespaces?: readonly string[];
|
||||
readonly repositories?: readonly string[];
|
||||
readonly digests?: readonly string[];
|
||||
readonly labels?: readonly string[];
|
||||
readonly componentPurls?: readonly string[];
|
||||
readonly minSeverity?: string | null;
|
||||
readonly verdicts?: readonly string[];
|
||||
readonly kevOnly?: boolean | null;
|
||||
readonly vex?: NotifyRuleMatchVex | null;
|
||||
}
|
||||
|
||||
export interface NotifyRuleAction {
|
||||
readonly actionId: string;
|
||||
readonly channel: string;
|
||||
readonly template?: string;
|
||||
readonly digest?: string;
|
||||
readonly throttle?: string | null;
|
||||
readonly locale?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NotifyRule {
|
||||
readonly schemaVersion?: string;
|
||||
readonly ruleId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly match: NotifyRuleMatch;
|
||||
readonly actions: readonly NotifyRuleAction[];
|
||||
readonly labels?: Record<string, string>;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdBy?: string;
|
||||
readonly createdAt?: string;
|
||||
readonly updatedBy?: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveryAttempt {
|
||||
readonly timestamp: string;
|
||||
readonly status: NotifyDeliveryAttemptStatus;
|
||||
readonly statusCode?: number;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveryRendered {
|
||||
readonly channelType: NotifyChannelType;
|
||||
readonly format: NotifyDeliveryFormat;
|
||||
readonly target: string;
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly summary?: string;
|
||||
readonly textBody?: string;
|
||||
readonly locale?: string;
|
||||
readonly bodyHash?: string;
|
||||
readonly attachments?: readonly string[];
|
||||
}
|
||||
|
||||
export interface NotifyDelivery {
|
||||
readonly deliveryId: string;
|
||||
readonly tenantId: string;
|
||||
readonly ruleId: string;
|
||||
readonly actionId: string;
|
||||
readonly eventId: string;
|
||||
readonly kind: string;
|
||||
readonly status: NotifyDeliveryStatus;
|
||||
readonly statusReason?: string;
|
||||
readonly rendered?: NotifyDeliveryRendered;
|
||||
readonly attempts?: readonly NotifyDeliveryAttempt[];
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdAt: string;
|
||||
readonly sentAt?: string;
|
||||
readonly completedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveriesQueryOptions {
|
||||
readonly status?: NotifyDeliveryStatus;
|
||||
readonly since?: string;
|
||||
readonly limit?: number;
|
||||
readonly continuationToken?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveriesResponse {
|
||||
readonly items: readonly NotifyDelivery[];
|
||||
readonly continuationToken?: string | null;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface ChannelHealthResponse {
|
||||
readonly tenantId: string;
|
||||
readonly channelId: string;
|
||||
readonly status: ChannelHealthStatus;
|
||||
readonly message?: string | null;
|
||||
readonly checkedAt: string;
|
||||
readonly traceId: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ChannelTestSendRequest {
|
||||
readonly target?: string;
|
||||
readonly templateId?: string;
|
||||
readonly title?: string;
|
||||
readonly summary?: string;
|
||||
readonly body?: string;
|
||||
readonly textBody?: string;
|
||||
readonly locale?: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly attachments?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ChannelTestSendResponse {
|
||||
readonly tenantId: string;
|
||||
readonly channelId: string;
|
||||
readonly preview: NotifyDeliveryRendered;
|
||||
readonly queuedAt: string;
|
||||
readonly traceId: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-NOTIFY-39-001: Digest scheduling, quiet-hours, throttle management.
|
||||
*/
|
||||
|
||||
/** Digest frequency. */
|
||||
export type DigestFrequency = 'instant' | 'hourly' | 'daily' | 'weekly';
|
||||
|
||||
/** Digest schedule. */
|
||||
export interface DigestSchedule {
|
||||
readonly scheduleId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly frequency: DigestFrequency;
|
||||
readonly timezone: string;
|
||||
readonly hour?: number;
|
||||
readonly dayOfWeek?: number;
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Digest schedules response. */
|
||||
export interface DigestSchedulesResponse {
|
||||
readonly items: readonly DigestSchedule[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Quiet hour window. */
|
||||
export interface QuietHourWindow {
|
||||
readonly timezone: string;
|
||||
readonly days: readonly string[];
|
||||
readonly start: string;
|
||||
readonly end: string;
|
||||
}
|
||||
|
||||
/** Quiet hour exemption. */
|
||||
export interface QuietHourExemption {
|
||||
readonly eventKinds: readonly string[];
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/** Quiet hours configuration. */
|
||||
export interface QuietHours {
|
||||
readonly quietHoursId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly windows: readonly QuietHourWindow[];
|
||||
readonly exemptions?: readonly QuietHourExemption[];
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Quiet hours response. */
|
||||
export interface QuietHoursResponse {
|
||||
readonly items: readonly QuietHours[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Throttle configuration. */
|
||||
export interface ThrottleConfig {
|
||||
readonly throttleId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly windowSeconds: number;
|
||||
readonly maxEvents: number;
|
||||
readonly burstLimit?: number;
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Throttle configs response. */
|
||||
export interface ThrottleConfigsResponse {
|
||||
readonly items: readonly ThrottleConfig[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Simulation request. */
|
||||
export interface NotifySimulationRequest {
|
||||
readonly eventKind: string;
|
||||
readonly payload: Record<string, unknown>;
|
||||
readonly targetChannels?: readonly string[];
|
||||
readonly dryRun: boolean;
|
||||
}
|
||||
|
||||
/** Simulation result. */
|
||||
export interface NotifySimulationResult {
|
||||
readonly simulationId: string;
|
||||
readonly matchedRules: readonly string[];
|
||||
readonly wouldNotify: readonly {
|
||||
readonly channelId: string;
|
||||
readonly actionId: string;
|
||||
readonly template: string;
|
||||
readonly digest: DigestFrequency;
|
||||
}[];
|
||||
readonly throttled: boolean;
|
||||
readonly quietHoursActive: boolean;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-NOTIFY-40-001: Escalation, localization, channel health, ack verification.
|
||||
*/
|
||||
|
||||
/** Escalation policy. */
|
||||
export interface EscalationPolicy {
|
||||
readonly policyId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly levels: readonly EscalationLevel[];
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Escalation level. */
|
||||
export interface EscalationLevel {
|
||||
readonly level: number;
|
||||
readonly delayMinutes: number;
|
||||
readonly channels: readonly string[];
|
||||
readonly notifyOnAck: boolean;
|
||||
}
|
||||
|
||||
/** Escalation policies response. */
|
||||
export interface EscalationPoliciesResponse {
|
||||
readonly items: readonly EscalationPolicy[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Localization config. */
|
||||
export interface LocalizationConfig {
|
||||
readonly localeId: string;
|
||||
readonly tenantId: string;
|
||||
readonly locale: string;
|
||||
readonly name: string;
|
||||
readonly templates: Record<string, string>;
|
||||
readonly dateFormat?: string;
|
||||
readonly timeFormat?: string;
|
||||
readonly timezone?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Localization configs response. */
|
||||
export interface LocalizationConfigsResponse {
|
||||
readonly items: readonly LocalizationConfig[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Incident for acknowledgment. */
|
||||
export interface NotifyIncident {
|
||||
readonly incidentId: string;
|
||||
readonly tenantId: string;
|
||||
readonly title: string;
|
||||
readonly severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
|
||||
readonly status: 'open' | 'acknowledged' | 'resolved' | 'closed';
|
||||
readonly eventIds: readonly string[];
|
||||
readonly escalationLevel?: number;
|
||||
readonly escalationPolicyId?: string;
|
||||
readonly assignee?: string;
|
||||
readonly acknowledgedAt?: string;
|
||||
readonly acknowledgedBy?: string;
|
||||
readonly resolvedAt?: string;
|
||||
readonly resolvedBy?: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Incidents response. */
|
||||
export interface NotifyIncidentsResponse {
|
||||
readonly items: readonly NotifyIncident[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Acknowledgment request. */
|
||||
export interface AckRequest {
|
||||
readonly ackToken: string;
|
||||
readonly note?: string;
|
||||
}
|
||||
|
||||
/** Acknowledgment response. */
|
||||
export interface AckResponse {
|
||||
readonly incidentId: string;
|
||||
readonly acknowledged: boolean;
|
||||
readonly acknowledgedAt: string;
|
||||
readonly acknowledgedBy: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Notify query options. */
|
||||
export interface NotifyQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly pageToken?: string;
|
||||
readonly pageSize?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Notify error codes. */
|
||||
export type NotifyErrorCode =
|
||||
| 'ERR_NOTIFY_CHANNEL_NOT_FOUND'
|
||||
| 'ERR_NOTIFY_RULE_NOT_FOUND'
|
||||
| 'ERR_NOTIFY_INVALID_CONFIG'
|
||||
| 'ERR_NOTIFY_RATE_LIMIT'
|
||||
| 'ERR_NOTIFY_ACK_INVALID'
|
||||
| 'ERR_NOTIFY_ACK_EXPIRED';
|
||||
|
||||
export type NotifyChannelType =
|
||||
| 'Slack'
|
||||
| 'Teams'
|
||||
| 'Email'
|
||||
| 'Webhook'
|
||||
| 'Custom';
|
||||
|
||||
export type ChannelHealthStatus = 'Healthy' | 'Degraded' | 'Unhealthy';
|
||||
|
||||
export type NotifyDeliveryStatus =
|
||||
| 'Pending'
|
||||
| 'Sent'
|
||||
| 'Failed'
|
||||
| 'Throttled'
|
||||
| 'Digested'
|
||||
| 'Dropped';
|
||||
|
||||
export type NotifyDeliveryAttemptStatus =
|
||||
| 'Enqueued'
|
||||
| 'Sending'
|
||||
| 'Succeeded'
|
||||
| 'Failed'
|
||||
| 'Throttled'
|
||||
| 'Skipped';
|
||||
|
||||
export type NotifyDeliveryFormat =
|
||||
| 'Slack'
|
||||
| 'Teams'
|
||||
| 'Email'
|
||||
| 'Webhook'
|
||||
| 'Json';
|
||||
|
||||
export interface NotifyChannelLimits {
|
||||
readonly concurrency?: number | null;
|
||||
readonly requestsPerMinute?: number | null;
|
||||
readonly timeout?: string | null;
|
||||
readonly maxBatchSize?: number | null;
|
||||
}
|
||||
|
||||
export interface NotifyChannelConfig {
|
||||
readonly secretRef: string;
|
||||
readonly target?: string;
|
||||
readonly endpoint?: string;
|
||||
readonly properties?: Record<string, string>;
|
||||
readonly limits?: NotifyChannelLimits | null;
|
||||
}
|
||||
|
||||
export interface NotifyChannel {
|
||||
readonly schemaVersion?: string;
|
||||
readonly channelId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly displayName?: string;
|
||||
readonly description?: string;
|
||||
readonly type: NotifyChannelType;
|
||||
readonly enabled: boolean;
|
||||
readonly config: NotifyChannelConfig;
|
||||
readonly labels?: Record<string, string>;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdBy?: string;
|
||||
readonly createdAt?: string;
|
||||
readonly updatedBy?: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyRuleMatchVex {
|
||||
readonly includeAcceptedJustifications?: boolean;
|
||||
readonly includeRejectedJustifications?: boolean;
|
||||
readonly includeUnknownJustifications?: boolean;
|
||||
readonly justificationKinds?: readonly string[];
|
||||
}
|
||||
|
||||
export interface NotifyRuleMatch {
|
||||
readonly eventKinds?: readonly string[];
|
||||
readonly namespaces?: readonly string[];
|
||||
readonly repositories?: readonly string[];
|
||||
readonly digests?: readonly string[];
|
||||
readonly labels?: readonly string[];
|
||||
readonly componentPurls?: readonly string[];
|
||||
readonly minSeverity?: string | null;
|
||||
readonly verdicts?: readonly string[];
|
||||
readonly kevOnly?: boolean | null;
|
||||
readonly vex?: NotifyRuleMatchVex | null;
|
||||
}
|
||||
|
||||
export interface NotifyRuleAction {
|
||||
readonly actionId: string;
|
||||
readonly channel: string;
|
||||
readonly template?: string;
|
||||
readonly digest?: string;
|
||||
readonly throttle?: string | null;
|
||||
readonly locale?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NotifyRule {
|
||||
readonly schemaVersion?: string;
|
||||
readonly ruleId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly match: NotifyRuleMatch;
|
||||
readonly actions: readonly NotifyRuleAction[];
|
||||
readonly labels?: Record<string, string>;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdBy?: string;
|
||||
readonly createdAt?: string;
|
||||
readonly updatedBy?: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveryAttempt {
|
||||
readonly timestamp: string;
|
||||
readonly status: NotifyDeliveryAttemptStatus;
|
||||
readonly statusCode?: number;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveryRendered {
|
||||
readonly channelType: NotifyChannelType;
|
||||
readonly format: NotifyDeliveryFormat;
|
||||
readonly target: string;
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly summary?: string;
|
||||
readonly textBody?: string;
|
||||
readonly locale?: string;
|
||||
readonly bodyHash?: string;
|
||||
readonly attachments?: readonly string[];
|
||||
}
|
||||
|
||||
export interface NotifyDelivery {
|
||||
readonly deliveryId: string;
|
||||
readonly tenantId: string;
|
||||
readonly ruleId: string;
|
||||
readonly actionId: string;
|
||||
readonly eventId: string;
|
||||
readonly kind: string;
|
||||
readonly status: NotifyDeliveryStatus;
|
||||
readonly statusReason?: string;
|
||||
readonly rendered?: NotifyDeliveryRendered;
|
||||
readonly attempts?: readonly NotifyDeliveryAttempt[];
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdAt: string;
|
||||
readonly sentAt?: string;
|
||||
readonly completedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveriesQueryOptions {
|
||||
readonly status?: NotifyDeliveryStatus;
|
||||
readonly since?: string;
|
||||
readonly limit?: number;
|
||||
readonly continuationToken?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveriesResponse {
|
||||
readonly items: readonly NotifyDelivery[];
|
||||
readonly continuationToken?: string | null;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface ChannelHealthResponse {
|
||||
readonly tenantId: string;
|
||||
readonly channelId: string;
|
||||
readonly status: ChannelHealthStatus;
|
||||
readonly message?: string | null;
|
||||
readonly checkedAt: string;
|
||||
readonly traceId: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ChannelTestSendRequest {
|
||||
readonly target?: string;
|
||||
readonly templateId?: string;
|
||||
readonly title?: string;
|
||||
readonly summary?: string;
|
||||
readonly body?: string;
|
||||
readonly textBody?: string;
|
||||
readonly locale?: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly attachments?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ChannelTestSendResponse {
|
||||
readonly tenantId: string;
|
||||
readonly channelId: string;
|
||||
readonly preview: NotifyDeliveryRendered;
|
||||
readonly queuedAt: string;
|
||||
readonly traceId: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-NOTIFY-39-001: Digest scheduling, quiet-hours, throttle management.
|
||||
*/
|
||||
|
||||
/** Digest frequency. */
|
||||
export type DigestFrequency = 'instant' | 'hourly' | 'daily' | 'weekly';
|
||||
|
||||
/** Digest schedule. */
|
||||
export interface DigestSchedule {
|
||||
readonly scheduleId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly frequency: DigestFrequency;
|
||||
readonly timezone: string;
|
||||
readonly hour?: number;
|
||||
readonly dayOfWeek?: number;
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Digest schedules response. */
|
||||
export interface DigestSchedulesResponse {
|
||||
readonly items: readonly DigestSchedule[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Quiet hour window. */
|
||||
export interface QuietHourWindow {
|
||||
readonly timezone: string;
|
||||
readonly days: readonly string[];
|
||||
readonly start: string;
|
||||
readonly end: string;
|
||||
}
|
||||
|
||||
/** Quiet hour exemption. */
|
||||
export interface QuietHourExemption {
|
||||
readonly eventKinds: readonly string[];
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/** Quiet hours configuration. */
|
||||
export interface QuietHours {
|
||||
readonly quietHoursId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly windows: readonly QuietHourWindow[];
|
||||
readonly exemptions?: readonly QuietHourExemption[];
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Quiet hours response. */
|
||||
export interface QuietHoursResponse {
|
||||
readonly items: readonly QuietHours[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Throttle configuration. */
|
||||
export interface ThrottleConfig {
|
||||
readonly throttleId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly windowSeconds: number;
|
||||
readonly maxEvents: number;
|
||||
readonly burstLimit?: number;
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Throttle configs response. */
|
||||
export interface ThrottleConfigsResponse {
|
||||
readonly items: readonly ThrottleConfig[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Simulation request. */
|
||||
export interface NotifySimulationRequest {
|
||||
readonly eventKind: string;
|
||||
readonly payload: Record<string, unknown>;
|
||||
readonly targetChannels?: readonly string[];
|
||||
readonly dryRun: boolean;
|
||||
}
|
||||
|
||||
/** Simulation result. */
|
||||
export interface NotifySimulationResult {
|
||||
readonly simulationId: string;
|
||||
readonly matchedRules: readonly string[];
|
||||
readonly wouldNotify: readonly {
|
||||
readonly channelId: string;
|
||||
readonly actionId: string;
|
||||
readonly template: string;
|
||||
readonly digest: DigestFrequency;
|
||||
}[];
|
||||
readonly throttled: boolean;
|
||||
readonly quietHoursActive: boolean;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-NOTIFY-40-001: Escalation, localization, channel health, ack verification.
|
||||
*/
|
||||
|
||||
/** Escalation policy. */
|
||||
export interface EscalationPolicy {
|
||||
readonly policyId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly levels: readonly EscalationLevel[];
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Escalation level. */
|
||||
export interface EscalationLevel {
|
||||
readonly level: number;
|
||||
readonly delayMinutes: number;
|
||||
readonly channels: readonly string[];
|
||||
readonly notifyOnAck: boolean;
|
||||
}
|
||||
|
||||
/** Escalation policies response. */
|
||||
export interface EscalationPoliciesResponse {
|
||||
readonly items: readonly EscalationPolicy[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Localization config. */
|
||||
export interface LocalizationConfig {
|
||||
readonly localeId: string;
|
||||
readonly tenantId: string;
|
||||
readonly locale: string;
|
||||
readonly name: string;
|
||||
readonly templates: Record<string, string>;
|
||||
readonly dateFormat?: string;
|
||||
readonly timeFormat?: string;
|
||||
readonly timezone?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Localization configs response. */
|
||||
export interface LocalizationConfigsResponse {
|
||||
readonly items: readonly LocalizationConfig[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Incident for acknowledgment. */
|
||||
export interface NotifyIncident {
|
||||
readonly incidentId: string;
|
||||
readonly tenantId: string;
|
||||
readonly title: string;
|
||||
readonly severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
|
||||
readonly status: 'open' | 'acknowledged' | 'resolved' | 'closed';
|
||||
readonly eventIds: readonly string[];
|
||||
readonly escalationLevel?: number;
|
||||
readonly escalationPolicyId?: string;
|
||||
readonly assignee?: string;
|
||||
readonly acknowledgedAt?: string;
|
||||
readonly acknowledgedBy?: string;
|
||||
readonly resolvedAt?: string;
|
||||
readonly resolvedBy?: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Incidents response. */
|
||||
export interface NotifyIncidentsResponse {
|
||||
readonly items: readonly NotifyIncident[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Acknowledgment request. */
|
||||
export interface AckRequest {
|
||||
readonly ackToken: string;
|
||||
readonly note?: string;
|
||||
}
|
||||
|
||||
/** Acknowledgment response. */
|
||||
export interface AckResponse {
|
||||
readonly incidentId: string;
|
||||
readonly acknowledged: boolean;
|
||||
readonly acknowledgedAt: string;
|
||||
readonly acknowledgedBy: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Notify query options. */
|
||||
export interface NotifyQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly pageToken?: string;
|
||||
readonly pageSize?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Notify error codes. */
|
||||
export type NotifyErrorCode =
|
||||
| 'ERR_NOTIFY_CHANNEL_NOT_FOUND'
|
||||
| 'ERR_NOTIFY_RULE_NOT_FOUND'
|
||||
| 'ERR_NOTIFY_INVALID_CONFIG'
|
||||
| 'ERR_NOTIFY_RATE_LIMIT'
|
||||
| 'ERR_NOTIFY_ACK_INVALID'
|
||||
| 'ERR_NOTIFY_ACK_EXPIRED';
|
||||
|
||||
|
||||
@@ -1,128 +1,128 @@
|
||||
export interface PolicyPreviewRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
policy?: PolicyPreviewPolicyDto;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewPolicyDto {
|
||||
content?: string;
|
||||
format?: string;
|
||||
actor?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewFindingDto {
|
||||
id: string;
|
||||
severity: string;
|
||||
environment?: string;
|
||||
source?: string;
|
||||
vendor?: string;
|
||||
license?: string;
|
||||
image?: string;
|
||||
repository?: string;
|
||||
package?: string;
|
||||
purl?: string;
|
||||
cve?: string;
|
||||
path?: string;
|
||||
layerDigest?: string;
|
||||
tags?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewVerdictDto {
|
||||
findingId: string;
|
||||
status: string;
|
||||
ruleName?: string | null;
|
||||
ruleAction?: string | null;
|
||||
notes?: string | null;
|
||||
score?: number | null;
|
||||
configVersion?: string | null;
|
||||
inputs?: Readonly<Record<string, number>>;
|
||||
quietedBy?: string | null;
|
||||
quiet?: boolean | null;
|
||||
unknownConfidence?: number | null;
|
||||
confidenceBand?: string | null;
|
||||
unknownAgeDays?: number | null;
|
||||
sourceTrust?: string | null;
|
||||
reachability?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewDiffDto {
|
||||
findingId: string;
|
||||
baseline: PolicyPreviewVerdictDto;
|
||||
projected: PolicyPreviewVerdictDto;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewIssueDto {
|
||||
code: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewResponseDto {
|
||||
success: boolean;
|
||||
policyDigest: string;
|
||||
revisionId?: string | null;
|
||||
changed: number;
|
||||
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewSample {
|
||||
previewRequest: PolicyPreviewRequestDto;
|
||||
previewResponse: PolicyPreviewResponseDto;
|
||||
}
|
||||
|
||||
export interface PolicyReportRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportResponseDto {
|
||||
report: PolicyReportDocumentDto;
|
||||
dsse?: DsseEnvelopeDto | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportDocumentDto {
|
||||
reportId: string;
|
||||
imageDigest: string;
|
||||
generatedAt: string;
|
||||
verdict: string;
|
||||
policy: PolicyReportPolicyDto;
|
||||
summary: PolicyReportSummaryDto;
|
||||
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportPolicyDto {
|
||||
revisionId?: string | null;
|
||||
digest?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportSummaryDto {
|
||||
total: number;
|
||||
blocked: number;
|
||||
warned: number;
|
||||
ignored: number;
|
||||
quieted: number;
|
||||
}
|
||||
|
||||
export interface DsseEnvelopeDto {
|
||||
payloadType: string;
|
||||
payload: string;
|
||||
signatures: ReadonlyArray<DsseSignatureDto>;
|
||||
}
|
||||
|
||||
export interface DsseSignatureDto {
|
||||
keyId: string;
|
||||
algorithm: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface PolicyReportSample {
|
||||
reportRequest: PolicyReportRequestDto;
|
||||
reportResponse: PolicyReportResponseDto;
|
||||
}
|
||||
export interface PolicyPreviewRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
policy?: PolicyPreviewPolicyDto;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewPolicyDto {
|
||||
content?: string;
|
||||
format?: string;
|
||||
actor?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewFindingDto {
|
||||
id: string;
|
||||
severity: string;
|
||||
environment?: string;
|
||||
source?: string;
|
||||
vendor?: string;
|
||||
license?: string;
|
||||
image?: string;
|
||||
repository?: string;
|
||||
package?: string;
|
||||
purl?: string;
|
||||
cve?: string;
|
||||
path?: string;
|
||||
layerDigest?: string;
|
||||
tags?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewVerdictDto {
|
||||
findingId: string;
|
||||
status: string;
|
||||
ruleName?: string | null;
|
||||
ruleAction?: string | null;
|
||||
notes?: string | null;
|
||||
score?: number | null;
|
||||
configVersion?: string | null;
|
||||
inputs?: Readonly<Record<string, number>>;
|
||||
quietedBy?: string | null;
|
||||
quiet?: boolean | null;
|
||||
unknownConfidence?: number | null;
|
||||
confidenceBand?: string | null;
|
||||
unknownAgeDays?: number | null;
|
||||
sourceTrust?: string | null;
|
||||
reachability?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewDiffDto {
|
||||
findingId: string;
|
||||
baseline: PolicyPreviewVerdictDto;
|
||||
projected: PolicyPreviewVerdictDto;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewIssueDto {
|
||||
code: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewResponseDto {
|
||||
success: boolean;
|
||||
policyDigest: string;
|
||||
revisionId?: string | null;
|
||||
changed: number;
|
||||
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewSample {
|
||||
previewRequest: PolicyPreviewRequestDto;
|
||||
previewResponse: PolicyPreviewResponseDto;
|
||||
}
|
||||
|
||||
export interface PolicyReportRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportResponseDto {
|
||||
report: PolicyReportDocumentDto;
|
||||
dsse?: DsseEnvelopeDto | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportDocumentDto {
|
||||
reportId: string;
|
||||
imageDigest: string;
|
||||
generatedAt: string;
|
||||
verdict: string;
|
||||
policy: PolicyReportPolicyDto;
|
||||
summary: PolicyReportSummaryDto;
|
||||
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportPolicyDto {
|
||||
revisionId?: string | null;
|
||||
digest?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportSummaryDto {
|
||||
total: number;
|
||||
blocked: number;
|
||||
warned: number;
|
||||
ignored: number;
|
||||
quieted: number;
|
||||
}
|
||||
|
||||
export interface DsseEnvelopeDto {
|
||||
payloadType: string;
|
||||
payload: string;
|
||||
signatures: ReadonlyArray<DsseSignatureDto>;
|
||||
}
|
||||
|
||||
export interface DsseSignatureDto {
|
||||
keyId: string;
|
||||
algorithm: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface PolicyReportSample {
|
||||
reportRequest: PolicyReportRequestDto;
|
||||
reportResponse: PolicyReportResponseDto;
|
||||
}
|
||||
|
||||
@@ -1,163 +1,163 @@
|
||||
/**
|
||||
* Policy gate models for release flow indicators.
|
||||
*/
|
||||
|
||||
export interface PolicyGateStatus {
|
||||
/** Overall gate status */
|
||||
status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
|
||||
|
||||
/** Policy evaluation ID */
|
||||
evaluationId: string;
|
||||
|
||||
/** Target artifact (image, SBOM, etc.) */
|
||||
targetRef: string;
|
||||
|
||||
/** Policy set that was evaluated */
|
||||
policySetId: string;
|
||||
|
||||
/** Individual gate results */
|
||||
gates: PolicyGate[];
|
||||
|
||||
/** Blocking issues preventing publish */
|
||||
blockingIssues: PolicyBlockingIssue[];
|
||||
|
||||
/** Warning-level issues */
|
||||
warnings: PolicyWarning[];
|
||||
|
||||
/** Remediation hints for failures */
|
||||
remediationHints: PolicyRemediationHint[];
|
||||
|
||||
/** Evaluation timestamp */
|
||||
evaluatedAt: string;
|
||||
|
||||
/** Can the artifact be published? */
|
||||
canPublish: boolean;
|
||||
|
||||
/** Reason if publish is blocked */
|
||||
blockReason?: string;
|
||||
}
|
||||
|
||||
export interface PolicyGate {
|
||||
/** Gate identifier */
|
||||
gateId: string;
|
||||
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
|
||||
/** Gate type */
|
||||
type: 'determinism' | 'vulnerability' | 'license' | 'signature' | 'entropy' | 'custom';
|
||||
|
||||
/** Gate result */
|
||||
result: 'passed' | 'failed' | 'warning' | 'skipped';
|
||||
|
||||
/** Is this gate required for publish? */
|
||||
required: boolean;
|
||||
|
||||
/** Gate-specific details */
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
/** Evidence references */
|
||||
evidenceRefs?: string[];
|
||||
}
|
||||
|
||||
export interface PolicyBlockingIssue {
|
||||
/** Issue code */
|
||||
code: string;
|
||||
|
||||
/** Gate that produced this issue */
|
||||
gateId: string;
|
||||
|
||||
/** Issue severity */
|
||||
severity: 'critical' | 'high';
|
||||
|
||||
/** Issue description */
|
||||
message: string;
|
||||
|
||||
/** Affected resource */
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
export interface PolicyWarning {
|
||||
/** Warning code */
|
||||
code: string;
|
||||
|
||||
/** Gate that produced this warning */
|
||||
gateId: string;
|
||||
|
||||
/** Warning message */
|
||||
message: string;
|
||||
|
||||
/** Affected resource */
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
export interface PolicyRemediationHint {
|
||||
/** Which gate/issue this remediates */
|
||||
forGate: string;
|
||||
|
||||
/** Which issue code */
|
||||
forCode?: string;
|
||||
|
||||
/** Hint title */
|
||||
title: string;
|
||||
|
||||
/** Step-by-step instructions */
|
||||
steps: string[];
|
||||
|
||||
/** Documentation link */
|
||||
docsUrl?: string;
|
||||
|
||||
/** CLI command to run */
|
||||
cliCommand?: string;
|
||||
|
||||
/** Estimated effort */
|
||||
effort?: 'trivial' | 'easy' | 'moderate' | 'complex';
|
||||
}
|
||||
|
||||
export interface DeterminismGateDetails {
|
||||
/** Merkle root consistency */
|
||||
merkleRootConsistent: boolean;
|
||||
|
||||
/** Expected Merkle root */
|
||||
expectedMerkleRoot?: string;
|
||||
|
||||
/** Computed Merkle root */
|
||||
computedMerkleRoot?: string;
|
||||
|
||||
/** Fragment verification results */
|
||||
fragmentResults: {
|
||||
fragmentId: string;
|
||||
expected: string;
|
||||
computed: string;
|
||||
match: boolean;
|
||||
}[];
|
||||
|
||||
/** Composition file present */
|
||||
compositionPresent: boolean;
|
||||
|
||||
/** Total fragments */
|
||||
totalFragments: number;
|
||||
|
||||
/** Matching fragments */
|
||||
matchingFragments: number;
|
||||
}
|
||||
|
||||
export interface EntropyGateDetails {
|
||||
/** Overall entropy score */
|
||||
entropyScore: number;
|
||||
|
||||
/** Score threshold for warning */
|
||||
warnThreshold: number;
|
||||
|
||||
/** Score threshold for block */
|
||||
blockThreshold: number;
|
||||
|
||||
/** Action taken based on score */
|
||||
action: 'allow' | 'warn' | 'block';
|
||||
|
||||
/** High entropy files count */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Suspicious patterns detected */
|
||||
suspiciousPatterns: string[];
|
||||
}
|
||||
/**
|
||||
* Policy gate models for release flow indicators.
|
||||
*/
|
||||
|
||||
export interface PolicyGateStatus {
|
||||
/** Overall gate status */
|
||||
status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
|
||||
|
||||
/** Policy evaluation ID */
|
||||
evaluationId: string;
|
||||
|
||||
/** Target artifact (image, SBOM, etc.) */
|
||||
targetRef: string;
|
||||
|
||||
/** Policy set that was evaluated */
|
||||
policySetId: string;
|
||||
|
||||
/** Individual gate results */
|
||||
gates: PolicyGate[];
|
||||
|
||||
/** Blocking issues preventing publish */
|
||||
blockingIssues: PolicyBlockingIssue[];
|
||||
|
||||
/** Warning-level issues */
|
||||
warnings: PolicyWarning[];
|
||||
|
||||
/** Remediation hints for failures */
|
||||
remediationHints: PolicyRemediationHint[];
|
||||
|
||||
/** Evaluation timestamp */
|
||||
evaluatedAt: string;
|
||||
|
||||
/** Can the artifact be published? */
|
||||
canPublish: boolean;
|
||||
|
||||
/** Reason if publish is blocked */
|
||||
blockReason?: string;
|
||||
}
|
||||
|
||||
export interface PolicyGate {
|
||||
/** Gate identifier */
|
||||
gateId: string;
|
||||
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
|
||||
/** Gate type */
|
||||
type: 'determinism' | 'vulnerability' | 'license' | 'signature' | 'entropy' | 'custom';
|
||||
|
||||
/** Gate result */
|
||||
result: 'passed' | 'failed' | 'warning' | 'skipped';
|
||||
|
||||
/** Is this gate required for publish? */
|
||||
required: boolean;
|
||||
|
||||
/** Gate-specific details */
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
/** Evidence references */
|
||||
evidenceRefs?: string[];
|
||||
}
|
||||
|
||||
export interface PolicyBlockingIssue {
|
||||
/** Issue code */
|
||||
code: string;
|
||||
|
||||
/** Gate that produced this issue */
|
||||
gateId: string;
|
||||
|
||||
/** Issue severity */
|
||||
severity: 'critical' | 'high';
|
||||
|
||||
/** Issue description */
|
||||
message: string;
|
||||
|
||||
/** Affected resource */
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
export interface PolicyWarning {
|
||||
/** Warning code */
|
||||
code: string;
|
||||
|
||||
/** Gate that produced this warning */
|
||||
gateId: string;
|
||||
|
||||
/** Warning message */
|
||||
message: string;
|
||||
|
||||
/** Affected resource */
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
export interface PolicyRemediationHint {
|
||||
/** Which gate/issue this remediates */
|
||||
forGate: string;
|
||||
|
||||
/** Which issue code */
|
||||
forCode?: string;
|
||||
|
||||
/** Hint title */
|
||||
title: string;
|
||||
|
||||
/** Step-by-step instructions */
|
||||
steps: string[];
|
||||
|
||||
/** Documentation link */
|
||||
docsUrl?: string;
|
||||
|
||||
/** CLI command to run */
|
||||
cliCommand?: string;
|
||||
|
||||
/** Estimated effort */
|
||||
effort?: 'trivial' | 'easy' | 'moderate' | 'complex';
|
||||
}
|
||||
|
||||
export interface DeterminismGateDetails {
|
||||
/** Merkle root consistency */
|
||||
merkleRootConsistent: boolean;
|
||||
|
||||
/** Expected Merkle root */
|
||||
expectedMerkleRoot?: string;
|
||||
|
||||
/** Computed Merkle root */
|
||||
computedMerkleRoot?: string;
|
||||
|
||||
/** Fragment verification results */
|
||||
fragmentResults: {
|
||||
fragmentId: string;
|
||||
expected: string;
|
||||
computed: string;
|
||||
match: boolean;
|
||||
}[];
|
||||
|
||||
/** Composition file present */
|
||||
compositionPresent: boolean;
|
||||
|
||||
/** Total fragments */
|
||||
totalFragments: number;
|
||||
|
||||
/** Matching fragments */
|
||||
matchingFragments: number;
|
||||
}
|
||||
|
||||
export interface EntropyGateDetails {
|
||||
/** Overall entropy score */
|
||||
entropyScore: number;
|
||||
|
||||
/** Score threshold for warning */
|
||||
warnThreshold: number;
|
||||
|
||||
/** Score threshold for block */
|
||||
blockThreshold: number;
|
||||
|
||||
/** Action taken based on score */
|
||||
action: 'allow' | 'warn' | 'block';
|
||||
|
||||
/** High entropy files count */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Suspicious patterns detected */
|
||||
suspiciousPatterns: string[];
|
||||
}
|
||||
|
||||
@@ -1,373 +1,373 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
Release,
|
||||
ReleaseArtifact,
|
||||
PolicyEvaluation,
|
||||
PolicyGateResult,
|
||||
DeterminismGateDetails,
|
||||
RemediationHint,
|
||||
DeterminismFeatureFlags,
|
||||
PolicyGateStatus,
|
||||
} from './release.models';
|
||||
|
||||
/**
|
||||
* Injection token for Release API client.
|
||||
*/
|
||||
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
|
||||
|
||||
/**
|
||||
* Release API interface.
|
||||
*/
|
||||
export interface ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release>;
|
||||
listReleases(): Observable<readonly Release[]>;
|
||||
publishRelease(releaseId: string): Observable<Release>;
|
||||
cancelRelease(releaseId: string): Observable<Release>;
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data Fixtures
|
||||
// ============================================================================
|
||||
|
||||
const determinismPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-001',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'passed',
|
||||
message: 'Merkle root consistent. All fragment attestations verified.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-abc123?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f6...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const determinismFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-002',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'failed',
|
||||
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-def456?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a6...',
|
||||
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: [
|
||||
'sha256:layer3digest...',
|
||||
'sha256:layer5digest...',
|
||||
],
|
||||
},
|
||||
},
|
||||
remediation: {
|
||||
gateType: 'determinism',
|
||||
severity: 'critical',
|
||||
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
|
||||
steps: [
|
||||
{
|
||||
action: 'rebuild',
|
||||
title: 'Rebuild with deterministic toolchain',
|
||||
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
|
||||
command: 'stella scan --deterministic --sign --push',
|
||||
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide provenance attestation',
|
||||
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
|
||||
documentationUrl: 'https://docs.stellaops.io/provenance',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Re-sign with valid key',
|
||||
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
|
||||
command: 'stella sign --artifact sha256:...',
|
||||
automated: true,
|
||||
},
|
||||
{
|
||||
action: 'request-exception',
|
||||
title: 'Request policy exception',
|
||||
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '15-30 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const vulnerabilityPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-vuln-001',
|
||||
gateType: 'vulnerability',
|
||||
name: 'Vulnerability Scan',
|
||||
status: 'passed',
|
||||
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const entropyWarningGate: PolicyGateResult = {
|
||||
gateId: 'gate-ent-001',
|
||||
gateType: 'entropy',
|
||||
name: 'Entropy Analysis',
|
||||
status: 'warning',
|
||||
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
remediation: {
|
||||
gateType: 'entropy',
|
||||
severity: 'medium',
|
||||
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
|
||||
steps: [
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide source provenance',
|
||||
description: 'Attach build provenance or source mappings for high-entropy binaries.',
|
||||
automated: false,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '10 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const licensePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-lic-001',
|
||||
gateType: 'license',
|
||||
name: 'License Compliance',
|
||||
status: 'passed',
|
||||
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const signaturePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-001',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'passed',
|
||||
message: 'Image signature verified against tenant keyring.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
};
|
||||
|
||||
const signatureFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-002',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'failed',
|
||||
message: 'No valid signature found. Image must be signed before release.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
remediation: {
|
||||
gateType: 'signature',
|
||||
severity: 'critical',
|
||||
summary: 'The image is not signed or the signature cannot be verified.',
|
||||
steps: [
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Sign the image',
|
||||
description: 'Sign the image using your tenant signing key.',
|
||||
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '2 minutes',
|
||||
exceptionAllowed: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Artifacts with policy evaluations
|
||||
const passingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-001',
|
||||
name: 'api-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
size: 245_000_000,
|
||||
createdAt: '2025-11-27T08:00:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-001',
|
||||
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
overallStatus: 'passed',
|
||||
gates: [
|
||||
determinismPassingGate,
|
||||
vulnerabilityPassingGate,
|
||||
entropyWarningGate,
|
||||
licensePassingGate,
|
||||
signaturePassingGate,
|
||||
],
|
||||
blockingGates: [],
|
||||
canPublish: true,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
|
||||
merkleRootConsistent: true,
|
||||
contentHash: 'sha256:content1234567890abcdef',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const failingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-002',
|
||||
name: 'worker-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
size: 312_000_000,
|
||||
createdAt: '2025-11-27T07:45:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-002',
|
||||
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
overallStatus: 'failed',
|
||||
gates: [
|
||||
determinismFailingGate,
|
||||
vulnerabilityPassingGate,
|
||||
licensePassingGate,
|
||||
signatureFailingGate,
|
||||
],
|
||||
blockingGates: ['gate-det-002', 'gate-sig-002'],
|
||||
canPublish: false,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a67890',
|
||||
merkleRootConsistent: false,
|
||||
contentHash: 'sha256:content9876543210',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Release fixtures
|
||||
const passingRelease: Release = {
|
||||
releaseId: 'rel-001',
|
||||
name: 'Platform v1.2.3',
|
||||
version: '1.2.3',
|
||||
status: 'pending_approval',
|
||||
createdAt: '2025-11-27T08:30:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [passingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Feature release with API improvements and bug fixes.',
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-001',
|
||||
approver: 'security-team',
|
||||
decision: 'approved',
|
||||
comment: 'Security review passed.',
|
||||
decidedAt: '2025-11-27T09:00:00Z',
|
||||
},
|
||||
{
|
||||
approvalId: 'apr-002',
|
||||
approver: 'release-manager',
|
||||
decision: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const blockedRelease: Release = {
|
||||
releaseId: 'rel-002',
|
||||
name: 'Platform v1.2.4-rc1',
|
||||
version: '1.2.4-rc1',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T07:00:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [failingArtifact],
|
||||
targetEnvironment: 'staging',
|
||||
notes: 'Release candidate blocked due to policy gate failures.',
|
||||
};
|
||||
|
||||
const mixedRelease: Release = {
|
||||
releaseId: 'rel-003',
|
||||
name: 'Platform v1.2.5',
|
||||
version: '1.2.5',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T06:00:00Z',
|
||||
createdBy: 'ci-pipeline',
|
||||
artifacts: [passingArtifact, failingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Multi-artifact release with mixed policy results.',
|
||||
};
|
||||
|
||||
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
|
||||
|
||||
const mockFeatureFlags: DeterminismFeatureFlags = {
|
||||
enabled: true,
|
||||
blockOnFailure: true,
|
||||
warnOnly: false,
|
||||
bypassRoles: ['security-admin', 'release-manager'],
|
||||
requireApprovalForBypass: true,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mock API Implementation
|
||||
// ============================================================================
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockReleaseApi implements ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of(release).pipe(delay(200));
|
||||
}
|
||||
|
||||
listReleases(): Observable<readonly Release[]> {
|
||||
return of(mockReleases).pipe(delay(300));
|
||||
}
|
||||
|
||||
publishRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
// Simulate publish (would update status in real implementation)
|
||||
return of({
|
||||
...release,
|
||||
status: 'published',
|
||||
publishedAt: new Date().toISOString(),
|
||||
} as Release).pipe(delay(500));
|
||||
}
|
||||
|
||||
cancelRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of({
|
||||
...release,
|
||||
status: 'cancelled',
|
||||
} as Release).pipe(delay(300));
|
||||
}
|
||||
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
|
||||
return of(mockFeatureFlags).pipe(delay(100));
|
||||
}
|
||||
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
|
||||
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
|
||||
}
|
||||
}
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
Release,
|
||||
ReleaseArtifact,
|
||||
PolicyEvaluation,
|
||||
PolicyGateResult,
|
||||
DeterminismGateDetails,
|
||||
RemediationHint,
|
||||
DeterminismFeatureFlags,
|
||||
PolicyGateStatus,
|
||||
} from './release.models';
|
||||
|
||||
/**
|
||||
* Injection token for Release API client.
|
||||
*/
|
||||
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
|
||||
|
||||
/**
|
||||
* Release API interface.
|
||||
*/
|
||||
export interface ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release>;
|
||||
listReleases(): Observable<readonly Release[]>;
|
||||
publishRelease(releaseId: string): Observable<Release>;
|
||||
cancelRelease(releaseId: string): Observable<Release>;
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data Fixtures
|
||||
// ============================================================================
|
||||
|
||||
const determinismPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-001',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'passed',
|
||||
message: 'Merkle root consistent. All fragment attestations verified.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-abc123?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f6...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const determinismFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-002',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'failed',
|
||||
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-def456?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a6...',
|
||||
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: [
|
||||
'sha256:layer3digest...',
|
||||
'sha256:layer5digest...',
|
||||
],
|
||||
},
|
||||
},
|
||||
remediation: {
|
||||
gateType: 'determinism',
|
||||
severity: 'critical',
|
||||
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
|
||||
steps: [
|
||||
{
|
||||
action: 'rebuild',
|
||||
title: 'Rebuild with deterministic toolchain',
|
||||
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
|
||||
command: 'stella scan --deterministic --sign --push',
|
||||
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide provenance attestation',
|
||||
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
|
||||
documentationUrl: 'https://docs.stellaops.io/provenance',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Re-sign with valid key',
|
||||
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
|
||||
command: 'stella sign --artifact sha256:...',
|
||||
automated: true,
|
||||
},
|
||||
{
|
||||
action: 'request-exception',
|
||||
title: 'Request policy exception',
|
||||
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '15-30 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const vulnerabilityPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-vuln-001',
|
||||
gateType: 'vulnerability',
|
||||
name: 'Vulnerability Scan',
|
||||
status: 'passed',
|
||||
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const entropyWarningGate: PolicyGateResult = {
|
||||
gateId: 'gate-ent-001',
|
||||
gateType: 'entropy',
|
||||
name: 'Entropy Analysis',
|
||||
status: 'warning',
|
||||
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
remediation: {
|
||||
gateType: 'entropy',
|
||||
severity: 'medium',
|
||||
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
|
||||
steps: [
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide source provenance',
|
||||
description: 'Attach build provenance or source mappings for high-entropy binaries.',
|
||||
automated: false,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '10 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const licensePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-lic-001',
|
||||
gateType: 'license',
|
||||
name: 'License Compliance',
|
||||
status: 'passed',
|
||||
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const signaturePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-001',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'passed',
|
||||
message: 'Image signature verified against tenant keyring.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
};
|
||||
|
||||
const signatureFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-002',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'failed',
|
||||
message: 'No valid signature found. Image must be signed before release.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
remediation: {
|
||||
gateType: 'signature',
|
||||
severity: 'critical',
|
||||
summary: 'The image is not signed or the signature cannot be verified.',
|
||||
steps: [
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Sign the image',
|
||||
description: 'Sign the image using your tenant signing key.',
|
||||
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '2 minutes',
|
||||
exceptionAllowed: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Artifacts with policy evaluations
|
||||
const passingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-001',
|
||||
name: 'api-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
size: 245_000_000,
|
||||
createdAt: '2025-11-27T08:00:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-001',
|
||||
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
overallStatus: 'passed',
|
||||
gates: [
|
||||
determinismPassingGate,
|
||||
vulnerabilityPassingGate,
|
||||
entropyWarningGate,
|
||||
licensePassingGate,
|
||||
signaturePassingGate,
|
||||
],
|
||||
blockingGates: [],
|
||||
canPublish: true,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
|
||||
merkleRootConsistent: true,
|
||||
contentHash: 'sha256:content1234567890abcdef',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const failingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-002',
|
||||
name: 'worker-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
size: 312_000_000,
|
||||
createdAt: '2025-11-27T07:45:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-002',
|
||||
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
overallStatus: 'failed',
|
||||
gates: [
|
||||
determinismFailingGate,
|
||||
vulnerabilityPassingGate,
|
||||
licensePassingGate,
|
||||
signatureFailingGate,
|
||||
],
|
||||
blockingGates: ['gate-det-002', 'gate-sig-002'],
|
||||
canPublish: false,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a67890',
|
||||
merkleRootConsistent: false,
|
||||
contentHash: 'sha256:content9876543210',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Release fixtures
|
||||
const passingRelease: Release = {
|
||||
releaseId: 'rel-001',
|
||||
name: 'Platform v1.2.3',
|
||||
version: '1.2.3',
|
||||
status: 'pending_approval',
|
||||
createdAt: '2025-11-27T08:30:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [passingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Feature release with API improvements and bug fixes.',
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-001',
|
||||
approver: 'security-team',
|
||||
decision: 'approved',
|
||||
comment: 'Security review passed.',
|
||||
decidedAt: '2025-11-27T09:00:00Z',
|
||||
},
|
||||
{
|
||||
approvalId: 'apr-002',
|
||||
approver: 'release-manager',
|
||||
decision: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const blockedRelease: Release = {
|
||||
releaseId: 'rel-002',
|
||||
name: 'Platform v1.2.4-rc1',
|
||||
version: '1.2.4-rc1',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T07:00:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [failingArtifact],
|
||||
targetEnvironment: 'staging',
|
||||
notes: 'Release candidate blocked due to policy gate failures.',
|
||||
};
|
||||
|
||||
const mixedRelease: Release = {
|
||||
releaseId: 'rel-003',
|
||||
name: 'Platform v1.2.5',
|
||||
version: '1.2.5',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T06:00:00Z',
|
||||
createdBy: 'ci-pipeline',
|
||||
artifacts: [passingArtifact, failingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Multi-artifact release with mixed policy results.',
|
||||
};
|
||||
|
||||
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
|
||||
|
||||
const mockFeatureFlags: DeterminismFeatureFlags = {
|
||||
enabled: true,
|
||||
blockOnFailure: true,
|
||||
warnOnly: false,
|
||||
bypassRoles: ['security-admin', 'release-manager'],
|
||||
requireApprovalForBypass: true,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mock API Implementation
|
||||
// ============================================================================
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockReleaseApi implements ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of(release).pipe(delay(200));
|
||||
}
|
||||
|
||||
listReleases(): Observable<readonly Release[]> {
|
||||
return of(mockReleases).pipe(delay(300));
|
||||
}
|
||||
|
||||
publishRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
// Simulate publish (would update status in real implementation)
|
||||
return of({
|
||||
...release,
|
||||
status: 'published',
|
||||
publishedAt: new Date().toISOString(),
|
||||
} as Release).pipe(delay(500));
|
||||
}
|
||||
|
||||
cancelRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of({
|
||||
...release,
|
||||
status: 'cancelled',
|
||||
} as Release).pipe(delay(300));
|
||||
}
|
||||
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
|
||||
return of(mockFeatureFlags).pipe(delay(100));
|
||||
}
|
||||
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
|
||||
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,161 +1,161 @@
|
||||
/**
|
||||
* Release and Policy Gate models for UI-POLICY-DET-01.
|
||||
* Supports determinism-gated release flows with remediation hints.
|
||||
*/
|
||||
|
||||
// Policy gate evaluation status
|
||||
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
|
||||
|
||||
// Types of policy gates
|
||||
export type PolicyGateType =
|
||||
| 'determinism'
|
||||
| 'vulnerability'
|
||||
| 'license'
|
||||
| 'entropy'
|
||||
| 'signature'
|
||||
| 'sbom-completeness'
|
||||
| 'custom';
|
||||
|
||||
// Remediation action types
|
||||
export type RemediationActionType =
|
||||
| 'rebuild'
|
||||
| 'provide-provenance'
|
||||
| 'sign-artifact'
|
||||
| 'update-dependency'
|
||||
| 'request-exception'
|
||||
| 'manual-review';
|
||||
|
||||
/**
|
||||
* A single remediation step with optional automation support.
|
||||
*/
|
||||
export interface RemediationStep {
|
||||
readonly action: RemediationActionType;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly command?: string; // Optional CLI command to run
|
||||
readonly documentationUrl?: string;
|
||||
readonly automated: boolean; // Can be triggered from UI
|
||||
}
|
||||
|
||||
/**
|
||||
* Remediation hints for a failed policy gate.
|
||||
*/
|
||||
export interface RemediationHint {
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly summary: string;
|
||||
readonly steps: readonly RemediationStep[];
|
||||
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
|
||||
readonly exceptionAllowed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual policy gate evaluation result.
|
||||
*/
|
||||
export interface PolicyGateResult {
|
||||
readonly gateId: string;
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly name: string;
|
||||
readonly status: PolicyGateStatus;
|
||||
readonly message: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly blockingPublish: boolean;
|
||||
readonly evidence?: {
|
||||
readonly type: string;
|
||||
readonly url?: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
};
|
||||
readonly remediation?: RemediationHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determinism-specific gate details.
|
||||
*/
|
||||
export interface DeterminismGateDetails {
|
||||
readonly merkleRoot?: string;
|
||||
readonly merkleRootConsistent: boolean;
|
||||
readonly contentHash?: string;
|
||||
readonly compositionManifestUri?: string;
|
||||
readonly fragmentCount?: number;
|
||||
readonly verifiedFragments?: number;
|
||||
readonly failedFragments?: readonly string[]; // Layer digests that failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall policy evaluation for a release artifact.
|
||||
*/
|
||||
export interface PolicyEvaluation {
|
||||
readonly evaluationId: string;
|
||||
readonly artifactDigest: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly overallStatus: PolicyGateStatus;
|
||||
readonly gates: readonly PolicyGateResult[];
|
||||
readonly blockingGates: readonly string[]; // Gate IDs that block publish
|
||||
readonly canPublish: boolean;
|
||||
readonly determinismDetails?: DeterminismGateDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release artifact with policy evaluation.
|
||||
*/
|
||||
export interface ReleaseArtifact {
|
||||
readonly artifactId: string;
|
||||
readonly name: string;
|
||||
readonly tag: string;
|
||||
readonly digest: string;
|
||||
readonly size: number;
|
||||
readonly createdAt: string;
|
||||
readonly registry: string;
|
||||
readonly policyEvaluation?: PolicyEvaluation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release workflow status.
|
||||
*/
|
||||
export type ReleaseStatus =
|
||||
| 'draft'
|
||||
| 'pending_approval'
|
||||
| 'approved'
|
||||
| 'publishing'
|
||||
| 'published'
|
||||
| 'blocked'
|
||||
| 'cancelled';
|
||||
|
||||
/**
|
||||
* Release with multiple artifacts and policy gates.
|
||||
*/
|
||||
export interface Release {
|
||||
readonly releaseId: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly status: ReleaseStatus;
|
||||
readonly createdAt: string;
|
||||
readonly createdBy: string;
|
||||
readonly artifacts: readonly ReleaseArtifact[];
|
||||
readonly targetEnvironment: string;
|
||||
readonly notes?: string;
|
||||
readonly approvals?: readonly ReleaseApproval[];
|
||||
readonly publishedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release approval record.
|
||||
*/
|
||||
export interface ReleaseApproval {
|
||||
readonly approvalId: string;
|
||||
readonly approver: string;
|
||||
readonly decision: 'approved' | 'rejected' | 'pending';
|
||||
readonly comment?: string;
|
||||
readonly decidedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag configuration for determinism blocking.
|
||||
*/
|
||||
export interface DeterminismFeatureFlags {
|
||||
readonly enabled: boolean;
|
||||
readonly blockOnFailure: boolean;
|
||||
readonly warnOnly: boolean;
|
||||
readonly bypassRoles?: readonly string[];
|
||||
readonly requireApprovalForBypass: boolean;
|
||||
}
|
||||
/**
|
||||
* Release and Policy Gate models for UI-POLICY-DET-01.
|
||||
* Supports determinism-gated release flows with remediation hints.
|
||||
*/
|
||||
|
||||
// Policy gate evaluation status
|
||||
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
|
||||
|
||||
// Types of policy gates
|
||||
export type PolicyGateType =
|
||||
| 'determinism'
|
||||
| 'vulnerability'
|
||||
| 'license'
|
||||
| 'entropy'
|
||||
| 'signature'
|
||||
| 'sbom-completeness'
|
||||
| 'custom';
|
||||
|
||||
// Remediation action types
|
||||
export type RemediationActionType =
|
||||
| 'rebuild'
|
||||
| 'provide-provenance'
|
||||
| 'sign-artifact'
|
||||
| 'update-dependency'
|
||||
| 'request-exception'
|
||||
| 'manual-review';
|
||||
|
||||
/**
|
||||
* A single remediation step with optional automation support.
|
||||
*/
|
||||
export interface RemediationStep {
|
||||
readonly action: RemediationActionType;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly command?: string; // Optional CLI command to run
|
||||
readonly documentationUrl?: string;
|
||||
readonly automated: boolean; // Can be triggered from UI
|
||||
}
|
||||
|
||||
/**
|
||||
* Remediation hints for a failed policy gate.
|
||||
*/
|
||||
export interface RemediationHint {
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly summary: string;
|
||||
readonly steps: readonly RemediationStep[];
|
||||
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
|
||||
readonly exceptionAllowed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual policy gate evaluation result.
|
||||
*/
|
||||
export interface PolicyGateResult {
|
||||
readonly gateId: string;
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly name: string;
|
||||
readonly status: PolicyGateStatus;
|
||||
readonly message: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly blockingPublish: boolean;
|
||||
readonly evidence?: {
|
||||
readonly type: string;
|
||||
readonly url?: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
};
|
||||
readonly remediation?: RemediationHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determinism-specific gate details.
|
||||
*/
|
||||
export interface DeterminismGateDetails {
|
||||
readonly merkleRoot?: string;
|
||||
readonly merkleRootConsistent: boolean;
|
||||
readonly contentHash?: string;
|
||||
readonly compositionManifestUri?: string;
|
||||
readonly fragmentCount?: number;
|
||||
readonly verifiedFragments?: number;
|
||||
readonly failedFragments?: readonly string[]; // Layer digests that failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall policy evaluation for a release artifact.
|
||||
*/
|
||||
export interface PolicyEvaluation {
|
||||
readonly evaluationId: string;
|
||||
readonly artifactDigest: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly overallStatus: PolicyGateStatus;
|
||||
readonly gates: readonly PolicyGateResult[];
|
||||
readonly blockingGates: readonly string[]; // Gate IDs that block publish
|
||||
readonly canPublish: boolean;
|
||||
readonly determinismDetails?: DeterminismGateDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release artifact with policy evaluation.
|
||||
*/
|
||||
export interface ReleaseArtifact {
|
||||
readonly artifactId: string;
|
||||
readonly name: string;
|
||||
readonly tag: string;
|
||||
readonly digest: string;
|
||||
readonly size: number;
|
||||
readonly createdAt: string;
|
||||
readonly registry: string;
|
||||
readonly policyEvaluation?: PolicyEvaluation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release workflow status.
|
||||
*/
|
||||
export type ReleaseStatus =
|
||||
| 'draft'
|
||||
| 'pending_approval'
|
||||
| 'approved'
|
||||
| 'publishing'
|
||||
| 'published'
|
||||
| 'blocked'
|
||||
| 'cancelled';
|
||||
|
||||
/**
|
||||
* Release with multiple artifacts and policy gates.
|
||||
*/
|
||||
export interface Release {
|
||||
readonly releaseId: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly status: ReleaseStatus;
|
||||
readonly createdAt: string;
|
||||
readonly createdBy: string;
|
||||
readonly artifacts: readonly ReleaseArtifact[];
|
||||
readonly targetEnvironment: string;
|
||||
readonly notes?: string;
|
||||
readonly approvals?: readonly ReleaseApproval[];
|
||||
readonly publishedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release approval record.
|
||||
*/
|
||||
export interface ReleaseApproval {
|
||||
readonly approvalId: string;
|
||||
readonly approver: string;
|
||||
readonly decision: 'approved' | 'rejected' | 'pending';
|
||||
readonly comment?: string;
|
||||
readonly decidedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag configuration for determinism blocking.
|
||||
*/
|
||||
export interface DeterminismFeatureFlags {
|
||||
readonly enabled: boolean;
|
||||
readonly blockOnFailure: boolean;
|
||||
readonly warnOnly: boolean;
|
||||
readonly bypassRoles?: readonly string[];
|
||||
readonly requireApprovalForBypass: boolean;
|
||||
}
|
||||
|
||||
@@ -1,147 +1,147 @@
|
||||
export type ScanAttestationStatusKind = 'verified' | 'pending' | 'failed';
|
||||
|
||||
export interface ScanAttestationStatus {
|
||||
readonly uuid: string;
|
||||
readonly status: ScanAttestationStatusKind;
|
||||
readonly index?: number;
|
||||
readonly logUrl?: string;
|
||||
readonly checkedAt?: string;
|
||||
readonly statusMessage?: string;
|
||||
}
|
||||
|
||||
// Determinism models based on docs/modules/scanner/deterministic-sbom-compose.md
|
||||
|
||||
export type DeterminismStatus = 'verified' | 'pending' | 'failed' | 'unknown';
|
||||
|
||||
export interface FragmentAttestation {
|
||||
readonly layerDigest: string;
|
||||
readonly fragmentSha256: string;
|
||||
readonly dsseEnvelopeSha256: string;
|
||||
readonly dsseStatus: 'verified' | 'pending' | 'failed';
|
||||
readonly verifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface CompositionManifest {
|
||||
readonly compositionUri: string;
|
||||
readonly merkleRoot: string;
|
||||
readonly fragmentCount: number;
|
||||
readonly fragments: readonly FragmentAttestation[];
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface DeterminismEvidence {
|
||||
readonly status: DeterminismStatus;
|
||||
readonly merkleRoot?: string;
|
||||
readonly merkleRootConsistent: boolean;
|
||||
readonly compositionManifest?: CompositionManifest;
|
||||
readonly contentHash?: string;
|
||||
readonly verifiedAt?: string;
|
||||
readonly failureReason?: string;
|
||||
readonly stellaProperties?: {
|
||||
readonly 'stellaops:stella.contentHash'?: string;
|
||||
readonly 'stellaops:composition.manifest'?: string;
|
||||
readonly 'stellaops:merkle.root'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Entropy analysis models based on docs/modules/scanner/entropy.md
|
||||
|
||||
export interface EntropyWindow {
|
||||
readonly offset: number;
|
||||
readonly length: number;
|
||||
readonly entropy: number; // 0-8 bits/byte
|
||||
}
|
||||
|
||||
export interface EntropyFile {
|
||||
readonly path: string;
|
||||
readonly size: number;
|
||||
readonly opaqueBytes: number;
|
||||
readonly opaqueRatio: number; // 0-1
|
||||
readonly flags: readonly string[]; // e.g., 'stripped', 'section:.UPX0', 'no-symbols', 'packed'
|
||||
readonly windows: readonly EntropyWindow[];
|
||||
}
|
||||
|
||||
export interface EntropyLayerSummary {
|
||||
readonly digest: string;
|
||||
readonly opaqueBytes: number;
|
||||
readonly totalBytes: number;
|
||||
readonly opaqueRatio: number; // 0-1
|
||||
readonly indicators: readonly string[]; // e.g., 'packed', 'no-symbols'
|
||||
}
|
||||
|
||||
export interface EntropyReport {
|
||||
readonly schema: string;
|
||||
readonly generatedAt: string;
|
||||
readonly imageDigest: string;
|
||||
readonly layerDigest?: string;
|
||||
readonly files: readonly EntropyFile[];
|
||||
}
|
||||
|
||||
export interface EntropyLayerSummaryReport {
|
||||
readonly schema: string;
|
||||
readonly generatedAt: string;
|
||||
readonly imageDigest: string;
|
||||
readonly layers: readonly EntropyLayerSummary[];
|
||||
readonly imageOpaqueRatio: number; // 0-1
|
||||
readonly entropyPenalty: number; // 0-0.3
|
||||
}
|
||||
|
||||
export interface EntropyEvidence {
|
||||
readonly report?: EntropyReport;
|
||||
readonly layerSummary?: EntropyLayerSummaryReport;
|
||||
readonly downloadUrl?: string; // URL to entropy.report.json
|
||||
}
|
||||
|
||||
// Binary Evidence models for SPRINT_20251226_014_BINIDX (SCANINT-17,18,19)
|
||||
|
||||
export type BinaryFixStatus = 'fixed' | 'vulnerable' | 'not_affected' | 'wontfix' | 'unknown';
|
||||
|
||||
export interface BinaryIdentity {
|
||||
readonly format: 'elf' | 'pe' | 'macho';
|
||||
readonly buildId?: string;
|
||||
readonly fileSha256: string;
|
||||
readonly architecture: string;
|
||||
readonly binaryKey: string;
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
export interface BinaryFixStatusInfo {
|
||||
readonly state: BinaryFixStatus;
|
||||
readonly fixedVersion?: string;
|
||||
readonly method: 'changelog' | 'patch_analysis' | 'advisory';
|
||||
readonly confidence: number;
|
||||
}
|
||||
|
||||
export interface BinaryVulnMatch {
|
||||
readonly cveId: string;
|
||||
readonly method: 'buildid_catalog' | 'fingerprint_match' | 'range_match';
|
||||
readonly confidence: number;
|
||||
readonly vulnerablePurl: string;
|
||||
readonly fixStatus?: BinaryFixStatusInfo;
|
||||
readonly similarity?: number;
|
||||
readonly matchedFunction?: string;
|
||||
}
|
||||
|
||||
export interface BinaryFinding {
|
||||
readonly identity: BinaryIdentity;
|
||||
readonly layerDigest: string;
|
||||
readonly matches: readonly BinaryVulnMatch[];
|
||||
}
|
||||
|
||||
export interface BinaryEvidence {
|
||||
readonly binaries: readonly BinaryFinding[];
|
||||
readonly scanId: string;
|
||||
readonly scannedAt: string;
|
||||
readonly distro?: string;
|
||||
readonly release?: string;
|
||||
}
|
||||
|
||||
export interface ScanDetail {
|
||||
readonly scanId: string;
|
||||
readonly imageDigest: string;
|
||||
readonly completedAt: string;
|
||||
readonly attestation?: ScanAttestationStatus;
|
||||
readonly determinism?: DeterminismEvidence;
|
||||
readonly entropy?: EntropyEvidence;
|
||||
readonly binaryEvidence?: BinaryEvidence;
|
||||
}
|
||||
export type ScanAttestationStatusKind = 'verified' | 'pending' | 'failed';
|
||||
|
||||
export interface ScanAttestationStatus {
|
||||
readonly uuid: string;
|
||||
readonly status: ScanAttestationStatusKind;
|
||||
readonly index?: number;
|
||||
readonly logUrl?: string;
|
||||
readonly checkedAt?: string;
|
||||
readonly statusMessage?: string;
|
||||
}
|
||||
|
||||
// Determinism models based on docs/modules/scanner/deterministic-sbom-compose.md
|
||||
|
||||
export type DeterminismStatus = 'verified' | 'pending' | 'failed' | 'unknown';
|
||||
|
||||
export interface FragmentAttestation {
|
||||
readonly layerDigest: string;
|
||||
readonly fragmentSha256: string;
|
||||
readonly dsseEnvelopeSha256: string;
|
||||
readonly dsseStatus: 'verified' | 'pending' | 'failed';
|
||||
readonly verifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface CompositionManifest {
|
||||
readonly compositionUri: string;
|
||||
readonly merkleRoot: string;
|
||||
readonly fragmentCount: number;
|
||||
readonly fragments: readonly FragmentAttestation[];
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface DeterminismEvidence {
|
||||
readonly status: DeterminismStatus;
|
||||
readonly merkleRoot?: string;
|
||||
readonly merkleRootConsistent: boolean;
|
||||
readonly compositionManifest?: CompositionManifest;
|
||||
readonly contentHash?: string;
|
||||
readonly verifiedAt?: string;
|
||||
readonly failureReason?: string;
|
||||
readonly stellaProperties?: {
|
||||
readonly 'stellaops:stella.contentHash'?: string;
|
||||
readonly 'stellaops:composition.manifest'?: string;
|
||||
readonly 'stellaops:merkle.root'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Entropy analysis models based on docs/modules/scanner/entropy.md
|
||||
|
||||
export interface EntropyWindow {
|
||||
readonly offset: number;
|
||||
readonly length: number;
|
||||
readonly entropy: number; // 0-8 bits/byte
|
||||
}
|
||||
|
||||
export interface EntropyFile {
|
||||
readonly path: string;
|
||||
readonly size: number;
|
||||
readonly opaqueBytes: number;
|
||||
readonly opaqueRatio: number; // 0-1
|
||||
readonly flags: readonly string[]; // e.g., 'stripped', 'section:.UPX0', 'no-symbols', 'packed'
|
||||
readonly windows: readonly EntropyWindow[];
|
||||
}
|
||||
|
||||
export interface EntropyLayerSummary {
|
||||
readonly digest: string;
|
||||
readonly opaqueBytes: number;
|
||||
readonly totalBytes: number;
|
||||
readonly opaqueRatio: number; // 0-1
|
||||
readonly indicators: readonly string[]; // e.g., 'packed', 'no-symbols'
|
||||
}
|
||||
|
||||
export interface EntropyReport {
|
||||
readonly schema: string;
|
||||
readonly generatedAt: string;
|
||||
readonly imageDigest: string;
|
||||
readonly layerDigest?: string;
|
||||
readonly files: readonly EntropyFile[];
|
||||
}
|
||||
|
||||
export interface EntropyLayerSummaryReport {
|
||||
readonly schema: string;
|
||||
readonly generatedAt: string;
|
||||
readonly imageDigest: string;
|
||||
readonly layers: readonly EntropyLayerSummary[];
|
||||
readonly imageOpaqueRatio: number; // 0-1
|
||||
readonly entropyPenalty: number; // 0-0.3
|
||||
}
|
||||
|
||||
export interface EntropyEvidence {
|
||||
readonly report?: EntropyReport;
|
||||
readonly layerSummary?: EntropyLayerSummaryReport;
|
||||
readonly downloadUrl?: string; // URL to entropy.report.json
|
||||
}
|
||||
|
||||
// Binary Evidence models for SPRINT_20251226_014_BINIDX (SCANINT-17,18,19)
|
||||
|
||||
export type BinaryFixStatus = 'fixed' | 'vulnerable' | 'not_affected' | 'wontfix' | 'unknown';
|
||||
|
||||
export interface BinaryIdentity {
|
||||
readonly format: 'elf' | 'pe' | 'macho';
|
||||
readonly buildId?: string;
|
||||
readonly fileSha256: string;
|
||||
readonly architecture: string;
|
||||
readonly binaryKey: string;
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
export interface BinaryFixStatusInfo {
|
||||
readonly state: BinaryFixStatus;
|
||||
readonly fixedVersion?: string;
|
||||
readonly method: 'changelog' | 'patch_analysis' | 'advisory';
|
||||
readonly confidence: number;
|
||||
}
|
||||
|
||||
export interface BinaryVulnMatch {
|
||||
readonly cveId: string;
|
||||
readonly method: 'buildid_catalog' | 'fingerprint_match' | 'range_match';
|
||||
readonly confidence: number;
|
||||
readonly vulnerablePurl: string;
|
||||
readonly fixStatus?: BinaryFixStatusInfo;
|
||||
readonly similarity?: number;
|
||||
readonly matchedFunction?: string;
|
||||
}
|
||||
|
||||
export interface BinaryFinding {
|
||||
readonly identity: BinaryIdentity;
|
||||
readonly layerDigest: string;
|
||||
readonly matches: readonly BinaryVulnMatch[];
|
||||
}
|
||||
|
||||
export interface BinaryEvidence {
|
||||
readonly binaries: readonly BinaryFinding[];
|
||||
readonly scanId: string;
|
||||
readonly scannedAt: string;
|
||||
readonly distro?: string;
|
||||
readonly release?: string;
|
||||
}
|
||||
|
||||
export interface ScanDetail {
|
||||
readonly scanId: string;
|
||||
readonly imageDigest: string;
|
||||
readonly completedAt: string;
|
||||
readonly attestation?: ScanAttestationStatus;
|
||||
readonly determinism?: DeterminismEvidence;
|
||||
readonly entropy?: EntropyEvidence;
|
||||
readonly binaryEvidence?: BinaryEvidence;
|
||||
}
|
||||
|
||||
@@ -1,267 +1,267 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
Vulnerability,
|
||||
VulnerabilitiesQueryOptions,
|
||||
VulnerabilitiesResponse,
|
||||
VulnerabilityStats,
|
||||
VulnWorkflowRequest,
|
||||
VulnWorkflowResponse,
|
||||
VulnExportRequest,
|
||||
VulnExportResponse,
|
||||
} from './vulnerability.models';
|
||||
|
||||
/**
|
||||
* Vulnerability API interface.
|
||||
* Implements WEB-VULN-29-001 contract with tenant scoping and RBAC/ABAC enforcement.
|
||||
*/
|
||||
export interface VulnerabilityApi {
|
||||
/** List vulnerabilities with filtering and pagination. */
|
||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse>;
|
||||
|
||||
/** Get a single vulnerability by ID. */
|
||||
getVulnerability(vulnId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<Vulnerability>;
|
||||
|
||||
/** Get vulnerability statistics. */
|
||||
getStats(options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats>;
|
||||
|
||||
/** Submit a workflow action (ack, close, reopen, etc.). */
|
||||
submitWorkflowAction(request: VulnWorkflowRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnWorkflowResponse>;
|
||||
|
||||
/** Request a vulnerability export. */
|
||||
requestExport(request: VulnExportRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
|
||||
|
||||
/** Get export status by ID. */
|
||||
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
|
||||
}
|
||||
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
Vulnerability,
|
||||
VulnerabilitiesQueryOptions,
|
||||
VulnerabilitiesResponse,
|
||||
VulnerabilityStats,
|
||||
VulnWorkflowRequest,
|
||||
VulnWorkflowResponse,
|
||||
VulnExportRequest,
|
||||
VulnExportResponse,
|
||||
} from './vulnerability.models';
|
||||
|
||||
/**
|
||||
* Vulnerability API interface.
|
||||
* Implements WEB-VULN-29-001 contract with tenant scoping and RBAC/ABAC enforcement.
|
||||
*/
|
||||
export interface VulnerabilityApi {
|
||||
/** List vulnerabilities with filtering and pagination. */
|
||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse>;
|
||||
|
||||
/** Get a single vulnerability by ID. */
|
||||
getVulnerability(vulnId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<Vulnerability>;
|
||||
|
||||
/** Get vulnerability statistics. */
|
||||
getStats(options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats>;
|
||||
|
||||
/** Submit a workflow action (ack, close, reopen, etc.). */
|
||||
submitWorkflowAction(request: VulnWorkflowRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnWorkflowResponse>;
|
||||
|
||||
/** Request a vulnerability export. */
|
||||
requestExport(request: VulnExportRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
|
||||
|
||||
/** Get export status by ID. */
|
||||
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
|
||||
}
|
||||
|
||||
export const VULNERABILITY_API = new InjectionToken<VulnerabilityApi>('VULNERABILITY_API');
|
||||
|
||||
const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
{
|
||||
vulnId: 'vuln-001',
|
||||
cveId: 'CVE-2021-44228',
|
||||
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
|
||||
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severity: 'critical',
|
||||
cvssScore: 10.0,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||
status: 'open',
|
||||
publishedAt: '2021-12-10T00:00:00Z',
|
||||
modifiedAt: '2024-06-27T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
|
||||
name: 'log4j-core',
|
||||
version: '2.14.1',
|
||||
fixedVersion: '2.17.1',
|
||||
assetIds: ['asset-web-prod', 'asset-api-prod'],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||
'https://logging.apache.org/log4j/2.x/security.html',
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-002',
|
||||
cveId: 'CVE-2021-45046',
|
||||
title: 'Log4j2 Thread Context Message Pattern DoS',
|
||||
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
|
||||
severity: 'critical',
|
||||
cvssScore: 9.0,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||
status: 'excepted',
|
||||
publishedAt: '2021-12-14T00:00:00Z',
|
||||
modifiedAt: '2023-11-06T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0',
|
||||
name: 'log4j-core',
|
||||
version: '2.15.0',
|
||||
fixedVersion: '2.17.1',
|
||||
assetIds: ['asset-internal-001'],
|
||||
},
|
||||
],
|
||||
hasException: true,
|
||||
exceptionId: 'exc-test-001',
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-003',
|
||||
cveId: 'CVE-2023-44487',
|
||||
title: 'HTTP/2 Rapid Reset Attack',
|
||||
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
|
||||
severity: 'high',
|
||||
cvssScore: 7.5,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H',
|
||||
status: 'in_progress',
|
||||
publishedAt: '2023-10-10T00:00:00Z',
|
||||
modifiedAt: '2024-05-01T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/golang.org/x/net@0.15.0',
|
||||
name: 'golang.org/x/net',
|
||||
version: '0.15.0',
|
||||
fixedVersion: '0.17.0',
|
||||
assetIds: ['asset-api-prod', 'asset-worker-prod'],
|
||||
},
|
||||
{
|
||||
purl: 'pkg:npm/nghttp2@1.55.0',
|
||||
name: 'nghttp2',
|
||||
version: '1.55.0',
|
||||
fixedVersion: '1.57.0',
|
||||
assetIds: ['asset-web-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-004',
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'runc container escape vulnerability',
|
||||
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
|
||||
severity: 'high',
|
||||
cvssScore: 8.6,
|
||||
cvssVector: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H',
|
||||
status: 'fixed',
|
||||
publishedAt: '2024-01-31T00:00:00Z',
|
||||
modifiedAt: '2024-09-13T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/github.com/opencontainers/runc@1.1.10',
|
||||
name: 'runc',
|
||||
version: '1.1.10',
|
||||
fixedVersion: '1.1.12',
|
||||
assetIds: ['asset-builder-001'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-005',
|
||||
cveId: 'CVE-2023-38545',
|
||||
title: 'curl SOCKS5 heap buffer overflow',
|
||||
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
|
||||
severity: 'high',
|
||||
cvssScore: 9.8,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
|
||||
status: 'open',
|
||||
publishedAt: '2023-10-11T00:00:00Z',
|
||||
modifiedAt: '2024-06-10T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:deb/debian/curl@7.88.1-10',
|
||||
name: 'curl',
|
||||
version: '7.88.1-10',
|
||||
fixedVersion: '8.4.0',
|
||||
assetIds: ['asset-web-prod', 'asset-api-prod', 'asset-worker-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-006',
|
||||
cveId: 'CVE-2022-22965',
|
||||
title: 'Spring4Shell - Spring Framework RCE',
|
||||
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
|
||||
severity: 'critical',
|
||||
cvssScore: 9.8,
|
||||
status: 'wont_fix',
|
||||
publishedAt: '2022-03-31T00:00:00Z',
|
||||
modifiedAt: '2024-08-20T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.springframework/spring-beans@5.3.17',
|
||||
name: 'spring-beans',
|
||||
version: '5.3.17',
|
||||
fixedVersion: '5.3.18',
|
||||
assetIds: ['asset-legacy-001'],
|
||||
},
|
||||
],
|
||||
hasException: true,
|
||||
exceptionId: 'exc-legacy-spring',
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-007',
|
||||
cveId: 'CVE-2023-45853',
|
||||
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
|
||||
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
|
||||
severity: 'medium',
|
||||
cvssScore: 5.3,
|
||||
status: 'open',
|
||||
publishedAt: '2023-10-14T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:deb/debian/zlib@1.2.13',
|
||||
name: 'zlib',
|
||||
version: '1.2.13',
|
||||
fixedVersion: '1.3.1',
|
||||
assetIds: ['asset-web-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-008',
|
||||
cveId: 'CVE-2024-0567',
|
||||
title: 'GnuTLS certificate verification bypass',
|
||||
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
|
||||
severity: 'medium',
|
||||
cvssScore: 5.9,
|
||||
status: 'open',
|
||||
publishedAt: '2024-01-16T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:rpm/fedora/gnutls@3.8.2',
|
||||
name: 'gnutls',
|
||||
version: '3.8.2',
|
||||
fixedVersion: '3.8.3',
|
||||
assetIds: ['asset-internal-001'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-009',
|
||||
cveId: 'CVE-2023-5363',
|
||||
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
|
||||
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
|
||||
severity: 'low',
|
||||
cvssScore: 3.7,
|
||||
status: 'fixed',
|
||||
publishedAt: '2023-10-24T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:nuget/System.Security.Cryptography.Pkcs@7.0.2',
|
||||
name: 'System.Security.Cryptography.Pkcs',
|
||||
version: '7.0.2',
|
||||
fixedVersion: '8.0.0',
|
||||
assetIds: ['asset-api-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-010',
|
||||
cveId: 'CVE-2024-24790',
|
||||
title: 'Go net/netip ParseAddr stack exhaustion',
|
||||
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
|
||||
severity: 'low',
|
||||
cvssScore: 4.0,
|
||||
status: 'open',
|
||||
publishedAt: '2024-06-05T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/stdlib@1.21.10',
|
||||
name: 'go stdlib',
|
||||
version: '1.21.10',
|
||||
fixedVersion: '1.21.11',
|
||||
assetIds: ['asset-api-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
{
|
||||
vulnId: 'vuln-001',
|
||||
cveId: 'CVE-2021-44228',
|
||||
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
|
||||
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severity: 'critical',
|
||||
cvssScore: 10.0,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||
status: 'open',
|
||||
publishedAt: '2021-12-10T00:00:00Z',
|
||||
modifiedAt: '2024-06-27T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
|
||||
name: 'log4j-core',
|
||||
version: '2.14.1',
|
||||
fixedVersion: '2.17.1',
|
||||
assetIds: ['asset-web-prod', 'asset-api-prod'],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||
'https://logging.apache.org/log4j/2.x/security.html',
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-002',
|
||||
cveId: 'CVE-2021-45046',
|
||||
title: 'Log4j2 Thread Context Message Pattern DoS',
|
||||
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
|
||||
severity: 'critical',
|
||||
cvssScore: 9.0,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||
status: 'excepted',
|
||||
publishedAt: '2021-12-14T00:00:00Z',
|
||||
modifiedAt: '2023-11-06T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0',
|
||||
name: 'log4j-core',
|
||||
version: '2.15.0',
|
||||
fixedVersion: '2.17.1',
|
||||
assetIds: ['asset-internal-001'],
|
||||
},
|
||||
],
|
||||
hasException: true,
|
||||
exceptionId: 'exc-test-001',
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-003',
|
||||
cveId: 'CVE-2023-44487',
|
||||
title: 'HTTP/2 Rapid Reset Attack',
|
||||
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
|
||||
severity: 'high',
|
||||
cvssScore: 7.5,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H',
|
||||
status: 'in_progress',
|
||||
publishedAt: '2023-10-10T00:00:00Z',
|
||||
modifiedAt: '2024-05-01T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/golang.org/x/net@0.15.0',
|
||||
name: 'golang.org/x/net',
|
||||
version: '0.15.0',
|
||||
fixedVersion: '0.17.0',
|
||||
assetIds: ['asset-api-prod', 'asset-worker-prod'],
|
||||
},
|
||||
{
|
||||
purl: 'pkg:npm/nghttp2@1.55.0',
|
||||
name: 'nghttp2',
|
||||
version: '1.55.0',
|
||||
fixedVersion: '1.57.0',
|
||||
assetIds: ['asset-web-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-004',
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'runc container escape vulnerability',
|
||||
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
|
||||
severity: 'high',
|
||||
cvssScore: 8.6,
|
||||
cvssVector: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H',
|
||||
status: 'fixed',
|
||||
publishedAt: '2024-01-31T00:00:00Z',
|
||||
modifiedAt: '2024-09-13T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/github.com/opencontainers/runc@1.1.10',
|
||||
name: 'runc',
|
||||
version: '1.1.10',
|
||||
fixedVersion: '1.1.12',
|
||||
assetIds: ['asset-builder-001'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-005',
|
||||
cveId: 'CVE-2023-38545',
|
||||
title: 'curl SOCKS5 heap buffer overflow',
|
||||
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
|
||||
severity: 'high',
|
||||
cvssScore: 9.8,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
|
||||
status: 'open',
|
||||
publishedAt: '2023-10-11T00:00:00Z',
|
||||
modifiedAt: '2024-06-10T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:deb/debian/curl@7.88.1-10',
|
||||
name: 'curl',
|
||||
version: '7.88.1-10',
|
||||
fixedVersion: '8.4.0',
|
||||
assetIds: ['asset-web-prod', 'asset-api-prod', 'asset-worker-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-006',
|
||||
cveId: 'CVE-2022-22965',
|
||||
title: 'Spring4Shell - Spring Framework RCE',
|
||||
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
|
||||
severity: 'critical',
|
||||
cvssScore: 9.8,
|
||||
status: 'wont_fix',
|
||||
publishedAt: '2022-03-31T00:00:00Z',
|
||||
modifiedAt: '2024-08-20T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.springframework/spring-beans@5.3.17',
|
||||
name: 'spring-beans',
|
||||
version: '5.3.17',
|
||||
fixedVersion: '5.3.18',
|
||||
assetIds: ['asset-legacy-001'],
|
||||
},
|
||||
],
|
||||
hasException: true,
|
||||
exceptionId: 'exc-legacy-spring',
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-007',
|
||||
cveId: 'CVE-2023-45853',
|
||||
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
|
||||
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
|
||||
severity: 'medium',
|
||||
cvssScore: 5.3,
|
||||
status: 'open',
|
||||
publishedAt: '2023-10-14T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:deb/debian/zlib@1.2.13',
|
||||
name: 'zlib',
|
||||
version: '1.2.13',
|
||||
fixedVersion: '1.3.1',
|
||||
assetIds: ['asset-web-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-008',
|
||||
cveId: 'CVE-2024-0567',
|
||||
title: 'GnuTLS certificate verification bypass',
|
||||
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
|
||||
severity: 'medium',
|
||||
cvssScore: 5.9,
|
||||
status: 'open',
|
||||
publishedAt: '2024-01-16T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:rpm/fedora/gnutls@3.8.2',
|
||||
name: 'gnutls',
|
||||
version: '3.8.2',
|
||||
fixedVersion: '3.8.3',
|
||||
assetIds: ['asset-internal-001'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-009',
|
||||
cveId: 'CVE-2023-5363',
|
||||
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
|
||||
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
|
||||
severity: 'low',
|
||||
cvssScore: 3.7,
|
||||
status: 'fixed',
|
||||
publishedAt: '2023-10-24T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:nuget/System.Security.Cryptography.Pkcs@7.0.2',
|
||||
name: 'System.Security.Cryptography.Pkcs',
|
||||
version: '7.0.2',
|
||||
fixedVersion: '8.0.0',
|
||||
assetIds: ['asset-api-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-010',
|
||||
cveId: 'CVE-2024-24790',
|
||||
title: 'Go net/netip ParseAddr stack exhaustion',
|
||||
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
|
||||
severity: 'low',
|
||||
cvssScore: 4.0,
|
||||
status: 'open',
|
||||
publishedAt: '2024-06-05T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/stdlib@1.21.10',
|
||||
name: 'go stdlib',
|
||||
version: '1.21.10',
|
||||
fixedVersion: '1.21.11',
|
||||
assetIds: ['asset-api-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -302,19 +302,19 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
|
||||
|
||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
|
||||
let items = [...MOCK_VULNERABILITIES];
|
||||
|
||||
if (options?.severity && options.severity !== 'all') {
|
||||
items = items.filter((v) => v.severity === options.severity);
|
||||
}
|
||||
|
||||
if (options?.status && options.status !== 'all') {
|
||||
items = items.filter((v) => v.status === options.status);
|
||||
}
|
||||
|
||||
if (options?.hasException !== undefined) {
|
||||
items = items.filter((v) => v.hasException === options.hasException);
|
||||
}
|
||||
|
||||
|
||||
if (options?.severity && options.severity !== 'all') {
|
||||
items = items.filter((v) => v.severity === options.severity);
|
||||
}
|
||||
|
||||
if (options?.status && options.status !== 'all') {
|
||||
items = items.filter((v) => v.status === options.status);
|
||||
}
|
||||
|
||||
if (options?.hasException !== undefined) {
|
||||
items = items.filter((v) => v.hasException === options.hasException);
|
||||
}
|
||||
|
||||
if (options?.search) {
|
||||
const search = options.search.toLowerCase();
|
||||
items = items.filter(
|
||||
@@ -356,25 +356,25 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
|
||||
etag: `"vuln-${vulnId}-etag"`,
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
|
||||
|
||||
getStats(_options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats> {
|
||||
const vulns = MOCK_VULNERABILITIES;
|
||||
const stats: VulnerabilityStats = {
|
||||
total: vulns.length,
|
||||
bySeverity: {
|
||||
critical: vulns.filter((v) => v.severity === 'critical').length,
|
||||
high: vulns.filter((v) => v.severity === 'high').length,
|
||||
medium: vulns.filter((v) => v.severity === 'medium').length,
|
||||
low: vulns.filter((v) => v.severity === 'low').length,
|
||||
unknown: vulns.filter((v) => v.severity === 'unknown').length,
|
||||
},
|
||||
byStatus: {
|
||||
open: vulns.filter((v) => v.status === 'open').length,
|
||||
fixed: vulns.filter((v) => v.status === 'fixed').length,
|
||||
wont_fix: vulns.filter((v) => v.status === 'wont_fix').length,
|
||||
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
|
||||
excepted: vulns.filter((v) => v.status === 'excepted').length,
|
||||
},
|
||||
total: vulns.length,
|
||||
bySeverity: {
|
||||
critical: vulns.filter((v) => v.severity === 'critical').length,
|
||||
high: vulns.filter((v) => v.severity === 'high').length,
|
||||
medium: vulns.filter((v) => v.severity === 'medium').length,
|
||||
low: vulns.filter((v) => v.severity === 'low').length,
|
||||
unknown: vulns.filter((v) => v.severity === 'unknown').length,
|
||||
},
|
||||
byStatus: {
|
||||
open: vulns.filter((v) => v.status === 'open').length,
|
||||
fixed: vulns.filter((v) => v.status === 'fixed').length,
|
||||
wont_fix: vulns.filter((v) => v.status === 'wont_fix').length,
|
||||
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
|
||||
excepted: vulns.filter((v) => v.status === 'excepted').length,
|
||||
},
|
||||
withExceptions: vulns.filter((v) => v.hasException).length,
|
||||
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
|
||||
computedAt: MockVulnerabilityApiService.FixedNowIso,
|
||||
@@ -409,24 +409,24 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
|
||||
fileSize: 1024 * (request.includeComponents ? 50 : 20),
|
||||
traceId,
|
||||
};
|
||||
|
||||
this.mockExports.set(exportId, exportResponse);
|
||||
return of(exportResponse).pipe(delay(500));
|
||||
}
|
||||
|
||||
|
||||
this.mockExports.set(exportId, exportResponse);
|
||||
return of(exportResponse).pipe(delay(500));
|
||||
}
|
||||
|
||||
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse> {
|
||||
const traceId = options?.traceId ?? 'mock-trace-vuln-export-status';
|
||||
const existing = this.mockExports.get(exportId);
|
||||
|
||||
if (existing) {
|
||||
return of(existing).pipe(delay(100));
|
||||
}
|
||||
|
||||
return of({
|
||||
exportId,
|
||||
status: 'failed' as const,
|
||||
traceId,
|
||||
error: { code: 'ERR_EXPORT_NOT_FOUND', message: 'Export not found' },
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return of({
|
||||
exportId,
|
||||
status: 'failed' as const,
|
||||
traceId,
|
||||
error: { code: 'ERR_EXPORT_NOT_FOUND', message: 'Export not found' },
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,208 +1,208 @@
|
||||
export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
|
||||
export type VulnerabilityStatus = 'open' | 'fixed' | 'wont_fix' | 'in_progress' | 'excepted';
|
||||
|
||||
/**
|
||||
* Workflow action types for vulnerability lifecycle.
|
||||
*/
|
||||
export type VulnWorkflowAction = 'open' | 'ack' | 'close' | 'reopen' | 'export';
|
||||
|
||||
/**
|
||||
* Actor types for workflow actions.
|
||||
*/
|
||||
export type VulnActorType = 'user' | 'service' | 'automation';
|
||||
|
||||
export interface Vulnerability {
|
||||
readonly vulnId: string;
|
||||
readonly cveId: string;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly severity: VulnerabilitySeverity;
|
||||
readonly cvssScore?: number;
|
||||
readonly cvssVector?: string;
|
||||
readonly status: VulnerabilityStatus;
|
||||
readonly publishedAt?: string;
|
||||
readonly modifiedAt?: string;
|
||||
readonly affectedComponents: readonly AffectedComponent[];
|
||||
readonly references?: readonly string[];
|
||||
readonly hasException?: boolean;
|
||||
readonly exceptionId?: string;
|
||||
/** ETag for optimistic concurrency. */
|
||||
readonly etag?: string;
|
||||
/** Reachability score from signals integration. */
|
||||
readonly reachabilityScore?: number;
|
||||
/** Reachability status from signals. */
|
||||
readonly reachabilityStatus?: 'reachable' | 'unreachable' | 'unknown';
|
||||
}
|
||||
|
||||
export interface AffectedComponent {
|
||||
readonly purl: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly fixedVersion?: string;
|
||||
readonly assetIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface VulnerabilityStats {
|
||||
readonly total: number;
|
||||
readonly bySeverity: Record<VulnerabilitySeverity, number>;
|
||||
readonly byStatus: Record<VulnerabilityStatus, number>;
|
||||
readonly withExceptions: number;
|
||||
readonly criticalOpen: number;
|
||||
/** Last computation timestamp. */
|
||||
readonly computedAt?: string;
|
||||
/** Trace ID for the stats computation. */
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
export interface VulnerabilitiesQueryOptions {
|
||||
readonly severity?: VulnerabilitySeverity | 'all';
|
||||
readonly status?: VulnerabilityStatus | 'all';
|
||||
readonly search?: string;
|
||||
readonly hasException?: boolean;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
readonly page?: number;
|
||||
readonly pageSize?: number;
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly traceId?: string;
|
||||
/** Filter by reachability status. */
|
||||
readonly reachability?: 'reachable' | 'unreachable' | 'unknown' | 'all';
|
||||
/** Include reachability data in response. */
|
||||
readonly includeReachability?: boolean;
|
||||
}
|
||||
|
||||
export interface VulnerabilitiesResponse {
|
||||
readonly items: readonly Vulnerability[];
|
||||
readonly total: number;
|
||||
readonly hasMore?: boolean;
|
||||
readonly page?: number;
|
||||
readonly pageSize?: number;
|
||||
/** ETag for the response. */
|
||||
readonly etag?: string;
|
||||
/** Trace ID for the request. */
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow action request for Findings Ledger integration.
|
||||
* Implements WEB-VULN-29-002 contract.
|
||||
*/
|
||||
export interface VulnWorkflowRequest {
|
||||
/** Workflow action type. */
|
||||
readonly action: VulnWorkflowAction;
|
||||
/** Finding/vulnerability ID. */
|
||||
readonly findingId: string;
|
||||
/** Reason code for the action. */
|
||||
readonly reasonCode?: string;
|
||||
/** Optional comment. */
|
||||
readonly comment?: string;
|
||||
/** Attachments for the action. */
|
||||
readonly attachments?: readonly VulnWorkflowAttachment[];
|
||||
/** Actor performing the action. */
|
||||
readonly actor: VulnWorkflowActor;
|
||||
/** Additional metadata. */
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment for workflow actions.
|
||||
*/
|
||||
export interface VulnWorkflowAttachment {
|
||||
readonly name: string;
|
||||
readonly digest: string;
|
||||
readonly contentType?: string;
|
||||
readonly size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actor for workflow actions.
|
||||
*/
|
||||
export interface VulnWorkflowActor {
|
||||
readonly subject: string;
|
||||
readonly type: VulnActorType;
|
||||
readonly name?: string;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow action response from Findings Ledger.
|
||||
*/
|
||||
export interface VulnWorkflowResponse {
|
||||
/** Action status. */
|
||||
readonly status: 'accepted' | 'rejected' | 'pending';
|
||||
/** Ledger event ID for correlation. */
|
||||
readonly ledgerEventId: string;
|
||||
/** ETag for optimistic concurrency. */
|
||||
readonly etag: string;
|
||||
/** Trace ID for the request. */
|
||||
readonly traceId: string;
|
||||
/** Correlation ID. */
|
||||
readonly correlationId: string;
|
||||
/** Error details if rejected. */
|
||||
readonly error?: VulnWorkflowError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow error response.
|
||||
*/
|
||||
export interface VulnWorkflowError {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export request for vulnerability data.
|
||||
*/
|
||||
export interface VulnExportRequest {
|
||||
/** Format for export. */
|
||||
readonly format: 'csv' | 'json' | 'cyclonedx' | 'spdx';
|
||||
/** Filter options. */
|
||||
readonly filter?: VulnerabilitiesQueryOptions;
|
||||
/** Include affected components. */
|
||||
readonly includeComponents?: boolean;
|
||||
/** Include reachability data. */
|
||||
readonly includeReachability?: boolean;
|
||||
/** Maximum records (for large exports). */
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export response with signed download URL.
|
||||
*/
|
||||
export interface VulnExportResponse {
|
||||
/** Export job ID. */
|
||||
readonly exportId: string;
|
||||
/** Current status. */
|
||||
readonly status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
/** Signed download URL (when completed). */
|
||||
readonly downloadUrl?: string;
|
||||
/** URL expiration timestamp. */
|
||||
readonly expiresAt?: string;
|
||||
/** Record count. */
|
||||
readonly recordCount?: number;
|
||||
/** File size in bytes. */
|
||||
readonly fileSize?: number;
|
||||
/** Trace ID. */
|
||||
readonly traceId: string;
|
||||
/** Error if failed. */
|
||||
readonly error?: VulnWorkflowError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request logging metadata for observability.
|
||||
*/
|
||||
export interface VulnRequestLog {
|
||||
readonly requestId: string;
|
||||
readonly traceId: string;
|
||||
readonly tenantId: string;
|
||||
readonly projectId?: string;
|
||||
readonly operation: string;
|
||||
readonly path: string;
|
||||
readonly method: string;
|
||||
readonly timestamp: string;
|
||||
readonly durationMs?: number;
|
||||
readonly statusCode?: number;
|
||||
readonly error?: string;
|
||||
}
|
||||
export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
|
||||
export type VulnerabilityStatus = 'open' | 'fixed' | 'wont_fix' | 'in_progress' | 'excepted';
|
||||
|
||||
/**
|
||||
* Workflow action types for vulnerability lifecycle.
|
||||
*/
|
||||
export type VulnWorkflowAction = 'open' | 'ack' | 'close' | 'reopen' | 'export';
|
||||
|
||||
/**
|
||||
* Actor types for workflow actions.
|
||||
*/
|
||||
export type VulnActorType = 'user' | 'service' | 'automation';
|
||||
|
||||
export interface Vulnerability {
|
||||
readonly vulnId: string;
|
||||
readonly cveId: string;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly severity: VulnerabilitySeverity;
|
||||
readonly cvssScore?: number;
|
||||
readonly cvssVector?: string;
|
||||
readonly status: VulnerabilityStatus;
|
||||
readonly publishedAt?: string;
|
||||
readonly modifiedAt?: string;
|
||||
readonly affectedComponents: readonly AffectedComponent[];
|
||||
readonly references?: readonly string[];
|
||||
readonly hasException?: boolean;
|
||||
readonly exceptionId?: string;
|
||||
/** ETag for optimistic concurrency. */
|
||||
readonly etag?: string;
|
||||
/** Reachability score from signals integration. */
|
||||
readonly reachabilityScore?: number;
|
||||
/** Reachability status from signals. */
|
||||
readonly reachabilityStatus?: 'reachable' | 'unreachable' | 'unknown';
|
||||
}
|
||||
|
||||
export interface AffectedComponent {
|
||||
readonly purl: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly fixedVersion?: string;
|
||||
readonly assetIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface VulnerabilityStats {
|
||||
readonly total: number;
|
||||
readonly bySeverity: Record<VulnerabilitySeverity, number>;
|
||||
readonly byStatus: Record<VulnerabilityStatus, number>;
|
||||
readonly withExceptions: number;
|
||||
readonly criticalOpen: number;
|
||||
/** Last computation timestamp. */
|
||||
readonly computedAt?: string;
|
||||
/** Trace ID for the stats computation. */
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
export interface VulnerabilitiesQueryOptions {
|
||||
readonly severity?: VulnerabilitySeverity | 'all';
|
||||
readonly status?: VulnerabilityStatus | 'all';
|
||||
readonly search?: string;
|
||||
readonly hasException?: boolean;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
readonly page?: number;
|
||||
readonly pageSize?: number;
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly traceId?: string;
|
||||
/** Filter by reachability status. */
|
||||
readonly reachability?: 'reachable' | 'unreachable' | 'unknown' | 'all';
|
||||
/** Include reachability data in response. */
|
||||
readonly includeReachability?: boolean;
|
||||
}
|
||||
|
||||
export interface VulnerabilitiesResponse {
|
||||
readonly items: readonly Vulnerability[];
|
||||
readonly total: number;
|
||||
readonly hasMore?: boolean;
|
||||
readonly page?: number;
|
||||
readonly pageSize?: number;
|
||||
/** ETag for the response. */
|
||||
readonly etag?: string;
|
||||
/** Trace ID for the request. */
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow action request for Findings Ledger integration.
|
||||
* Implements WEB-VULN-29-002 contract.
|
||||
*/
|
||||
export interface VulnWorkflowRequest {
|
||||
/** Workflow action type. */
|
||||
readonly action: VulnWorkflowAction;
|
||||
/** Finding/vulnerability ID. */
|
||||
readonly findingId: string;
|
||||
/** Reason code for the action. */
|
||||
readonly reasonCode?: string;
|
||||
/** Optional comment. */
|
||||
readonly comment?: string;
|
||||
/** Attachments for the action. */
|
||||
readonly attachments?: readonly VulnWorkflowAttachment[];
|
||||
/** Actor performing the action. */
|
||||
readonly actor: VulnWorkflowActor;
|
||||
/** Additional metadata. */
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment for workflow actions.
|
||||
*/
|
||||
export interface VulnWorkflowAttachment {
|
||||
readonly name: string;
|
||||
readonly digest: string;
|
||||
readonly contentType?: string;
|
||||
readonly size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actor for workflow actions.
|
||||
*/
|
||||
export interface VulnWorkflowActor {
|
||||
readonly subject: string;
|
||||
readonly type: VulnActorType;
|
||||
readonly name?: string;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow action response from Findings Ledger.
|
||||
*/
|
||||
export interface VulnWorkflowResponse {
|
||||
/** Action status. */
|
||||
readonly status: 'accepted' | 'rejected' | 'pending';
|
||||
/** Ledger event ID for correlation. */
|
||||
readonly ledgerEventId: string;
|
||||
/** ETag for optimistic concurrency. */
|
||||
readonly etag: string;
|
||||
/** Trace ID for the request. */
|
||||
readonly traceId: string;
|
||||
/** Correlation ID. */
|
||||
readonly correlationId: string;
|
||||
/** Error details if rejected. */
|
||||
readonly error?: VulnWorkflowError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow error response.
|
||||
*/
|
||||
export interface VulnWorkflowError {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export request for vulnerability data.
|
||||
*/
|
||||
export interface VulnExportRequest {
|
||||
/** Format for export. */
|
||||
readonly format: 'csv' | 'json' | 'cyclonedx' | 'spdx';
|
||||
/** Filter options. */
|
||||
readonly filter?: VulnerabilitiesQueryOptions;
|
||||
/** Include affected components. */
|
||||
readonly includeComponents?: boolean;
|
||||
/** Include reachability data. */
|
||||
readonly includeReachability?: boolean;
|
||||
/** Maximum records (for large exports). */
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export response with signed download URL.
|
||||
*/
|
||||
export interface VulnExportResponse {
|
||||
/** Export job ID. */
|
||||
readonly exportId: string;
|
||||
/** Current status. */
|
||||
readonly status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
/** Signed download URL (when completed). */
|
||||
readonly downloadUrl?: string;
|
||||
/** URL expiration timestamp. */
|
||||
readonly expiresAt?: string;
|
||||
/** Record count. */
|
||||
readonly recordCount?: number;
|
||||
/** File size in bytes. */
|
||||
readonly fileSize?: number;
|
||||
/** Trace ID. */
|
||||
readonly traceId: string;
|
||||
/** Error if failed. */
|
||||
readonly error?: VulnWorkflowError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request logging metadata for observability.
|
||||
*/
|
||||
export interface VulnRequestLog {
|
||||
readonly requestId: string;
|
||||
readonly traceId: string;
|
||||
readonly tenantId: string;
|
||||
readonly projectId?: string;
|
||||
readonly operation: string;
|
||||
readonly path: string;
|
||||
readonly method: string;
|
||||
readonly timestamp: string;
|
||||
readonly durationMs?: number;
|
||||
readonly statusCode?: number;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export const STEP_TYPES: StepTypeDefinition[] = [
|
||||
label: 'Script',
|
||||
description: 'Execute a custom script or command',
|
||||
icon: 'code',
|
||||
color: '#6366f1',
|
||||
color: '#D4920A',
|
||||
defaultConfig: { command: '', timeout: 300 },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,171 +1,171 @@
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, firstValueFrom, from, throwError } from 'rxjs';
|
||||
import { catchError, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { DpopService } from './dpop/dpop.service';
|
||||
import { AuthorityAuthService } from './authority-auth.service';
|
||||
|
||||
const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
|
||||
|
||||
@Injectable()
|
||||
export class AuthHttpInterceptor implements HttpInterceptor {
|
||||
private excludedOrigins: Set<string> | null = null;
|
||||
private tokenEndpoint: string | null = null;
|
||||
private authorityResolved = false;
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthorityAuthService,
|
||||
private readonly config: AppConfigService,
|
||||
private readonly dpop: DpopService
|
||||
) {
|
||||
// lazy resolve authority configuration in intercept to allow APP_INITIALIZER to run first
|
||||
}
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
this.ensureAuthorityInfo();
|
||||
|
||||
if (request.headers.has('Authorization') || this.shouldSkip(request.url)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
return from(
|
||||
this.auth.getAuthHeadersForRequest(
|
||||
this.resolveAbsoluteUrl(request.url),
|
||||
request.method
|
||||
)
|
||||
).pipe(
|
||||
switchMap((headers) => {
|
||||
if (!headers) {
|
||||
return next.handle(request);
|
||||
}
|
||||
const authorizedRequest = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: headers.authorization,
|
||||
DPoP: headers.dpop,
|
||||
},
|
||||
headers: request.headers.set(RETRY_HEADER, '0'),
|
||||
});
|
||||
return next.handle(authorizedRequest);
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) =>
|
||||
this.handleError(request, error, next)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private handleError(
|
||||
request: HttpRequest<unknown>,
|
||||
error: HttpErrorResponse,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
if (error.status !== 401) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
const nonce = error.headers?.get('DPoP-Nonce');
|
||||
if (!nonce) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
if (request.headers.get(RETRY_HEADER) === '1') {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
return from(this.retryWithNonce(request, nonce, next)).pipe(
|
||||
catchError(() => throwError(() => error))
|
||||
);
|
||||
}
|
||||
|
||||
private async retryWithNonce(
|
||||
request: HttpRequest<unknown>,
|
||||
nonce: string,
|
||||
next: HttpHandler
|
||||
): Promise<HttpEvent<unknown>> {
|
||||
await this.dpop.setNonce(nonce);
|
||||
const headers = await this.auth.getAuthHeadersForRequest(
|
||||
this.resolveAbsoluteUrl(request.url),
|
||||
request.method
|
||||
);
|
||||
if (!headers) {
|
||||
throw new Error('Unable to refresh authorization headers after nonce.');
|
||||
}
|
||||
|
||||
const retried = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: headers.authorization,
|
||||
DPoP: headers.dpop,
|
||||
},
|
||||
headers: request.headers.set(RETRY_HEADER, '1'),
|
||||
});
|
||||
|
||||
return firstValueFrom(next.handle(retried));
|
||||
}
|
||||
|
||||
private shouldSkip(url: string): boolean {
|
||||
this.ensureAuthorityInfo();
|
||||
const absolute = this.resolveAbsoluteUrl(url);
|
||||
if (!absolute) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = new URL(absolute);
|
||||
if (resolved.pathname.endsWith('/config.json')) {
|
||||
return true;
|
||||
}
|
||||
if (this.tokenEndpoint && absolute.startsWith(this.tokenEndpoint)) {
|
||||
return true;
|
||||
}
|
||||
const origin = resolved.origin;
|
||||
return this.excludedOrigins?.has(origin) ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAbsoluteUrl(url: string): string {
|
||||
try {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
const base =
|
||||
typeof window !== 'undefined' && window.location
|
||||
? window.location.origin
|
||||
: undefined;
|
||||
return base ? new URL(url, base).toString() : url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAuthorityInfo(): void {
|
||||
if (this.authorityResolved) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const authority = this.config.authority;
|
||||
this.tokenEndpoint = new URL(
|
||||
authority.tokenEndpoint,
|
||||
authority.issuer
|
||||
).toString();
|
||||
this.excludedOrigins = new Set<string>([
|
||||
this.tokenEndpoint,
|
||||
new URL(authority.authorizeEndpoint, authority.issuer).origin,
|
||||
]);
|
||||
this.authorityResolved = true;
|
||||
} catch {
|
||||
// Configuration not yet loaded; interceptor will retry on the next request.
|
||||
}
|
||||
}
|
||||
}
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, firstValueFrom, from, throwError } from 'rxjs';
|
||||
import { catchError, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { DpopService } from './dpop/dpop.service';
|
||||
import { AuthorityAuthService } from './authority-auth.service';
|
||||
|
||||
const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
|
||||
|
||||
@Injectable()
|
||||
export class AuthHttpInterceptor implements HttpInterceptor {
|
||||
private excludedOrigins: Set<string> | null = null;
|
||||
private tokenEndpoint: string | null = null;
|
||||
private authorityResolved = false;
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthorityAuthService,
|
||||
private readonly config: AppConfigService,
|
||||
private readonly dpop: DpopService
|
||||
) {
|
||||
// lazy resolve authority configuration in intercept to allow APP_INITIALIZER to run first
|
||||
}
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
this.ensureAuthorityInfo();
|
||||
|
||||
if (request.headers.has('Authorization') || this.shouldSkip(request.url)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
return from(
|
||||
this.auth.getAuthHeadersForRequest(
|
||||
this.resolveAbsoluteUrl(request.url),
|
||||
request.method
|
||||
)
|
||||
).pipe(
|
||||
switchMap((headers) => {
|
||||
if (!headers) {
|
||||
return next.handle(request);
|
||||
}
|
||||
const authorizedRequest = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: headers.authorization,
|
||||
DPoP: headers.dpop,
|
||||
},
|
||||
headers: request.headers.set(RETRY_HEADER, '0'),
|
||||
});
|
||||
return next.handle(authorizedRequest);
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) =>
|
||||
this.handleError(request, error, next)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private handleError(
|
||||
request: HttpRequest<unknown>,
|
||||
error: HttpErrorResponse,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
if (error.status !== 401) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
const nonce = error.headers?.get('DPoP-Nonce');
|
||||
if (!nonce) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
if (request.headers.get(RETRY_HEADER) === '1') {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
return from(this.retryWithNonce(request, nonce, next)).pipe(
|
||||
catchError(() => throwError(() => error))
|
||||
);
|
||||
}
|
||||
|
||||
private async retryWithNonce(
|
||||
request: HttpRequest<unknown>,
|
||||
nonce: string,
|
||||
next: HttpHandler
|
||||
): Promise<HttpEvent<unknown>> {
|
||||
await this.dpop.setNonce(nonce);
|
||||
const headers = await this.auth.getAuthHeadersForRequest(
|
||||
this.resolveAbsoluteUrl(request.url),
|
||||
request.method
|
||||
);
|
||||
if (!headers) {
|
||||
throw new Error('Unable to refresh authorization headers after nonce.');
|
||||
}
|
||||
|
||||
const retried = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: headers.authorization,
|
||||
DPoP: headers.dpop,
|
||||
},
|
||||
headers: request.headers.set(RETRY_HEADER, '1'),
|
||||
});
|
||||
|
||||
return firstValueFrom(next.handle(retried));
|
||||
}
|
||||
|
||||
private shouldSkip(url: string): boolean {
|
||||
this.ensureAuthorityInfo();
|
||||
const absolute = this.resolveAbsoluteUrl(url);
|
||||
if (!absolute) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = new URL(absolute);
|
||||
if (resolved.pathname.endsWith('/config.json')) {
|
||||
return true;
|
||||
}
|
||||
if (this.tokenEndpoint && absolute.startsWith(this.tokenEndpoint)) {
|
||||
return true;
|
||||
}
|
||||
const origin = resolved.origin;
|
||||
return this.excludedOrigins?.has(origin) ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAbsoluteUrl(url: string): string {
|
||||
try {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
const base =
|
||||
typeof window !== 'undefined' && window.location
|
||||
? window.location.origin
|
||||
: undefined;
|
||||
return base ? new URL(url, base).toString() : url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAuthorityInfo(): void {
|
||||
if (this.authorityResolved) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const authority = this.config.authority;
|
||||
this.tokenEndpoint = new URL(
|
||||
authority.tokenEndpoint,
|
||||
authority.issuer
|
||||
).toString();
|
||||
this.excludedOrigins = new Set<string>([
|
||||
this.tokenEndpoint,
|
||||
new URL(authority.authorizeEndpoint, authority.issuer).origin,
|
||||
]);
|
||||
this.authorityResolved = true;
|
||||
} catch {
|
||||
// Configuration not yet loaded; interceptor will retry on the next request.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
export interface AuthTokens {
|
||||
readonly accessToken: string;
|
||||
readonly expiresAtEpochMs: number;
|
||||
readonly refreshToken?: string;
|
||||
readonly tokenType: 'Bearer';
|
||||
readonly scope: string;
|
||||
}
|
||||
|
||||
export interface AuthIdentity {
|
||||
readonly subject: string;
|
||||
readonly name?: string;
|
||||
readonly email?: string;
|
||||
readonly roles: readonly string[];
|
||||
readonly idToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
readonly tokens: AuthTokens;
|
||||
readonly identity: AuthIdentity;
|
||||
/**
|
||||
* SHA-256 JWK thumbprint of the active DPoP key pair.
|
||||
*/
|
||||
readonly dpopKeyThumbprint: string;
|
||||
readonly issuedAtEpochMs: number;
|
||||
readonly tenantId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationTimeEpochMs: number | null;
|
||||
readonly freshAuthActive: boolean;
|
||||
readonly freshAuthExpiresAtEpochMs: number | null;
|
||||
}
|
||||
|
||||
export interface PersistedSessionMetadata {
|
||||
readonly subject: string;
|
||||
readonly expiresAtEpochMs: number;
|
||||
readonly issuedAtEpochMs: number;
|
||||
readonly dpopKeyThumbprint: string;
|
||||
readonly tenantId?: string | null;
|
||||
}
|
||||
|
||||
export type AuthStatus =
|
||||
| 'unauthenticated'
|
||||
| 'authenticated'
|
||||
| 'refreshing'
|
||||
| 'loading';
|
||||
|
||||
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
|
||||
|
||||
export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
|
||||
|
||||
export type AuthErrorReason =
|
||||
| 'invalid_state'
|
||||
| 'token_exchange_failed'
|
||||
| 'refresh_failed'
|
||||
| 'dpop_generation_failed'
|
||||
| 'configuration_missing';
|
||||
export interface AuthTokens {
|
||||
readonly accessToken: string;
|
||||
readonly expiresAtEpochMs: number;
|
||||
readonly refreshToken?: string;
|
||||
readonly tokenType: 'Bearer';
|
||||
readonly scope: string;
|
||||
}
|
||||
|
||||
export interface AuthIdentity {
|
||||
readonly subject: string;
|
||||
readonly name?: string;
|
||||
readonly email?: string;
|
||||
readonly roles: readonly string[];
|
||||
readonly idToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
readonly tokens: AuthTokens;
|
||||
readonly identity: AuthIdentity;
|
||||
/**
|
||||
* SHA-256 JWK thumbprint of the active DPoP key pair.
|
||||
*/
|
||||
readonly dpopKeyThumbprint: string;
|
||||
readonly issuedAtEpochMs: number;
|
||||
readonly tenantId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationTimeEpochMs: number | null;
|
||||
readonly freshAuthActive: boolean;
|
||||
readonly freshAuthExpiresAtEpochMs: number | null;
|
||||
}
|
||||
|
||||
export interface PersistedSessionMetadata {
|
||||
readonly subject: string;
|
||||
readonly expiresAtEpochMs: number;
|
||||
readonly issuedAtEpochMs: number;
|
||||
readonly dpopKeyThumbprint: string;
|
||||
readonly tenantId?: string | null;
|
||||
}
|
||||
|
||||
export type AuthStatus =
|
||||
| 'unauthenticated'
|
||||
| 'authenticated'
|
||||
| 'refreshing'
|
||||
| 'loading';
|
||||
|
||||
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
|
||||
|
||||
export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
|
||||
|
||||
export type AuthErrorReason =
|
||||
| 'invalid_state'
|
||||
| 'token_exchange_failed'
|
||||
| 'refresh_failed'
|
||||
| 'dpop_generation_failed'
|
||||
| 'configuration_missing';
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
|
||||
import { AuthSessionStore } from './auth-session.store';
|
||||
|
||||
describe('AuthSessionStore', () => {
|
||||
let store: AuthSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [AuthSessionStore],
|
||||
});
|
||||
store = TestBed.inject(AuthSessionStore);
|
||||
});
|
||||
|
||||
it('persists minimal metadata when session is set', () => {
|
||||
const tokens: AuthTokens = {
|
||||
accessToken: 'token-abc',
|
||||
expiresAtEpochMs: Date.now() + 120_000,
|
||||
refreshToken: 'refresh-xyz',
|
||||
scope: 'openid ui.read',
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
|
||||
const session: AuthSession = {
|
||||
tokens,
|
||||
identity: {
|
||||
subject: 'user-123',
|
||||
name: 'Alex Operator',
|
||||
roles: ['ui.read'],
|
||||
},
|
||||
dpopKeyThumbprint: 'thumbprint-1',
|
||||
issuedAtEpochMs: Date.now(),
|
||||
tenantId: 'tenant-default',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationTimeEpochMs: Date.now(),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
|
||||
};
|
||||
|
||||
store.setSession(session);
|
||||
|
||||
const persisted = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
expect(persisted).toBeTruthy();
|
||||
const parsed = JSON.parse(persisted ?? '{}');
|
||||
expect(parsed.subject).toBe('user-123');
|
||||
expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
|
||||
expect(parsed.tenantId).toBe('tenant-default');
|
||||
|
||||
store.clear();
|
||||
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
|
||||
import { AuthSessionStore } from './auth-session.store';
|
||||
|
||||
describe('AuthSessionStore', () => {
|
||||
let store: AuthSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [AuthSessionStore],
|
||||
});
|
||||
store = TestBed.inject(AuthSessionStore);
|
||||
});
|
||||
|
||||
it('persists minimal metadata when session is set', () => {
|
||||
const tokens: AuthTokens = {
|
||||
accessToken: 'token-abc',
|
||||
expiresAtEpochMs: Date.now() + 120_000,
|
||||
refreshToken: 'refresh-xyz',
|
||||
scope: 'openid ui.read',
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
|
||||
const session: AuthSession = {
|
||||
tokens,
|
||||
identity: {
|
||||
subject: 'user-123',
|
||||
name: 'Alex Operator',
|
||||
roles: ['ui.read'],
|
||||
},
|
||||
dpopKeyThumbprint: 'thumbprint-1',
|
||||
issuedAtEpochMs: Date.now(),
|
||||
tenantId: 'tenant-default',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationTimeEpochMs: Date.now(),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
|
||||
};
|
||||
|
||||
store.setSession(session);
|
||||
|
||||
const persisted = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
expect(persisted).toBeTruthy();
|
||||
const parsed = JSON.parse(persisted ?? '{}');
|
||||
expect(parsed.subject).toBe('user-123');
|
||||
expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
|
||||
expect(parsed.tenantId).toBe('tenant-default');
|
||||
|
||||
store.clear();
|
||||
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,129 +1,129 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
import {
|
||||
AuthSession,
|
||||
AuthStatus,
|
||||
PersistedSessionMetadata,
|
||||
SESSION_STORAGE_KEY,
|
||||
} from './auth-session.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthSessionStore {
|
||||
private readonly sessionSignal = signal<AuthSession | null>(null);
|
||||
private readonly statusSignal = signal<AuthStatus>('unauthenticated');
|
||||
private readonly persistedSignal =
|
||||
signal<PersistedSessionMetadata | null>(this.readPersistedMetadata());
|
||||
|
||||
readonly session = computed(() => this.sessionSignal());
|
||||
readonly status = computed(() => this.statusSignal());
|
||||
|
||||
readonly identity = computed(() => this.sessionSignal()?.identity ?? null);
|
||||
readonly subjectHint = computed(
|
||||
() =>
|
||||
this.sessionSignal()?.identity.subject ??
|
||||
this.persistedSignal()?.subject ??
|
||||
null
|
||||
);
|
||||
|
||||
readonly expiresAtEpochMs = computed(
|
||||
() => this.sessionSignal()?.tokens.expiresAtEpochMs ?? null
|
||||
);
|
||||
|
||||
readonly isAuthenticated = computed(
|
||||
() => this.sessionSignal() !== null && this.statusSignal() !== 'loading'
|
||||
);
|
||||
|
||||
readonly tenantId = computed(
|
||||
() =>
|
||||
this.sessionSignal()?.tenantId ??
|
||||
this.persistedSignal()?.tenantId ??
|
||||
null
|
||||
);
|
||||
|
||||
setStatus(status: AuthStatus): void {
|
||||
this.statusSignal.set(status);
|
||||
}
|
||||
|
||||
setSession(session: AuthSession | null): void {
|
||||
this.sessionSignal.set(session);
|
||||
if (!session) {
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.persistedSignal.set(null);
|
||||
this.clearPersistedMetadata();
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusSignal.set('authenticated');
|
||||
const metadata: PersistedSessionMetadata = {
|
||||
subject: session.identity.subject,
|
||||
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
|
||||
issuedAtEpochMs: session.issuedAtEpochMs,
|
||||
dpopKeyThumbprint: session.dpopKeyThumbprint,
|
||||
tenantId: session.tenantId,
|
||||
};
|
||||
this.persistedSignal.set(metadata);
|
||||
this.persistMetadata(metadata);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.sessionSignal.set(null);
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.persistedSignal.set(null);
|
||||
this.clearPersistedMetadata();
|
||||
}
|
||||
|
||||
private readPersistedMetadata(): PersistedSessionMetadata | null {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PersistedSessionMetadata;
|
||||
if (
|
||||
typeof parsed.subject !== 'string' ||
|
||||
typeof parsed.expiresAtEpochMs !== 'number' ||
|
||||
typeof parsed.issuedAtEpochMs !== 'number' ||
|
||||
typeof parsed.dpopKeyThumbprint !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const tenantId =
|
||||
typeof parsed.tenantId === 'string'
|
||||
? parsed.tenantId.trim() || null
|
||||
: null;
|
||||
return {
|
||||
subject: parsed.subject,
|
||||
expiresAtEpochMs: parsed.expiresAtEpochMs,
|
||||
issuedAtEpochMs: parsed.issuedAtEpochMs,
|
||||
dpopKeyThumbprint: parsed.dpopKeyThumbprint,
|
||||
tenantId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private persistMetadata(metadata: PersistedSessionMetadata): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
private clearPersistedMetadata(): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
getActiveTenantId(): string | null {
|
||||
return this.tenantId();
|
||||
}
|
||||
}
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
import {
|
||||
AuthSession,
|
||||
AuthStatus,
|
||||
PersistedSessionMetadata,
|
||||
SESSION_STORAGE_KEY,
|
||||
} from './auth-session.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthSessionStore {
|
||||
private readonly sessionSignal = signal<AuthSession | null>(null);
|
||||
private readonly statusSignal = signal<AuthStatus>('unauthenticated');
|
||||
private readonly persistedSignal =
|
||||
signal<PersistedSessionMetadata | null>(this.readPersistedMetadata());
|
||||
|
||||
readonly session = computed(() => this.sessionSignal());
|
||||
readonly status = computed(() => this.statusSignal());
|
||||
|
||||
readonly identity = computed(() => this.sessionSignal()?.identity ?? null);
|
||||
readonly subjectHint = computed(
|
||||
() =>
|
||||
this.sessionSignal()?.identity.subject ??
|
||||
this.persistedSignal()?.subject ??
|
||||
null
|
||||
);
|
||||
|
||||
readonly expiresAtEpochMs = computed(
|
||||
() => this.sessionSignal()?.tokens.expiresAtEpochMs ?? null
|
||||
);
|
||||
|
||||
readonly isAuthenticated = computed(
|
||||
() => this.sessionSignal() !== null && this.statusSignal() !== 'loading'
|
||||
);
|
||||
|
||||
readonly tenantId = computed(
|
||||
() =>
|
||||
this.sessionSignal()?.tenantId ??
|
||||
this.persistedSignal()?.tenantId ??
|
||||
null
|
||||
);
|
||||
|
||||
setStatus(status: AuthStatus): void {
|
||||
this.statusSignal.set(status);
|
||||
}
|
||||
|
||||
setSession(session: AuthSession | null): void {
|
||||
this.sessionSignal.set(session);
|
||||
if (!session) {
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.persistedSignal.set(null);
|
||||
this.clearPersistedMetadata();
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusSignal.set('authenticated');
|
||||
const metadata: PersistedSessionMetadata = {
|
||||
subject: session.identity.subject,
|
||||
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
|
||||
issuedAtEpochMs: session.issuedAtEpochMs,
|
||||
dpopKeyThumbprint: session.dpopKeyThumbprint,
|
||||
tenantId: session.tenantId,
|
||||
};
|
||||
this.persistedSignal.set(metadata);
|
||||
this.persistMetadata(metadata);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.sessionSignal.set(null);
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.persistedSignal.set(null);
|
||||
this.clearPersistedMetadata();
|
||||
}
|
||||
|
||||
private readPersistedMetadata(): PersistedSessionMetadata | null {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PersistedSessionMetadata;
|
||||
if (
|
||||
typeof parsed.subject !== 'string' ||
|
||||
typeof parsed.expiresAtEpochMs !== 'number' ||
|
||||
typeof parsed.issuedAtEpochMs !== 'number' ||
|
||||
typeof parsed.dpopKeyThumbprint !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const tenantId =
|
||||
typeof parsed.tenantId === 'string'
|
||||
? parsed.tenantId.trim() || null
|
||||
: null;
|
||||
return {
|
||||
subject: parsed.subject,
|
||||
expiresAtEpochMs: parsed.expiresAtEpochMs,
|
||||
issuedAtEpochMs: parsed.issuedAtEpochMs,
|
||||
dpopKeyThumbprint: parsed.dpopKeyThumbprint,
|
||||
tenantId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private persistMetadata(metadata: PersistedSessionMetadata): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
private clearPersistedMetadata(): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
getActiveTenantId(): string | null {
|
||||
return this.tenantId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
const LOGIN_REQUEST_KEY = 'stellaops.auth.login.request';
|
||||
|
||||
export interface PendingLoginRequest {
|
||||
readonly state: string;
|
||||
readonly codeVerifier: string;
|
||||
readonly createdAtEpochMs: number;
|
||||
readonly returnUrl?: string;
|
||||
readonly nonce?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthStorageService {
|
||||
savePendingLogin(request: PendingLoginRequest): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(LOGIN_REQUEST_KEY, JSON.stringify(request));
|
||||
}
|
||||
|
||||
consumePendingLogin(expectedState: string): PendingLoginRequest | null {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = sessionStorage.getItem(LOGIN_REQUEST_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(LOGIN_REQUEST_KEY);
|
||||
try {
|
||||
const request = JSON.parse(raw) as PendingLoginRequest;
|
||||
if (request.state !== expectedState) {
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
const LOGIN_REQUEST_KEY = 'stellaops.auth.login.request';
|
||||
|
||||
export interface PendingLoginRequest {
|
||||
readonly state: string;
|
||||
readonly codeVerifier: string;
|
||||
readonly createdAtEpochMs: number;
|
||||
readonly returnUrl?: string;
|
||||
readonly nonce?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthStorageService {
|
||||
savePendingLogin(request: PendingLoginRequest): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(LOGIN_REQUEST_KEY, JSON.stringify(request));
|
||||
}
|
||||
|
||||
consumePendingLogin(expectedState: string): PendingLoginRequest | null {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = sessionStorage.getItem(LOGIN_REQUEST_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(LOGIN_REQUEST_KEY);
|
||||
try {
|
||||
const request = JSON.parse(raw) as PendingLoginRequest;
|
||||
if (request.state !== expectedState) {
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,217 +1,217 @@
|
||||
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
|
||||
import {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
/**
|
||||
* User info from authentication.
|
||||
*/
|
||||
export interface AuthUser {
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly name: string;
|
||||
readonly tenantId: string;
|
||||
readonly tenantName: string;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly StellaOpsScope[];
|
||||
readonly picture?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection token for Auth service.
|
||||
*/
|
||||
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
|
||||
|
||||
/**
|
||||
* Auth service interface.
|
||||
*/
|
||||
export interface AuthService {
|
||||
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
|
||||
readonly user: ReturnType<typeof signal<AuthUser | null>>;
|
||||
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean;
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
|
||||
canViewGraph(): boolean;
|
||||
canEditGraph(): boolean;
|
||||
canExportGraph(): boolean;
|
||||
canSimulate(): boolean;
|
||||
// Orchestrator access (UI-ORCH-32-001)
|
||||
canViewOrchestrator(): boolean;
|
||||
canOperateOrchestrator(): boolean;
|
||||
canManageOrchestratorQuotas(): boolean;
|
||||
canInitiateBackfill(): boolean;
|
||||
// Policy Studio access (UI-POLICY-20-003)
|
||||
canViewPolicies(): boolean;
|
||||
canAuthorPolicies(): boolean;
|
||||
canEditPolicies(): boolean;
|
||||
canReviewPolicies(): boolean;
|
||||
canApprovePolicies(): boolean;
|
||||
canOperatePolicies(): boolean;
|
||||
canActivatePolicies(): boolean;
|
||||
canSimulatePolicies(): boolean;
|
||||
canPublishPolicies(): boolean;
|
||||
canAuditPolicies(): boolean;
|
||||
// Session management
|
||||
logout?(): void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Auth Service
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_USER: AuthUser = {
|
||||
id: 'user-001',
|
||||
email: 'developer@example.com',
|
||||
name: 'Developer User',
|
||||
tenantId: 'tenant-001',
|
||||
tenantName: 'Acme Corp',
|
||||
roles: ['developer', 'security-analyst'],
|
||||
scopes: [
|
||||
// Graph permissions
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
// SBOM permissions
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
// Policy permissions (Policy Studio - UI-POLICY-20-003)
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.POLICY_AUTHOR,
|
||||
StellaOpsScopes.POLICY_EDIT,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_SUBMIT,
|
||||
StellaOpsScopes.POLICY_APPROVE,
|
||||
StellaOpsScopes.POLICY_OPERATE,
|
||||
StellaOpsScopes.POLICY_ACTIVATE,
|
||||
StellaOpsScopes.POLICY_RUN,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
// Scanner permissions
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
// Exception permissions
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
// Release permissions
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
// AOC permissions
|
||||
StellaOpsScopes.AOC_READ,
|
||||
// Orchestrator permissions (UI-ORCH-32-001)
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
|
||||
import {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
/**
|
||||
* User info from authentication.
|
||||
*/
|
||||
export interface AuthUser {
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly name: string;
|
||||
readonly tenantId: string;
|
||||
readonly tenantName: string;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly StellaOpsScope[];
|
||||
readonly picture?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection token for Auth service.
|
||||
*/
|
||||
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
|
||||
|
||||
/**
|
||||
* Auth service interface.
|
||||
*/
|
||||
export interface AuthService {
|
||||
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
|
||||
readonly user: ReturnType<typeof signal<AuthUser | null>>;
|
||||
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean;
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
|
||||
canViewGraph(): boolean;
|
||||
canEditGraph(): boolean;
|
||||
canExportGraph(): boolean;
|
||||
canSimulate(): boolean;
|
||||
// Orchestrator access (UI-ORCH-32-001)
|
||||
canViewOrchestrator(): boolean;
|
||||
canOperateOrchestrator(): boolean;
|
||||
canManageOrchestratorQuotas(): boolean;
|
||||
canInitiateBackfill(): boolean;
|
||||
// Policy Studio access (UI-POLICY-20-003)
|
||||
canViewPolicies(): boolean;
|
||||
canAuthorPolicies(): boolean;
|
||||
canEditPolicies(): boolean;
|
||||
canReviewPolicies(): boolean;
|
||||
canApprovePolicies(): boolean;
|
||||
canOperatePolicies(): boolean;
|
||||
canActivatePolicies(): boolean;
|
||||
canSimulatePolicies(): boolean;
|
||||
canPublishPolicies(): boolean;
|
||||
canAuditPolicies(): boolean;
|
||||
// Session management
|
||||
logout?(): void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Auth Service
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_USER: AuthUser = {
|
||||
id: 'user-001',
|
||||
email: 'developer@example.com',
|
||||
name: 'Developer User',
|
||||
tenantId: 'tenant-001',
|
||||
tenantName: 'Acme Corp',
|
||||
roles: ['developer', 'security-analyst'],
|
||||
scopes: [
|
||||
// Graph permissions
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
// SBOM permissions
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
// Policy permissions (Policy Studio - UI-POLICY-20-003)
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.POLICY_AUTHOR,
|
||||
StellaOpsScopes.POLICY_EDIT,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_SUBMIT,
|
||||
StellaOpsScopes.POLICY_APPROVE,
|
||||
StellaOpsScopes.POLICY_OPERATE,
|
||||
StellaOpsScopes.POLICY_ACTIVATE,
|
||||
StellaOpsScopes.POLICY_RUN,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
// Scanner permissions
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
// Exception permissions
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
// Release permissions
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
// AOC permissions
|
||||
StellaOpsScopes.AOC_READ,
|
||||
// Orchestrator permissions (UI-ORCH-32-001)
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
// UI permissions
|
||||
StellaOpsScopes.UI_READ,
|
||||
// Analytics permissions
|
||||
StellaOpsScopes.ANALYTICS_READ,
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAuthService implements AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly user = signal<AuthUser | null>(MOCK_USER);
|
||||
|
||||
readonly scopes = computed(() => {
|
||||
const u = this.user();
|
||||
return u?.scopes ?? [];
|
||||
});
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean {
|
||||
return hasScope(this.scopes(), scope);
|
||||
}
|
||||
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAllScopes(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAnyScope(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
canViewGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_READ);
|
||||
}
|
||||
|
||||
canEditGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
|
||||
}
|
||||
|
||||
canExportGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
|
||||
}
|
||||
|
||||
canSimulate(): boolean {
|
||||
return this.hasAnyScope([
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
]);
|
||||
}
|
||||
|
||||
// Orchestrator access methods (UI-ORCH-32-001)
|
||||
canViewOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_READ);
|
||||
}
|
||||
|
||||
canOperateOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
|
||||
}
|
||||
|
||||
canManageOrchestratorQuotas(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
|
||||
}
|
||||
|
||||
canInitiateBackfill(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
|
||||
}
|
||||
|
||||
// Policy Studio access methods (UI-POLICY-20-003)
|
||||
canViewPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_READ);
|
||||
}
|
||||
|
||||
canAuthorPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_AUTHOR);
|
||||
}
|
||||
|
||||
canEditPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_EDIT);
|
||||
}
|
||||
|
||||
canReviewPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_REVIEW);
|
||||
}
|
||||
|
||||
canApprovePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_APPROVE);
|
||||
}
|
||||
|
||||
canOperatePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_OPERATE);
|
||||
}
|
||||
|
||||
canActivatePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_ACTIVATE);
|
||||
}
|
||||
|
||||
canSimulatePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_SIMULATE);
|
||||
}
|
||||
|
||||
canPublishPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_PUBLISH);
|
||||
}
|
||||
|
||||
canAuditPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_AUDIT);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export scopes for convenience
|
||||
export { StellaOpsScopes, ScopeGroups } from './scopes';
|
||||
export type { StellaOpsScope } from './scopes';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAuthService implements AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly user = signal<AuthUser | null>(MOCK_USER);
|
||||
|
||||
readonly scopes = computed(() => {
|
||||
const u = this.user();
|
||||
return u?.scopes ?? [];
|
||||
});
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean {
|
||||
return hasScope(this.scopes(), scope);
|
||||
}
|
||||
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAllScopes(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAnyScope(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
canViewGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_READ);
|
||||
}
|
||||
|
||||
canEditGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
|
||||
}
|
||||
|
||||
canExportGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
|
||||
}
|
||||
|
||||
canSimulate(): boolean {
|
||||
return this.hasAnyScope([
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
]);
|
||||
}
|
||||
|
||||
// Orchestrator access methods (UI-ORCH-32-001)
|
||||
canViewOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_READ);
|
||||
}
|
||||
|
||||
canOperateOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
|
||||
}
|
||||
|
||||
canManageOrchestratorQuotas(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
|
||||
}
|
||||
|
||||
canInitiateBackfill(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
|
||||
}
|
||||
|
||||
// Policy Studio access methods (UI-POLICY-20-003)
|
||||
canViewPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_READ);
|
||||
}
|
||||
|
||||
canAuthorPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_AUTHOR);
|
||||
}
|
||||
|
||||
canEditPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_EDIT);
|
||||
}
|
||||
|
||||
canReviewPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_REVIEW);
|
||||
}
|
||||
|
||||
canApprovePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_APPROVE);
|
||||
}
|
||||
|
||||
canOperatePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_OPERATE);
|
||||
}
|
||||
|
||||
canActivatePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_ACTIVATE);
|
||||
}
|
||||
|
||||
canSimulatePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_SIMULATE);
|
||||
}
|
||||
|
||||
canPublishPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_PUBLISH);
|
||||
}
|
||||
|
||||
canAuditPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_AUDIT);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export scopes for convenience
|
||||
export { StellaOpsScopes, ScopeGroups } from './scopes';
|
||||
export type { StellaOpsScope } from './scopes';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,181 +1,181 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { DPoPAlgorithm } from '../../config/app-config.model';
|
||||
import { computeJwkThumbprint } from './jose-utilities';
|
||||
|
||||
const DB_NAME = 'stellaops-auth';
|
||||
const STORE_NAME = 'dpopKeys';
|
||||
const PRIMARY_KEY = 'primary';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
interface PersistedKeyPair {
|
||||
readonly id: string;
|
||||
readonly algorithm: DPoPAlgorithm;
|
||||
readonly publicJwk: JsonWebKey;
|
||||
readonly privateJwk: JsonWebKey;
|
||||
readonly thumbprint: string;
|
||||
readonly createdAtIso: string;
|
||||
}
|
||||
|
||||
export interface LoadedDpopKeyPair {
|
||||
readonly algorithm: DPoPAlgorithm;
|
||||
readonly privateKey: CryptoKey;
|
||||
readonly publicKey: CryptoKey;
|
||||
readonly publicJwk: JsonWebKey;
|
||||
readonly thumbprint: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DpopKeyStore {
|
||||
private dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
async load(): Promise<LoadedDpopKeyPair | null> {
|
||||
const record = await this.read();
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [privateKey, publicKey] = await Promise.all([
|
||||
crypto.subtle.importKey(
|
||||
'jwk',
|
||||
record.privateJwk,
|
||||
this.toKeyAlgorithm(record.algorithm),
|
||||
true,
|
||||
['sign']
|
||||
),
|
||||
crypto.subtle.importKey(
|
||||
'jwk',
|
||||
record.publicJwk,
|
||||
this.toKeyAlgorithm(record.algorithm),
|
||||
true,
|
||||
['verify']
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
algorithm: record.algorithm,
|
||||
privateKey,
|
||||
publicKey,
|
||||
publicJwk: record.publicJwk,
|
||||
thumbprint: record.thumbprint,
|
||||
};
|
||||
}
|
||||
|
||||
async save(
|
||||
keyPair: CryptoKeyPair,
|
||||
algorithm: DPoPAlgorithm
|
||||
): Promise<LoadedDpopKeyPair> {
|
||||
const [publicJwk, privateJwk] = await Promise.all([
|
||||
crypto.subtle.exportKey('jwk', keyPair.publicKey),
|
||||
crypto.subtle.exportKey('jwk', keyPair.privateKey),
|
||||
]);
|
||||
|
||||
if (!publicJwk) {
|
||||
throw new Error('Failed to export public JWK for DPoP key pair.');
|
||||
}
|
||||
|
||||
const thumbprint = await computeJwkThumbprint(publicJwk);
|
||||
const record: PersistedKeyPair = {
|
||||
id: PRIMARY_KEY,
|
||||
algorithm,
|
||||
publicJwk,
|
||||
privateJwk,
|
||||
thumbprint,
|
||||
createdAtIso: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.write(record);
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
publicJwk,
|
||||
thumbprint,
|
||||
};
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const db = await this.openDb();
|
||||
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
||||
store.delete(PRIMARY_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
async generate(algorithm: DPoPAlgorithm): Promise<LoadedDpopKeyPair> {
|
||||
const algo = this.toKeyAlgorithm(algorithm);
|
||||
const keyPair = await crypto.subtle.generateKey(algo, true, [
|
||||
'sign',
|
||||
'verify',
|
||||
]);
|
||||
|
||||
const stored = await this.save(keyPair, algorithm);
|
||||
return stored;
|
||||
}
|
||||
|
||||
private async read(): Promise<PersistedKeyPair | null> {
|
||||
const db = await this.openDb();
|
||||
return transactionPromise(db, STORE_NAME, 'readonly', (store) =>
|
||||
store.get(PRIMARY_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
private async write(record: PersistedKeyPair): Promise<void> {
|
||||
const db = await this.openDb();
|
||||
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
||||
store.put(record)
|
||||
);
|
||||
}
|
||||
|
||||
private toKeyAlgorithm(algorithm: DPoPAlgorithm): EcKeyImportParams {
|
||||
switch (algorithm) {
|
||||
case 'ES384':
|
||||
return { name: 'ECDSA', namedCurve: 'P-384' };
|
||||
case 'EdDSA':
|
||||
throw new Error('EdDSA DPoP keys are not yet supported.');
|
||||
case 'ES256':
|
||||
default:
|
||||
return { name: 'ECDSA', namedCurve: 'P-256' };
|
||||
}
|
||||
}
|
||||
|
||||
private async openDb(): Promise<IDBDatabase> {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
throw new Error('IndexedDB is not available for DPoP key persistence.');
|
||||
}
|
||||
|
||||
if (!this.dbPromise) {
|
||||
this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
return this.dbPromise;
|
||||
}
|
||||
}
|
||||
|
||||
function transactionPromise<T>(
|
||||
db: IDBDatabase,
|
||||
storeName: string,
|
||||
mode: IDBTransactionMode,
|
||||
executor: (store: IDBObjectStore) => IDBRequest<T>
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, mode);
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = executor(store);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { DPoPAlgorithm } from '../../config/app-config.model';
|
||||
import { computeJwkThumbprint } from './jose-utilities';
|
||||
|
||||
const DB_NAME = 'stellaops-auth';
|
||||
const STORE_NAME = 'dpopKeys';
|
||||
const PRIMARY_KEY = 'primary';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
interface PersistedKeyPair {
|
||||
readonly id: string;
|
||||
readonly algorithm: DPoPAlgorithm;
|
||||
readonly publicJwk: JsonWebKey;
|
||||
readonly privateJwk: JsonWebKey;
|
||||
readonly thumbprint: string;
|
||||
readonly createdAtIso: string;
|
||||
}
|
||||
|
||||
export interface LoadedDpopKeyPair {
|
||||
readonly algorithm: DPoPAlgorithm;
|
||||
readonly privateKey: CryptoKey;
|
||||
readonly publicKey: CryptoKey;
|
||||
readonly publicJwk: JsonWebKey;
|
||||
readonly thumbprint: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DpopKeyStore {
|
||||
private dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
async load(): Promise<LoadedDpopKeyPair | null> {
|
||||
const record = await this.read();
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [privateKey, publicKey] = await Promise.all([
|
||||
crypto.subtle.importKey(
|
||||
'jwk',
|
||||
record.privateJwk,
|
||||
this.toKeyAlgorithm(record.algorithm),
|
||||
true,
|
||||
['sign']
|
||||
),
|
||||
crypto.subtle.importKey(
|
||||
'jwk',
|
||||
record.publicJwk,
|
||||
this.toKeyAlgorithm(record.algorithm),
|
||||
true,
|
||||
['verify']
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
algorithm: record.algorithm,
|
||||
privateKey,
|
||||
publicKey,
|
||||
publicJwk: record.publicJwk,
|
||||
thumbprint: record.thumbprint,
|
||||
};
|
||||
}
|
||||
|
||||
async save(
|
||||
keyPair: CryptoKeyPair,
|
||||
algorithm: DPoPAlgorithm
|
||||
): Promise<LoadedDpopKeyPair> {
|
||||
const [publicJwk, privateJwk] = await Promise.all([
|
||||
crypto.subtle.exportKey('jwk', keyPair.publicKey),
|
||||
crypto.subtle.exportKey('jwk', keyPair.privateKey),
|
||||
]);
|
||||
|
||||
if (!publicJwk) {
|
||||
throw new Error('Failed to export public JWK for DPoP key pair.');
|
||||
}
|
||||
|
||||
const thumbprint = await computeJwkThumbprint(publicJwk);
|
||||
const record: PersistedKeyPair = {
|
||||
id: PRIMARY_KEY,
|
||||
algorithm,
|
||||
publicJwk,
|
||||
privateJwk,
|
||||
thumbprint,
|
||||
createdAtIso: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.write(record);
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
publicJwk,
|
||||
thumbprint,
|
||||
};
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const db = await this.openDb();
|
||||
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
||||
store.delete(PRIMARY_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
async generate(algorithm: DPoPAlgorithm): Promise<LoadedDpopKeyPair> {
|
||||
const algo = this.toKeyAlgorithm(algorithm);
|
||||
const keyPair = await crypto.subtle.generateKey(algo, true, [
|
||||
'sign',
|
||||
'verify',
|
||||
]);
|
||||
|
||||
const stored = await this.save(keyPair, algorithm);
|
||||
return stored;
|
||||
}
|
||||
|
||||
private async read(): Promise<PersistedKeyPair | null> {
|
||||
const db = await this.openDb();
|
||||
return transactionPromise(db, STORE_NAME, 'readonly', (store) =>
|
||||
store.get(PRIMARY_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
private async write(record: PersistedKeyPair): Promise<void> {
|
||||
const db = await this.openDb();
|
||||
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
||||
store.put(record)
|
||||
);
|
||||
}
|
||||
|
||||
private toKeyAlgorithm(algorithm: DPoPAlgorithm): EcKeyImportParams {
|
||||
switch (algorithm) {
|
||||
case 'ES384':
|
||||
return { name: 'ECDSA', namedCurve: 'P-384' };
|
||||
case 'EdDSA':
|
||||
throw new Error('EdDSA DPoP keys are not yet supported.');
|
||||
case 'ES256':
|
||||
default:
|
||||
return { name: 'ECDSA', namedCurve: 'P-256' };
|
||||
}
|
||||
}
|
||||
|
||||
private async openDb(): Promise<IDBDatabase> {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
throw new Error('IndexedDB is not available for DPoP key persistence.');
|
||||
}
|
||||
|
||||
if (!this.dbPromise) {
|
||||
this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
return this.dbPromise;
|
||||
}
|
||||
}
|
||||
|
||||
function transactionPromise<T>(
|
||||
db: IDBDatabase,
|
||||
storeName: string,
|
||||
mode: IDBTransactionMode,
|
||||
executor: (store: IDBObjectStore) => IDBRequest<T>
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, mode);
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = executor(store);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { APP_CONFIG, AppConfig } from '../../config/app-config.model';
|
||||
import { AppConfigService } from '../../config/app-config.service';
|
||||
import { base64UrlDecode } from './jose-utilities';
|
||||
import { DpopKeyStore } from './dpop-key-store';
|
||||
import { DpopService } from './dpop.service';
|
||||
|
||||
describe('DpopService', () => {
|
||||
const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
|
||||
const config: AppConfig = {
|
||||
authority: {
|
||||
issuer: 'https://auth.stellaops.test/',
|
||||
clientId: 'ui-client',
|
||||
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
|
||||
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
|
||||
redirectUri: 'https://ui.stellaops.test/auth/callback',
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { APP_CONFIG, AppConfig } from '../../config/app-config.model';
|
||||
import { AppConfigService } from '../../config/app-config.service';
|
||||
import { base64UrlDecode } from './jose-utilities';
|
||||
import { DpopKeyStore } from './dpop-key-store';
|
||||
import { DpopService } from './dpop.service';
|
||||
|
||||
describe('DpopService', () => {
|
||||
const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
|
||||
const config: AppConfig = {
|
||||
authority: {
|
||||
issuer: 'https://auth.stellaops.test/',
|
||||
clientId: 'ui-client',
|
||||
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
|
||||
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
|
||||
redirectUri: 'https://ui.stellaops.test/auth/callback',
|
||||
scope: 'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
|
||||
audience: 'https://scanner.stellaops.test',
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://auth.stellaops.test',
|
||||
scanner: 'https://scanner.stellaops.test',
|
||||
policy: 'https://policy.stellaops.test',
|
||||
concelier: 'https://concelier.stellaops.test',
|
||||
attestor: 'https://attestor.stellaops.test',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
AppConfigService,
|
||||
DpopKeyStore,
|
||||
DpopService,
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
useValue: config,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
|
||||
const store = TestBed.inject(DpopKeyStore);
|
||||
try {
|
||||
await store.clear();
|
||||
} catch {
|
||||
// ignore cleanup issues in test environment
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a DPoP proof with expected header values', async () => {
|
||||
const appConfig = TestBed.inject(AppConfigService);
|
||||
appConfig.setConfigForTesting(config);
|
||||
const service = TestBed.inject(DpopService);
|
||||
|
||||
const proof = await service.createProof({
|
||||
htm: 'get',
|
||||
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
||||
});
|
||||
|
||||
const [rawHeader, rawPayload] = proof.split('.');
|
||||
const header = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(rawHeader))
|
||||
);
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(rawPayload))
|
||||
);
|
||||
|
||||
expect(header.typ).toBe('dpop+jwt');
|
||||
expect(header.alg).toBe('ES256');
|
||||
expect(header.jwk.kty).toBe('EC');
|
||||
expect(payload.htm).toBe('GET');
|
||||
expect(payload.htu).toBe('https://scanner.stellaops.test/api/v1/scans');
|
||||
expect(typeof payload.iat).toBe('number');
|
||||
expect(typeof payload.jti).toBe('string');
|
||||
});
|
||||
|
||||
it('binds access token hash when provided', async () => {
|
||||
const appConfig = TestBed.inject(AppConfigService);
|
||||
appConfig.setConfigForTesting(config);
|
||||
const service = TestBed.inject(DpopService);
|
||||
|
||||
const accessToken = 'sample-access-token';
|
||||
const proof = await service.createProof({
|
||||
htm: 'post',
|
||||
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
||||
accessToken,
|
||||
});
|
||||
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(proof.split('.')[1]))
|
||||
);
|
||||
|
||||
expect(payload.ath).toBeDefined();
|
||||
expect(typeof payload.ath).toBe('string');
|
||||
});
|
||||
});
|
||||
audience: 'https://scanner.stellaops.test',
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://auth.stellaops.test',
|
||||
scanner: 'https://scanner.stellaops.test',
|
||||
policy: 'https://policy.stellaops.test',
|
||||
concelier: 'https://concelier.stellaops.test',
|
||||
attestor: 'https://attestor.stellaops.test',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
AppConfigService,
|
||||
DpopKeyStore,
|
||||
DpopService,
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
useValue: config,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
|
||||
const store = TestBed.inject(DpopKeyStore);
|
||||
try {
|
||||
await store.clear();
|
||||
} catch {
|
||||
// ignore cleanup issues in test environment
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a DPoP proof with expected header values', async () => {
|
||||
const appConfig = TestBed.inject(AppConfigService);
|
||||
appConfig.setConfigForTesting(config);
|
||||
const service = TestBed.inject(DpopService);
|
||||
|
||||
const proof = await service.createProof({
|
||||
htm: 'get',
|
||||
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
||||
});
|
||||
|
||||
const [rawHeader, rawPayload] = proof.split('.');
|
||||
const header = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(rawHeader))
|
||||
);
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(rawPayload))
|
||||
);
|
||||
|
||||
expect(header.typ).toBe('dpop+jwt');
|
||||
expect(header.alg).toBe('ES256');
|
||||
expect(header.jwk.kty).toBe('EC');
|
||||
expect(payload.htm).toBe('GET');
|
||||
expect(payload.htu).toBe('https://scanner.stellaops.test/api/v1/scans');
|
||||
expect(typeof payload.iat).toBe('number');
|
||||
expect(typeof payload.jti).toBe('string');
|
||||
});
|
||||
|
||||
it('binds access token hash when provided', async () => {
|
||||
const appConfig = TestBed.inject(AppConfigService);
|
||||
appConfig.setConfigForTesting(config);
|
||||
const service = TestBed.inject(DpopService);
|
||||
|
||||
const accessToken = 'sample-access-token';
|
||||
const proof = await service.createProof({
|
||||
htm: 'post',
|
||||
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
||||
accessToken,
|
||||
});
|
||||
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(proof.split('.')[1]))
|
||||
);
|
||||
|
||||
expect(payload.ath).toBeDefined();
|
||||
expect(typeof payload.ath).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,148 +1,148 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
import { AppConfigService } from '../../config/app-config.service';
|
||||
import { DPoPAlgorithm } from '../../config/app-config.model';
|
||||
import { sha256, base64UrlEncode, derToJoseSignature } from './jose-utilities';
|
||||
import { DpopKeyStore, LoadedDpopKeyPair } from './dpop-key-store';
|
||||
|
||||
export interface DpopProofOptions {
|
||||
readonly htm: string;
|
||||
readonly htu: string;
|
||||
readonly accessToken?: string;
|
||||
readonly nonce?: string | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DpopService {
|
||||
private keyPairPromise: Promise<LoadedDpopKeyPair> | null = null;
|
||||
private readonly nonceSignal = signal<string | null>(null);
|
||||
readonly nonce = computed(() => this.nonceSignal());
|
||||
|
||||
constructor(
|
||||
private readonly config: AppConfigService,
|
||||
private readonly store: DpopKeyStore
|
||||
) {}
|
||||
|
||||
async setNonce(nonce: string | null): Promise<void> {
|
||||
this.nonceSignal.set(nonce);
|
||||
}
|
||||
|
||||
async getThumbprint(): Promise<string | null> {
|
||||
const key = await this.getOrCreateKeyPair();
|
||||
return key.thumbprint ?? null;
|
||||
}
|
||||
|
||||
async rotateKey(): Promise<void> {
|
||||
const algorithm = this.resolveAlgorithm();
|
||||
this.keyPairPromise = this.store.generate(algorithm);
|
||||
}
|
||||
|
||||
async createProof(options: DpopProofOptions): Promise<string> {
|
||||
const keyPair = await this.getOrCreateKeyPair();
|
||||
|
||||
const header = {
|
||||
typ: 'dpop+jwt',
|
||||
alg: keyPair.algorithm,
|
||||
jwk: keyPair.publicJwk,
|
||||
};
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const payload: Record<string, unknown> = {
|
||||
htm: options.htm.toUpperCase(),
|
||||
htu: normalizeHtu(options.htu),
|
||||
iat: nowSeconds,
|
||||
jti: crypto.randomUUID ? crypto.randomUUID() : createRandomId(),
|
||||
};
|
||||
|
||||
const nonce = options.nonce ?? this.nonceSignal();
|
||||
if (nonce) {
|
||||
payload['nonce'] = nonce;
|
||||
}
|
||||
|
||||
if (options.accessToken) {
|
||||
const accessTokenHash = await sha256(
|
||||
new TextEncoder().encode(options.accessToken)
|
||||
);
|
||||
payload['ath'] = base64UrlEncode(accessTokenHash);
|
||||
}
|
||||
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = await crypto.subtle.sign(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
hash: this.resolveHashAlgorithm(keyPair.algorithm),
|
||||
},
|
||||
keyPair.privateKey,
|
||||
new TextEncoder().encode(signingInput)
|
||||
);
|
||||
|
||||
const joseSignature = base64UrlEncode(derToJoseSignature(signature));
|
||||
return `${signingInput}.${joseSignature}`;
|
||||
}
|
||||
|
||||
private async getOrCreateKeyPair(): Promise<LoadedDpopKeyPair> {
|
||||
if (!this.keyPairPromise) {
|
||||
this.keyPairPromise = this.loadKeyPair();
|
||||
}
|
||||
try {
|
||||
return await this.keyPairPromise;
|
||||
} catch (error) {
|
||||
// Reset the memoized promise so a subsequent call can retry.
|
||||
this.keyPairPromise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadKeyPair(): Promise<LoadedDpopKeyPair> {
|
||||
const algorithm = this.resolveAlgorithm();
|
||||
try {
|
||||
const existing = await this.store.load();
|
||||
if (existing && existing.algorithm === algorithm) {
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// fall through to regeneration
|
||||
}
|
||||
|
||||
return this.store.generate(algorithm);
|
||||
}
|
||||
|
||||
private resolveAlgorithm(): DPoPAlgorithm {
|
||||
const authority = this.config.authority;
|
||||
return authority.dpopAlgorithms?.[0] ?? 'ES256';
|
||||
}
|
||||
|
||||
private resolveHashAlgorithm(algorithm: DPoPAlgorithm): string {
|
||||
switch (algorithm) {
|
||||
case 'ES384':
|
||||
return 'SHA-384';
|
||||
case 'ES256':
|
||||
default:
|
||||
return 'SHA-256';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHtu(value: string): string {
|
||||
try {
|
||||
const base =
|
||||
typeof window !== 'undefined' && window.location
|
||||
? window.location.origin
|
||||
: undefined;
|
||||
const url = base ? new URL(value, base) : new URL(value);
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function createRandomId(): string {
|
||||
const array = new Uint8Array(16);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array);
|
||||
}
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
import { AppConfigService } from '../../config/app-config.service';
|
||||
import { DPoPAlgorithm } from '../../config/app-config.model';
|
||||
import { sha256, base64UrlEncode, derToJoseSignature } from './jose-utilities';
|
||||
import { DpopKeyStore, LoadedDpopKeyPair } from './dpop-key-store';
|
||||
|
||||
export interface DpopProofOptions {
|
||||
readonly htm: string;
|
||||
readonly htu: string;
|
||||
readonly accessToken?: string;
|
||||
readonly nonce?: string | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DpopService {
|
||||
private keyPairPromise: Promise<LoadedDpopKeyPair> | null = null;
|
||||
private readonly nonceSignal = signal<string | null>(null);
|
||||
readonly nonce = computed(() => this.nonceSignal());
|
||||
|
||||
constructor(
|
||||
private readonly config: AppConfigService,
|
||||
private readonly store: DpopKeyStore
|
||||
) {}
|
||||
|
||||
async setNonce(nonce: string | null): Promise<void> {
|
||||
this.nonceSignal.set(nonce);
|
||||
}
|
||||
|
||||
async getThumbprint(): Promise<string | null> {
|
||||
const key = await this.getOrCreateKeyPair();
|
||||
return key.thumbprint ?? null;
|
||||
}
|
||||
|
||||
async rotateKey(): Promise<void> {
|
||||
const algorithm = this.resolveAlgorithm();
|
||||
this.keyPairPromise = this.store.generate(algorithm);
|
||||
}
|
||||
|
||||
async createProof(options: DpopProofOptions): Promise<string> {
|
||||
const keyPair = await this.getOrCreateKeyPair();
|
||||
|
||||
const header = {
|
||||
typ: 'dpop+jwt',
|
||||
alg: keyPair.algorithm,
|
||||
jwk: keyPair.publicJwk,
|
||||
};
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const payload: Record<string, unknown> = {
|
||||
htm: options.htm.toUpperCase(),
|
||||
htu: normalizeHtu(options.htu),
|
||||
iat: nowSeconds,
|
||||
jti: crypto.randomUUID ? crypto.randomUUID() : createRandomId(),
|
||||
};
|
||||
|
||||
const nonce = options.nonce ?? this.nonceSignal();
|
||||
if (nonce) {
|
||||
payload['nonce'] = nonce;
|
||||
}
|
||||
|
||||
if (options.accessToken) {
|
||||
const accessTokenHash = await sha256(
|
||||
new TextEncoder().encode(options.accessToken)
|
||||
);
|
||||
payload['ath'] = base64UrlEncode(accessTokenHash);
|
||||
}
|
||||
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = await crypto.subtle.sign(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
hash: this.resolveHashAlgorithm(keyPair.algorithm),
|
||||
},
|
||||
keyPair.privateKey,
|
||||
new TextEncoder().encode(signingInput)
|
||||
);
|
||||
|
||||
const joseSignature = base64UrlEncode(derToJoseSignature(signature));
|
||||
return `${signingInput}.${joseSignature}`;
|
||||
}
|
||||
|
||||
private async getOrCreateKeyPair(): Promise<LoadedDpopKeyPair> {
|
||||
if (!this.keyPairPromise) {
|
||||
this.keyPairPromise = this.loadKeyPair();
|
||||
}
|
||||
try {
|
||||
return await this.keyPairPromise;
|
||||
} catch (error) {
|
||||
// Reset the memoized promise so a subsequent call can retry.
|
||||
this.keyPairPromise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadKeyPair(): Promise<LoadedDpopKeyPair> {
|
||||
const algorithm = this.resolveAlgorithm();
|
||||
try {
|
||||
const existing = await this.store.load();
|
||||
if (existing && existing.algorithm === algorithm) {
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// fall through to regeneration
|
||||
}
|
||||
|
||||
return this.store.generate(algorithm);
|
||||
}
|
||||
|
||||
private resolveAlgorithm(): DPoPAlgorithm {
|
||||
const authority = this.config.authority;
|
||||
return authority.dpopAlgorithms?.[0] ?? 'ES256';
|
||||
}
|
||||
|
||||
private resolveHashAlgorithm(algorithm: DPoPAlgorithm): string {
|
||||
switch (algorithm) {
|
||||
case 'ES384':
|
||||
return 'SHA-384';
|
||||
case 'ES256':
|
||||
default:
|
||||
return 'SHA-256';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHtu(value: string): string {
|
||||
try {
|
||||
const base =
|
||||
typeof window !== 'undefined' && window.location
|
||||
? window.location.origin
|
||||
: undefined;
|
||||
const url = base ? new URL(value, base) : new URL(value);
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function createRandomId(): string {
|
||||
const array = new Uint8Array(16);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array);
|
||||
}
|
||||
|
||||
@@ -1,123 +1,123 @@
|
||||
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
export function base64UrlEncode(
|
||||
input: ArrayBuffer | Uint8Array | string
|
||||
): string {
|
||||
let bytes: Uint8Array;
|
||||
if (typeof input === 'string') {
|
||||
bytes = new TextEncoder().encode(input);
|
||||
} else if (input instanceof Uint8Array) {
|
||||
bytes = input;
|
||||
} else {
|
||||
bytes = new Uint8Array(input);
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function base64UrlDecode(value: string): Uint8Array {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalized.length % 4;
|
||||
const padded =
|
||||
padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
|
||||
const canonical = canonicalizeJwk(jwk);
|
||||
const digest = await sha256(new TextEncoder().encode(canonical));
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
function canonicalizeJwk(jwk: JsonWebKey): string {
|
||||
if (!jwk.kty) {
|
||||
throw new Error('JWK must include "kty"');
|
||||
}
|
||||
|
||||
if (jwk.kty === 'EC') {
|
||||
const { crv, kty, x, y } = jwk;
|
||||
if (!crv || !x || !y) {
|
||||
throw new Error('EC JWK must include "crv", "x", and "y".');
|
||||
}
|
||||
return JSON.stringify({ crv, kty, x, y });
|
||||
}
|
||||
|
||||
if (jwk.kty === 'OKP') {
|
||||
const { crv, kty, x } = jwk;
|
||||
if (!crv || !x) {
|
||||
throw new Error('OKP JWK must include "crv" and "x".');
|
||||
}
|
||||
return JSON.stringify({ crv, kty, x });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
|
||||
}
|
||||
|
||||
export function derToJoseSignature(der: ArrayBuffer): Uint8Array {
|
||||
const bytes = new Uint8Array(der);
|
||||
if (bytes[0] !== 0x30) {
|
||||
// Some implementations already return raw (r || s) signature bytes.
|
||||
if (bytes.length === 64) {
|
||||
return bytes;
|
||||
}
|
||||
throw new Error('Invalid DER signature: expected sequence.');
|
||||
}
|
||||
|
||||
let offset = 2; // skip SEQUENCE header and length (assume short form)
|
||||
if (bytes[1] & 0x80) {
|
||||
const lengthBytes = bytes[1] & 0x7f;
|
||||
offset = 2 + lengthBytes;
|
||||
}
|
||||
|
||||
if (bytes[offset] !== 0x02) {
|
||||
throw new Error('Invalid DER signature: expected INTEGER for r.');
|
||||
}
|
||||
const rLength = bytes[offset + 1];
|
||||
let r = bytes.slice(offset + 2, offset + 2 + rLength);
|
||||
offset = offset + 2 + rLength;
|
||||
|
||||
if (bytes[offset] !== 0x02) {
|
||||
throw new Error('Invalid DER signature: expected INTEGER for s.');
|
||||
}
|
||||
const sLength = bytes[offset + 1];
|
||||
let s = bytes.slice(offset + 2, offset + 2 + sLength);
|
||||
|
||||
r = trimLeadingZeros(r);
|
||||
s = trimLeadingZeros(s);
|
||||
|
||||
const targetLength = 32;
|
||||
const signature = new Uint8Array(targetLength * 2);
|
||||
signature.set(padStart(r, targetLength), 0);
|
||||
signature.set(padStart(s, targetLength), targetLength);
|
||||
return signature;
|
||||
}
|
||||
|
||||
function trimLeadingZeros(bytes: Uint8Array): Uint8Array {
|
||||
let start = 0;
|
||||
while (start < bytes.length - 1 && bytes[start] === 0x00) {
|
||||
start += 1;
|
||||
}
|
||||
return bytes.subarray(start);
|
||||
}
|
||||
|
||||
function padStart(bytes: Uint8Array, length: number): Uint8Array {
|
||||
if (bytes.length >= length) {
|
||||
return bytes;
|
||||
}
|
||||
const padded = new Uint8Array(length);
|
||||
padded.set(bytes, length - bytes.length);
|
||||
return padded;
|
||||
}
|
||||
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
export function base64UrlEncode(
|
||||
input: ArrayBuffer | Uint8Array | string
|
||||
): string {
|
||||
let bytes: Uint8Array;
|
||||
if (typeof input === 'string') {
|
||||
bytes = new TextEncoder().encode(input);
|
||||
} else if (input instanceof Uint8Array) {
|
||||
bytes = input;
|
||||
} else {
|
||||
bytes = new Uint8Array(input);
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function base64UrlDecode(value: string): Uint8Array {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalized.length % 4;
|
||||
const padded =
|
||||
padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
|
||||
const canonical = canonicalizeJwk(jwk);
|
||||
const digest = await sha256(new TextEncoder().encode(canonical));
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
function canonicalizeJwk(jwk: JsonWebKey): string {
|
||||
if (!jwk.kty) {
|
||||
throw new Error('JWK must include "kty"');
|
||||
}
|
||||
|
||||
if (jwk.kty === 'EC') {
|
||||
const { crv, kty, x, y } = jwk;
|
||||
if (!crv || !x || !y) {
|
||||
throw new Error('EC JWK must include "crv", "x", and "y".');
|
||||
}
|
||||
return JSON.stringify({ crv, kty, x, y });
|
||||
}
|
||||
|
||||
if (jwk.kty === 'OKP') {
|
||||
const { crv, kty, x } = jwk;
|
||||
if (!crv || !x) {
|
||||
throw new Error('OKP JWK must include "crv" and "x".');
|
||||
}
|
||||
return JSON.stringify({ crv, kty, x });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
|
||||
}
|
||||
|
||||
export function derToJoseSignature(der: ArrayBuffer): Uint8Array {
|
||||
const bytes = new Uint8Array(der);
|
||||
if (bytes[0] !== 0x30) {
|
||||
// Some implementations already return raw (r || s) signature bytes.
|
||||
if (bytes.length === 64) {
|
||||
return bytes;
|
||||
}
|
||||
throw new Error('Invalid DER signature: expected sequence.');
|
||||
}
|
||||
|
||||
let offset = 2; // skip SEQUENCE header and length (assume short form)
|
||||
if (bytes[1] & 0x80) {
|
||||
const lengthBytes = bytes[1] & 0x7f;
|
||||
offset = 2 + lengthBytes;
|
||||
}
|
||||
|
||||
if (bytes[offset] !== 0x02) {
|
||||
throw new Error('Invalid DER signature: expected INTEGER for r.');
|
||||
}
|
||||
const rLength = bytes[offset + 1];
|
||||
let r = bytes.slice(offset + 2, offset + 2 + rLength);
|
||||
offset = offset + 2 + rLength;
|
||||
|
||||
if (bytes[offset] !== 0x02) {
|
||||
throw new Error('Invalid DER signature: expected INTEGER for s.');
|
||||
}
|
||||
const sLength = bytes[offset + 1];
|
||||
let s = bytes.slice(offset + 2, offset + 2 + sLength);
|
||||
|
||||
r = trimLeadingZeros(r);
|
||||
s = trimLeadingZeros(s);
|
||||
|
||||
const targetLength = 32;
|
||||
const signature = new Uint8Array(targetLength * 2);
|
||||
signature.set(padStart(r, targetLength), 0);
|
||||
signature.set(padStart(s, targetLength), targetLength);
|
||||
return signature;
|
||||
}
|
||||
|
||||
function trimLeadingZeros(bytes: Uint8Array): Uint8Array {
|
||||
let start = 0;
|
||||
while (start < bytes.length - 1 && bytes[start] === 0x00) {
|
||||
start += 1;
|
||||
}
|
||||
return bytes.subarray(start);
|
||||
}
|
||||
|
||||
function padStart(bytes: Uint8Array, length: number): Uint8Array {
|
||||
if (bytes.length >= length) {
|
||||
return bytes;
|
||||
}
|
||||
const padded = new Uint8Array(length);
|
||||
padded.set(bytes, length - bytes.length);
|
||||
return padded;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
export {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
ScopeLabels,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
export {
|
||||
AuthUser,
|
||||
AuthService,
|
||||
AUTH_SERVICE,
|
||||
MockAuthService,
|
||||
} from './auth.service';
|
||||
|
||||
export {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
ScopeLabels,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
export {
|
||||
AuthUser,
|
||||
AuthService,
|
||||
AUTH_SERVICE,
|
||||
MockAuthService,
|
||||
} from './auth.service';
|
||||
|
||||
export {
|
||||
requireAuthGuard,
|
||||
requireScopesGuard,
|
||||
@@ -32,34 +32,34 @@ export {
|
||||
requirePolicyAuditGuard,
|
||||
requireAnalyticsViewerGuard,
|
||||
} from './auth.guard';
|
||||
|
||||
export {
|
||||
TenantActivationService,
|
||||
TenantScope,
|
||||
AuthDecision,
|
||||
DenyReason,
|
||||
AuthDecisionAudit,
|
||||
ScopeCheckResult,
|
||||
TenantContext,
|
||||
JwtClaims,
|
||||
} from './tenant-activation.service';
|
||||
|
||||
export {
|
||||
TenantHttpInterceptor,
|
||||
TENANT_HEADERS,
|
||||
} from './tenant-http.interceptor';
|
||||
|
||||
export {
|
||||
TenantPersistenceService,
|
||||
PersistenceAuditMetadata,
|
||||
TenantPersistenceCheck,
|
||||
TenantStoragePath,
|
||||
PersistenceAuditEvent,
|
||||
} from './tenant-persistence.service';
|
||||
|
||||
export {
|
||||
AbacService,
|
||||
AbacMode,
|
||||
AbacConfig,
|
||||
AbacAuthResult,
|
||||
} from './abac.service';
|
||||
|
||||
export {
|
||||
TenantActivationService,
|
||||
TenantScope,
|
||||
AuthDecision,
|
||||
DenyReason,
|
||||
AuthDecisionAudit,
|
||||
ScopeCheckResult,
|
||||
TenantContext,
|
||||
JwtClaims,
|
||||
} from './tenant-activation.service';
|
||||
|
||||
export {
|
||||
TenantHttpInterceptor,
|
||||
TENANT_HEADERS,
|
||||
} from './tenant-http.interceptor';
|
||||
|
||||
export {
|
||||
TenantPersistenceService,
|
||||
PersistenceAuditMetadata,
|
||||
TenantPersistenceCheck,
|
||||
TenantStoragePath,
|
||||
PersistenceAuditEvent,
|
||||
} from './tenant-persistence.service';
|
||||
|
||||
export {
|
||||
AbacService,
|
||||
AbacMode,
|
||||
AbacConfig,
|
||||
AbacAuthResult,
|
||||
} from './abac.service';
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { base64UrlEncode, sha256 } from './dpop/jose-utilities';
|
||||
|
||||
export interface PkcePair {
|
||||
readonly verifier: string;
|
||||
readonly challenge: string;
|
||||
readonly method: 'S256';
|
||||
}
|
||||
|
||||
const VERIFIER_BYTE_LENGTH = 32;
|
||||
|
||||
export async function createPkcePair(): Promise<PkcePair> {
|
||||
const verifierBytes = new Uint8Array(VERIFIER_BYTE_LENGTH);
|
||||
crypto.getRandomValues(verifierBytes);
|
||||
|
||||
const verifier = base64UrlEncode(verifierBytes);
|
||||
const challengeBytes = await sha256(new TextEncoder().encode(verifier));
|
||||
const challenge = base64UrlEncode(challengeBytes);
|
||||
|
||||
return {
|
||||
verifier,
|
||||
challenge,
|
||||
method: 'S256',
|
||||
};
|
||||
}
|
||||
import { base64UrlEncode, sha256 } from './dpop/jose-utilities';
|
||||
|
||||
export interface PkcePair {
|
||||
readonly verifier: string;
|
||||
readonly challenge: string;
|
||||
readonly method: 'S256';
|
||||
}
|
||||
|
||||
const VERIFIER_BYTE_LENGTH = 32;
|
||||
|
||||
export async function createPkcePair(): Promise<PkcePair> {
|
||||
const verifierBytes = new Uint8Array(VERIFIER_BYTE_LENGTH);
|
||||
crypto.getRandomValues(verifierBytes);
|
||||
|
||||
const verifier = base64UrlEncode(verifierBytes);
|
||||
const challengeBytes = await sha256(new TextEncoder().encode(verifier));
|
||||
const challenge = base64UrlEncode(challengeBytes);
|
||||
|
||||
return {
|
||||
verifier,
|
||||
challenge,
|
||||
method: 'S256',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
/**
|
||||
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
|
||||
*
|
||||
* This is a stub implementation to unblock Graph Explorer development.
|
||||
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
|
||||
*
|
||||
* @see docs/modules/platform/architecture-overview.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* All available StellaOps OAuth2 scopes.
|
||||
*/
|
||||
export const StellaOpsScopes = {
|
||||
// Graph scopes
|
||||
GRAPH_READ: 'graph:read',
|
||||
GRAPH_WRITE: 'graph:write',
|
||||
GRAPH_ADMIN: 'graph:admin',
|
||||
GRAPH_EXPORT: 'graph:export',
|
||||
GRAPH_SIMULATE: 'graph:simulate',
|
||||
|
||||
// SBOM scopes
|
||||
SBOM_READ: 'sbom:read',
|
||||
SBOM_WRITE: 'sbom:write',
|
||||
SBOM_ATTEST: 'sbom:attest',
|
||||
|
||||
// Scanner scopes
|
||||
SCANNER_READ: 'scanner:read',
|
||||
SCANNER_WRITE: 'scanner:write',
|
||||
SCANNER_SCAN: 'scanner:scan',
|
||||
SCANNER_EXPORT: 'scanner:export',
|
||||
|
||||
// Policy scopes (full Policy Studio workflow - UI-POLICY-20-003)
|
||||
POLICY_READ: 'policy:read',
|
||||
POLICY_WRITE: 'policy:write',
|
||||
POLICY_EVALUATE: 'policy:evaluate',
|
||||
POLICY_SIMULATE: 'policy:simulate',
|
||||
// Policy Studio authoring & review workflow
|
||||
POLICY_AUTHOR: 'policy:author',
|
||||
POLICY_EDIT: 'policy:edit',
|
||||
POLICY_REVIEW: 'policy:review',
|
||||
POLICY_SUBMIT: 'policy:submit',
|
||||
POLICY_APPROVE: 'policy:approve',
|
||||
// Policy operations & execution
|
||||
POLICY_OPERATE: 'policy:operate',
|
||||
POLICY_ACTIVATE: 'policy:activate',
|
||||
POLICY_RUN: 'policy:run',
|
||||
POLICY_PUBLISH: 'policy:publish', // Requires interactive auth
|
||||
POLICY_PROMOTE: 'policy:promote', // Requires interactive auth
|
||||
POLICY_AUDIT: 'policy:audit',
|
||||
|
||||
// Exception scopes
|
||||
EXCEPTION_READ: 'exception:read',
|
||||
EXCEPTION_WRITE: 'exception:write',
|
||||
EXCEPTION_APPROVE: 'exception:approve',
|
||||
|
||||
// Advisory scopes
|
||||
ADVISORY_READ: 'advisory:read',
|
||||
|
||||
// VEX scopes
|
||||
VEX_READ: 'vex:read',
|
||||
VEX_EXPORT: 'vex:export',
|
||||
|
||||
/**
|
||||
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
|
||||
*
|
||||
* This is a stub implementation to unblock Graph Explorer development.
|
||||
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
|
||||
*
|
||||
* @see docs/modules/platform/architecture-overview.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* All available StellaOps OAuth2 scopes.
|
||||
*/
|
||||
export const StellaOpsScopes = {
|
||||
// Graph scopes
|
||||
GRAPH_READ: 'graph:read',
|
||||
GRAPH_WRITE: 'graph:write',
|
||||
GRAPH_ADMIN: 'graph:admin',
|
||||
GRAPH_EXPORT: 'graph:export',
|
||||
GRAPH_SIMULATE: 'graph:simulate',
|
||||
|
||||
// SBOM scopes
|
||||
SBOM_READ: 'sbom:read',
|
||||
SBOM_WRITE: 'sbom:write',
|
||||
SBOM_ATTEST: 'sbom:attest',
|
||||
|
||||
// Scanner scopes
|
||||
SCANNER_READ: 'scanner:read',
|
||||
SCANNER_WRITE: 'scanner:write',
|
||||
SCANNER_SCAN: 'scanner:scan',
|
||||
SCANNER_EXPORT: 'scanner:export',
|
||||
|
||||
// Policy scopes (full Policy Studio workflow - UI-POLICY-20-003)
|
||||
POLICY_READ: 'policy:read',
|
||||
POLICY_WRITE: 'policy:write',
|
||||
POLICY_EVALUATE: 'policy:evaluate',
|
||||
POLICY_SIMULATE: 'policy:simulate',
|
||||
// Policy Studio authoring & review workflow
|
||||
POLICY_AUTHOR: 'policy:author',
|
||||
POLICY_EDIT: 'policy:edit',
|
||||
POLICY_REVIEW: 'policy:review',
|
||||
POLICY_SUBMIT: 'policy:submit',
|
||||
POLICY_APPROVE: 'policy:approve',
|
||||
// Policy operations & execution
|
||||
POLICY_OPERATE: 'policy:operate',
|
||||
POLICY_ACTIVATE: 'policy:activate',
|
||||
POLICY_RUN: 'policy:run',
|
||||
POLICY_PUBLISH: 'policy:publish', // Requires interactive auth
|
||||
POLICY_PROMOTE: 'policy:promote', // Requires interactive auth
|
||||
POLICY_AUDIT: 'policy:audit',
|
||||
|
||||
// Exception scopes
|
||||
EXCEPTION_READ: 'exception:read',
|
||||
EXCEPTION_WRITE: 'exception:write',
|
||||
EXCEPTION_APPROVE: 'exception:approve',
|
||||
|
||||
// Advisory scopes
|
||||
ADVISORY_READ: 'advisory:read',
|
||||
|
||||
// VEX scopes
|
||||
VEX_READ: 'vex:read',
|
||||
VEX_EXPORT: 'vex:export',
|
||||
|
||||
// Release scopes
|
||||
RELEASE_READ: 'release:read',
|
||||
RELEASE_WRITE: 'release:write',
|
||||
@@ -72,215 +72,215 @@ export const StellaOpsScopes = {
|
||||
// AOC scopes
|
||||
AOC_READ: 'aoc:read',
|
||||
AOC_VERIFY: 'aoc:verify',
|
||||
|
||||
// Orchestrator scopes (UI-ORCH-32-001)
|
||||
ORCH_READ: 'orch:read',
|
||||
ORCH_OPERATE: 'orch:operate',
|
||||
ORCH_QUOTA: 'orch:quota',
|
||||
ORCH_BACKFILL: 'orch:backfill',
|
||||
|
||||
// UI scopes
|
||||
UI_READ: 'ui.read',
|
||||
UI_ADMIN: 'ui.admin',
|
||||
|
||||
// Admin scopes
|
||||
ADMIN: 'admin',
|
||||
TENANT_ADMIN: 'tenant:admin',
|
||||
|
||||
// Authority admin scopes
|
||||
AUTHORITY_TENANTS_READ: 'authority:tenants.read',
|
||||
AUTHORITY_TENANTS_WRITE: 'authority:tenants.write',
|
||||
AUTHORITY_USERS_READ: 'authority:users.read',
|
||||
AUTHORITY_USERS_WRITE: 'authority:users.write',
|
||||
AUTHORITY_ROLES_READ: 'authority:roles.read',
|
||||
AUTHORITY_ROLES_WRITE: 'authority:roles.write',
|
||||
AUTHORITY_CLIENTS_READ: 'authority:clients.read',
|
||||
AUTHORITY_CLIENTS_WRITE: 'authority:clients.write',
|
||||
AUTHORITY_TOKENS_READ: 'authority:tokens.read',
|
||||
AUTHORITY_TOKENS_REVOKE: 'authority:tokens.revoke',
|
||||
AUTHORITY_BRANDING_READ: 'authority:branding.read',
|
||||
AUTHORITY_BRANDING_WRITE: 'authority:branding.write',
|
||||
AUTHORITY_AUDIT_READ: 'authority:audit.read',
|
||||
|
||||
// Scheduler scopes
|
||||
SCHEDULER_READ: 'scheduler:read',
|
||||
SCHEDULER_OPERATE: 'scheduler:operate',
|
||||
SCHEDULER_ADMIN: 'scheduler:admin',
|
||||
|
||||
// Attestor scopes
|
||||
ATTEST_CREATE: 'attest:create',
|
||||
ATTEST_ADMIN: 'attest:admin',
|
||||
|
||||
// Signer scopes
|
||||
SIGNER_READ: 'signer:read',
|
||||
SIGNER_SIGN: 'signer:sign',
|
||||
SIGNER_ROTATE: 'signer:rotate',
|
||||
SIGNER_ADMIN: 'signer:admin',
|
||||
|
||||
// Zastava scopes
|
||||
ZASTAVA_READ: 'zastava:read',
|
||||
ZASTAVA_TRIGGER: 'zastava:trigger',
|
||||
ZASTAVA_ADMIN: 'zastava:admin',
|
||||
|
||||
// Exceptions scopes
|
||||
EXCEPTIONS_READ: 'exceptions:read',
|
||||
EXCEPTIONS_WRITE: 'exceptions:write',
|
||||
|
||||
// Findings scope
|
||||
FINDINGS_READ: 'findings:read',
|
||||
} as const;
|
||||
|
||||
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
|
||||
|
||||
/**
|
||||
* Scope groupings for common use cases.
|
||||
*/
|
||||
export const ScopeGroups = {
|
||||
GRAPH_VIEWER: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
] as const,
|
||||
|
||||
GRAPH_EDITOR: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.SBOM_WRITE,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
GRAPH_ADMIN: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_ADMIN,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
] as const,
|
||||
|
||||
RELEASE_MANAGER: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.RELEASE_WRITE,
|
||||
StellaOpsScopes.RELEASE_PUBLISH,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
SECURITY_ADMIN: [
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
StellaOpsScopes.EXCEPTION_APPROVE,
|
||||
StellaOpsScopes.RELEASE_BYPASS,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_WRITE,
|
||||
] as const,
|
||||
|
||||
// Orchestrator scope groups (UI-ORCH-32-001)
|
||||
ORCH_VIEWER: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
ORCH_OPERATOR: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
ORCH_ADMIN: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.ORCH_QUOTA,
|
||||
StellaOpsScopes.ORCH_BACKFILL,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
// Policy Studio scope groups (UI-POLICY-20-003)
|
||||
POLICY_VIEWER: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_AUTHOR: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_AUTHOR,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_REVIEWER: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_APPROVER: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_APPROVE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_OPERATOR: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_OPERATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_ADMIN: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_AUTHOR,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_APPROVE,
|
||||
StellaOpsScopes.POLICY_OPERATE,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Human-readable labels for scopes.
|
||||
*/
|
||||
export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||
'graph:read': 'View Graph',
|
||||
'graph:write': 'Edit Graph',
|
||||
'graph:admin': 'Administer Graph',
|
||||
'graph:export': 'Export Graph Data',
|
||||
'graph:simulate': 'Run Graph Simulations',
|
||||
'sbom:read': 'View SBOMs',
|
||||
'sbom:write': 'Create/Edit SBOMs',
|
||||
'sbom:attest': 'Attest SBOMs',
|
||||
'scanner:read': 'View Scan Results',
|
||||
'scanner:write': 'Configure Scanner',
|
||||
'scanner:scan': 'Trigger Scans',
|
||||
'scanner:export': 'Export Scan Results',
|
||||
'policy:read': 'View Policies',
|
||||
'policy:write': 'Edit Policies',
|
||||
'policy:evaluate': 'Evaluate Policies',
|
||||
'policy:simulate': 'Simulate Policy Changes',
|
||||
// Policy Studio workflow scopes (UI-POLICY-20-003)
|
||||
'policy:author': 'Author Policy Drafts',
|
||||
'policy:edit': 'Edit Policy Configuration',
|
||||
'policy:review': 'Review Policy Drafts',
|
||||
'policy:submit': 'Submit Policies for Review',
|
||||
'policy:approve': 'Approve/Reject Policies',
|
||||
'policy:operate': 'Operate Policy Promotions',
|
||||
'policy:activate': 'Activate Policies',
|
||||
'policy:run': 'Trigger Policy Runs',
|
||||
'policy:publish': 'Publish Policy Versions',
|
||||
'policy:promote': 'Promote Between Environments',
|
||||
'policy:audit': 'Audit Policy Activity',
|
||||
'exception:read': 'View Exceptions',
|
||||
'exception:write': 'Create Exceptions',
|
||||
'exception:approve': 'Approve Exceptions',
|
||||
'advisory:read': 'View Advisories',
|
||||
'vex:read': 'View VEX Evidence',
|
||||
'vex:export': 'Export VEX Evidence',
|
||||
|
||||
// Orchestrator scopes (UI-ORCH-32-001)
|
||||
ORCH_READ: 'orch:read',
|
||||
ORCH_OPERATE: 'orch:operate',
|
||||
ORCH_QUOTA: 'orch:quota',
|
||||
ORCH_BACKFILL: 'orch:backfill',
|
||||
|
||||
// UI scopes
|
||||
UI_READ: 'ui.read',
|
||||
UI_ADMIN: 'ui.admin',
|
||||
|
||||
// Admin scopes
|
||||
ADMIN: 'admin',
|
||||
TENANT_ADMIN: 'tenant:admin',
|
||||
|
||||
// Authority admin scopes
|
||||
AUTHORITY_TENANTS_READ: 'authority:tenants.read',
|
||||
AUTHORITY_TENANTS_WRITE: 'authority:tenants.write',
|
||||
AUTHORITY_USERS_READ: 'authority:users.read',
|
||||
AUTHORITY_USERS_WRITE: 'authority:users.write',
|
||||
AUTHORITY_ROLES_READ: 'authority:roles.read',
|
||||
AUTHORITY_ROLES_WRITE: 'authority:roles.write',
|
||||
AUTHORITY_CLIENTS_READ: 'authority:clients.read',
|
||||
AUTHORITY_CLIENTS_WRITE: 'authority:clients.write',
|
||||
AUTHORITY_TOKENS_READ: 'authority:tokens.read',
|
||||
AUTHORITY_TOKENS_REVOKE: 'authority:tokens.revoke',
|
||||
AUTHORITY_BRANDING_READ: 'authority:branding.read',
|
||||
AUTHORITY_BRANDING_WRITE: 'authority:branding.write',
|
||||
AUTHORITY_AUDIT_READ: 'authority:audit.read',
|
||||
|
||||
// Scheduler scopes
|
||||
SCHEDULER_READ: 'scheduler:read',
|
||||
SCHEDULER_OPERATE: 'scheduler:operate',
|
||||
SCHEDULER_ADMIN: 'scheduler:admin',
|
||||
|
||||
// Attestor scopes
|
||||
ATTEST_CREATE: 'attest:create',
|
||||
ATTEST_ADMIN: 'attest:admin',
|
||||
|
||||
// Signer scopes
|
||||
SIGNER_READ: 'signer:read',
|
||||
SIGNER_SIGN: 'signer:sign',
|
||||
SIGNER_ROTATE: 'signer:rotate',
|
||||
SIGNER_ADMIN: 'signer:admin',
|
||||
|
||||
// Zastava scopes
|
||||
ZASTAVA_READ: 'zastava:read',
|
||||
ZASTAVA_TRIGGER: 'zastava:trigger',
|
||||
ZASTAVA_ADMIN: 'zastava:admin',
|
||||
|
||||
// Exceptions scopes
|
||||
EXCEPTIONS_READ: 'exceptions:read',
|
||||
EXCEPTIONS_WRITE: 'exceptions:write',
|
||||
|
||||
// Findings scope
|
||||
FINDINGS_READ: 'findings:read',
|
||||
} as const;
|
||||
|
||||
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
|
||||
|
||||
/**
|
||||
* Scope groupings for common use cases.
|
||||
*/
|
||||
export const ScopeGroups = {
|
||||
GRAPH_VIEWER: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
] as const,
|
||||
|
||||
GRAPH_EDITOR: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.SBOM_WRITE,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
GRAPH_ADMIN: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_ADMIN,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
] as const,
|
||||
|
||||
RELEASE_MANAGER: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.RELEASE_WRITE,
|
||||
StellaOpsScopes.RELEASE_PUBLISH,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
SECURITY_ADMIN: [
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
StellaOpsScopes.EXCEPTION_APPROVE,
|
||||
StellaOpsScopes.RELEASE_BYPASS,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_WRITE,
|
||||
] as const,
|
||||
|
||||
// Orchestrator scope groups (UI-ORCH-32-001)
|
||||
ORCH_VIEWER: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
ORCH_OPERATOR: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
ORCH_ADMIN: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.ORCH_QUOTA,
|
||||
StellaOpsScopes.ORCH_BACKFILL,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
// Policy Studio scope groups (UI-POLICY-20-003)
|
||||
POLICY_VIEWER: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_AUTHOR: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_AUTHOR,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_REVIEWER: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_APPROVER: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_APPROVE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_OPERATOR: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_OPERATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_ADMIN: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_AUTHOR,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_APPROVE,
|
||||
StellaOpsScopes.POLICY_OPERATE,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Human-readable labels for scopes.
|
||||
*/
|
||||
export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||
'graph:read': 'View Graph',
|
||||
'graph:write': 'Edit Graph',
|
||||
'graph:admin': 'Administer Graph',
|
||||
'graph:export': 'Export Graph Data',
|
||||
'graph:simulate': 'Run Graph Simulations',
|
||||
'sbom:read': 'View SBOMs',
|
||||
'sbom:write': 'Create/Edit SBOMs',
|
||||
'sbom:attest': 'Attest SBOMs',
|
||||
'scanner:read': 'View Scan Results',
|
||||
'scanner:write': 'Configure Scanner',
|
||||
'scanner:scan': 'Trigger Scans',
|
||||
'scanner:export': 'Export Scan Results',
|
||||
'policy:read': 'View Policies',
|
||||
'policy:write': 'Edit Policies',
|
||||
'policy:evaluate': 'Evaluate Policies',
|
||||
'policy:simulate': 'Simulate Policy Changes',
|
||||
// Policy Studio workflow scopes (UI-POLICY-20-003)
|
||||
'policy:author': 'Author Policy Drafts',
|
||||
'policy:edit': 'Edit Policy Configuration',
|
||||
'policy:review': 'Review Policy Drafts',
|
||||
'policy:submit': 'Submit Policies for Review',
|
||||
'policy:approve': 'Approve/Reject Policies',
|
||||
'policy:operate': 'Operate Policy Promotions',
|
||||
'policy:activate': 'Activate Policies',
|
||||
'policy:run': 'Trigger Policy Runs',
|
||||
'policy:publish': 'Publish Policy Versions',
|
||||
'policy:promote': 'Promote Between Environments',
|
||||
'policy:audit': 'Audit Policy Activity',
|
||||
'exception:read': 'View Exceptions',
|
||||
'exception:write': 'Create Exceptions',
|
||||
'exception:approve': 'Approve Exceptions',
|
||||
'advisory:read': 'View Advisories',
|
||||
'vex:read': 'View VEX Evidence',
|
||||
'vex:export': 'Export VEX Evidence',
|
||||
'release:read': 'View Releases',
|
||||
'release:write': 'Create Releases',
|
||||
'release:publish': 'Publish Releases',
|
||||
@@ -288,82 +288,82 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||
'analytics.read': 'View Analytics',
|
||||
'aoc:read': 'View AOC Status',
|
||||
'aoc:verify': 'Trigger AOC Verification',
|
||||
// Orchestrator scope labels (UI-ORCH-32-001)
|
||||
'orch:read': 'View Orchestrator Jobs',
|
||||
'orch:operate': 'Operate Orchestrator',
|
||||
'orch:quota': 'Manage Orchestrator Quotas',
|
||||
'orch:backfill': 'Initiate Backfill Runs',
|
||||
// UI scope labels
|
||||
'ui.read': 'Console Access',
|
||||
'ui.admin': 'Console Admin Access',
|
||||
// Admin scope labels
|
||||
'admin': 'System Administrator',
|
||||
'tenant:admin': 'Tenant Administrator',
|
||||
// Authority admin scope labels
|
||||
'authority:tenants.read': 'View Tenants',
|
||||
'authority:tenants.write': 'Manage Tenants',
|
||||
'authority:users.read': 'View Users',
|
||||
'authority:users.write': 'Manage Users',
|
||||
'authority:roles.read': 'View Roles',
|
||||
'authority:roles.write': 'Manage Roles',
|
||||
'authority:clients.read': 'View Clients',
|
||||
'authority:clients.write': 'Manage Clients',
|
||||
'authority:tokens.read': 'View Tokens',
|
||||
'authority:tokens.revoke': 'Revoke Tokens',
|
||||
'authority:branding.read': 'View Branding',
|
||||
'authority:branding.write': 'Manage Branding',
|
||||
'authority:audit.read': 'View Audit Log',
|
||||
// Scheduler scope labels
|
||||
'scheduler:read': 'View Scheduler Jobs',
|
||||
'scheduler:operate': 'Operate Scheduler',
|
||||
'scheduler:admin': 'Administer Scheduler',
|
||||
// Attestor scope labels
|
||||
'attest:create': 'Create Attestations',
|
||||
'attest:admin': 'Administer Attestor',
|
||||
// Signer scope labels
|
||||
'signer:read': 'View Signer Configuration',
|
||||
'signer:sign': 'Create Signatures',
|
||||
'signer:rotate': 'Rotate Signing Keys',
|
||||
'signer:admin': 'Administer Signer',
|
||||
// Zastava scope labels
|
||||
'zastava:read': 'View Zastava State',
|
||||
'zastava:trigger': 'Trigger Zastava Processing',
|
||||
'zastava:admin': 'Administer Zastava',
|
||||
// Exception scope labels
|
||||
'exceptions:read': 'View Exceptions',
|
||||
'exceptions:write': 'Create Exceptions',
|
||||
// Findings scope label
|
||||
'findings:read': 'View Policy Findings',
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes a required scope.
|
||||
*/
|
||||
export function hasScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScope: StellaOpsScope
|
||||
): boolean {
|
||||
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes all required scopes.
|
||||
*/
|
||||
export function hasAllScopes(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.every((scope) => userScopes.includes(scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes any of the required scopes.
|
||||
*/
|
||||
export function hasAnyScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.some((scope) => userScopes.includes(scope));
|
||||
}
|
||||
// Orchestrator scope labels (UI-ORCH-32-001)
|
||||
'orch:read': 'View Orchestrator Jobs',
|
||||
'orch:operate': 'Operate Orchestrator',
|
||||
'orch:quota': 'Manage Orchestrator Quotas',
|
||||
'orch:backfill': 'Initiate Backfill Runs',
|
||||
// UI scope labels
|
||||
'ui.read': 'Console Access',
|
||||
'ui.admin': 'Console Admin Access',
|
||||
// Admin scope labels
|
||||
'admin': 'System Administrator',
|
||||
'tenant:admin': 'Tenant Administrator',
|
||||
// Authority admin scope labels
|
||||
'authority:tenants.read': 'View Tenants',
|
||||
'authority:tenants.write': 'Manage Tenants',
|
||||
'authority:users.read': 'View Users',
|
||||
'authority:users.write': 'Manage Users',
|
||||
'authority:roles.read': 'View Roles',
|
||||
'authority:roles.write': 'Manage Roles',
|
||||
'authority:clients.read': 'View Clients',
|
||||
'authority:clients.write': 'Manage Clients',
|
||||
'authority:tokens.read': 'View Tokens',
|
||||
'authority:tokens.revoke': 'Revoke Tokens',
|
||||
'authority:branding.read': 'View Branding',
|
||||
'authority:branding.write': 'Manage Branding',
|
||||
'authority:audit.read': 'View Audit Log',
|
||||
// Scheduler scope labels
|
||||
'scheduler:read': 'View Scheduler Jobs',
|
||||
'scheduler:operate': 'Operate Scheduler',
|
||||
'scheduler:admin': 'Administer Scheduler',
|
||||
// Attestor scope labels
|
||||
'attest:create': 'Create Attestations',
|
||||
'attest:admin': 'Administer Attestor',
|
||||
// Signer scope labels
|
||||
'signer:read': 'View Signer Configuration',
|
||||
'signer:sign': 'Create Signatures',
|
||||
'signer:rotate': 'Rotate Signing Keys',
|
||||
'signer:admin': 'Administer Signer',
|
||||
// Zastava scope labels
|
||||
'zastava:read': 'View Zastava State',
|
||||
'zastava:trigger': 'Trigger Zastava Processing',
|
||||
'zastava:admin': 'Administer Zastava',
|
||||
// Exception scope labels
|
||||
'exceptions:read': 'View Exceptions',
|
||||
'exceptions:write': 'Create Exceptions',
|
||||
// Findings scope label
|
||||
'findings:read': 'View Policy Findings',
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes a required scope.
|
||||
*/
|
||||
export function hasScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScope: StellaOpsScope
|
||||
): boolean {
|
||||
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes all required scopes.
|
||||
*/
|
||||
export function hasAllScopes(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.every((scope) => userScopes.includes(scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes any of the required scopes.
|
||||
*/
|
||||
export function hasAnyScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.some((scope) => userScopes.includes(scope));
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export type DPoPAlgorithm = 'ES256' | 'ES384' | 'EdDSA';
|
||||
|
||||
export interface AuthorityConfig {
|
||||
readonly issuer: string;
|
||||
readonly clientId: string;
|
||||
readonly authorizeEndpoint: string;
|
||||
readonly tokenEndpoint: string;
|
||||
readonly logoutEndpoint?: string;
|
||||
readonly redirectUri: string;
|
||||
readonly postLogoutRedirectUri?: string;
|
||||
readonly scope: string;
|
||||
readonly audience: string;
|
||||
/**
|
||||
* Preferred algorithms for DPoP proofs, in order of preference.
|
||||
* Defaults to ES256 if omitted.
|
||||
*/
|
||||
readonly dpopAlgorithms?: readonly DPoPAlgorithm[];
|
||||
/**
|
||||
* Seconds of leeway before access token expiry that should trigger a proactive refresh.
|
||||
* Defaults to 60.
|
||||
*/
|
||||
readonly refreshLeewaySeconds?: number;
|
||||
}
|
||||
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export type DPoPAlgorithm = 'ES256' | 'ES384' | 'EdDSA';
|
||||
|
||||
export interface AuthorityConfig {
|
||||
readonly issuer: string;
|
||||
readonly clientId: string;
|
||||
readonly authorizeEndpoint: string;
|
||||
readonly tokenEndpoint: string;
|
||||
readonly logoutEndpoint?: string;
|
||||
readonly redirectUri: string;
|
||||
readonly postLogoutRedirectUri?: string;
|
||||
readonly scope: string;
|
||||
readonly audience: string;
|
||||
/**
|
||||
* Preferred algorithms for DPoP proofs, in order of preference.
|
||||
* Defaults to ES256 if omitted.
|
||||
*/
|
||||
readonly dpopAlgorithms?: readonly DPoPAlgorithm[];
|
||||
/**
|
||||
* Seconds of leeway before access token expiry that should trigger a proactive refresh.
|
||||
* Defaults to 60.
|
||||
*/
|
||||
readonly refreshLeewaySeconds?: number;
|
||||
}
|
||||
|
||||
export interface ApiBaseUrlConfig {
|
||||
/**
|
||||
* Optional API gateway base URL for cross-cutting endpoints.
|
||||
@@ -38,11 +38,11 @@ export interface ApiBaseUrlConfig {
|
||||
readonly concelier: string;
|
||||
readonly excitor?: string;
|
||||
readonly attestor: string;
|
||||
readonly authority: string;
|
||||
readonly notify?: string;
|
||||
readonly scheduler?: string;
|
||||
}
|
||||
|
||||
readonly authority: string;
|
||||
readonly notify?: string;
|
||||
readonly scheduler?: string;
|
||||
}
|
||||
|
||||
export interface TelemetryConfig {
|
||||
readonly otlpEndpoint?: string;
|
||||
readonly sampleRate?: number;
|
||||
|
||||
@@ -6,15 +6,15 @@ import {
|
||||
computed,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
APP_CONFIG,
|
||||
AppConfig,
|
||||
AuthorityConfig,
|
||||
DPoPAlgorithm,
|
||||
} from './app-config.model';
|
||||
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
APP_CONFIG,
|
||||
AppConfig,
|
||||
AuthorityConfig,
|
||||
DPoPAlgorithm,
|
||||
} from './app-config.model';
|
||||
|
||||
const DEFAULT_CONFIG_URL = '/config.json';
|
||||
const DEFAULT_DPOP_ALG: DPoPAlgorithm = 'ES256';
|
||||
const DEFAULT_REFRESH_LEEWAY_SECONDS = 60;
|
||||
@@ -41,53 +41,53 @@ export class AppConfigService {
|
||||
// that themselves depend on AppConfigService.
|
||||
this.http = new HttpClient(httpBackend);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads application configuration either from the injected static value or via HTTP fetch.
|
||||
* Must be called during application bootstrap (see APP_INITIALIZER wiring).
|
||||
*/
|
||||
async load(configUrl: string = DEFAULT_CONFIG_URL): Promise<void> {
|
||||
if (this.configSignal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.staticConfig ?? (await this.fetchConfig(configUrl));
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows tests to short-circuit configuration loading.
|
||||
*/
|
||||
setConfigForTesting(config: AppConfig): void {
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
}
|
||||
|
||||
get config(): AppConfig {
|
||||
const current = this.configSignal();
|
||||
if (!current) {
|
||||
throw new Error('App configuration has not been loaded yet.');
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
get authority(): AuthorityConfig {
|
||||
const authority = this.authoritySignal();
|
||||
if (!authority) {
|
||||
throw new Error('Authority configuration has not been loaded yet.');
|
||||
}
|
||||
return authority;
|
||||
}
|
||||
|
||||
private async fetchConfig(configUrl: string): Promise<AppConfig> {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<AppConfig>(configUrl, {
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
withCredentials: false,
|
||||
})
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Loads application configuration either from the injected static value or via HTTP fetch.
|
||||
* Must be called during application bootstrap (see APP_INITIALIZER wiring).
|
||||
*/
|
||||
async load(configUrl: string = DEFAULT_CONFIG_URL): Promise<void> {
|
||||
if (this.configSignal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.staticConfig ?? (await this.fetchConfig(configUrl));
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows tests to short-circuit configuration loading.
|
||||
*/
|
||||
setConfigForTesting(config: AppConfig): void {
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
}
|
||||
|
||||
get config(): AppConfig {
|
||||
const current = this.configSignal();
|
||||
if (!current) {
|
||||
throw new Error('App configuration has not been loaded yet.');
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
get authority(): AuthorityConfig {
|
||||
const authority = this.authoritySignal();
|
||||
if (!authority) {
|
||||
throw new Error('Authority configuration has not been loaded yet.');
|
||||
}
|
||||
return authority;
|
||||
}
|
||||
|
||||
private async fetchConfig(configUrl: string): Promise<AppConfig> {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<AppConfig>(configUrl, {
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
withCredentials: false,
|
||||
})
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
private normalizeConfig(config: AppConfig): AppConfig {
|
||||
const authority = {
|
||||
...config.authority,
|
||||
|
||||
@@ -1,139 +1,139 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import {
|
||||
AUTHORITY_CONSOLE_API,
|
||||
AuthorityConsoleApi,
|
||||
TenantCatalogResponseDto,
|
||||
} from '../api/authority-console.client';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { ConsoleSessionService } from './console-session.service';
|
||||
import { ConsoleSessionStore } from './console-session.store';
|
||||
|
||||
class MockConsoleApi implements AuthorityConsoleApi {
|
||||
private createTenantResponse(): TenantCatalogResponseDto {
|
||||
return {
|
||||
tenants: [
|
||||
{
|
||||
id: 'tenant-default',
|
||||
displayName: 'Tenant Default',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.console'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
listTenants() {
|
||||
return of(this.createTenantResponse());
|
||||
}
|
||||
|
||||
getProfile() {
|
||||
return of({
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'Console User',
|
||||
tenant: 'tenant-default',
|
||||
sessionId: 'session-1',
|
||||
roles: ['role.console'],
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: '2025-10-31T12:00:00Z',
|
||||
authenticationTime: '2025-10-31T12:00:00Z',
|
||||
expiresAt: '2025-10-31T12:10:00Z',
|
||||
freshAuth: true,
|
||||
});
|
||||
}
|
||||
|
||||
introspectToken() {
|
||||
return of({
|
||||
active: true,
|
||||
tenant: 'tenant-default',
|
||||
subject: 'user-1',
|
||||
clientId: 'console-web',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
issuedAt: '2025-10-31T12:00:00Z',
|
||||
authenticationTime: '2025-10-31T12:00:00Z',
|
||||
expiresAt: '2025-10-31T12:10:00Z',
|
||||
freshAuth: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class MockAuthSessionStore {
|
||||
private tenantIdValue: string | null = 'tenant-default';
|
||||
private readonly sessionValue = {
|
||||
tenantId: 'tenant-default',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationTimeEpochMs: Date.parse('2025-10-31T12:00:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAtEpochMs: Date.parse('2025-10-31T12:05:00Z'),
|
||||
};
|
||||
|
||||
session = () => this.sessionValue as any;
|
||||
|
||||
getActiveTenantId(): string | null {
|
||||
return this.tenantIdValue;
|
||||
}
|
||||
|
||||
setTenantId(tenantId: string | null): void {
|
||||
this.tenantIdValue = tenantId;
|
||||
this.sessionValue.tenantId = tenantId ?? 'tenant-default';
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConsoleSessionService', () => {
|
||||
let service: ConsoleSessionService;
|
||||
let store: ConsoleSessionStore;
|
||||
let authStore: MockAuthSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ConsoleSessionStore,
|
||||
ConsoleSessionService,
|
||||
{ provide: AUTHORITY_CONSOLE_API, useClass: MockConsoleApi },
|
||||
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(ConsoleSessionService);
|
||||
store = TestBed.inject(ConsoleSessionStore);
|
||||
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
|
||||
});
|
||||
|
||||
it('loads console context for active tenant', async () => {
|
||||
await service.loadConsoleContext();
|
||||
|
||||
expect(store.tenants().length).toBe(1);
|
||||
expect(store.selectedTenantId()).toBe('tenant-default');
|
||||
expect(store.profile()?.displayName).toBe('Console User');
|
||||
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
||||
});
|
||||
|
||||
it('clears store when no tenant available', async () => {
|
||||
authStore.setTenantId(null);
|
||||
store.setTenants(
|
||||
[
|
||||
{
|
||||
id: 'existing',
|
||||
displayName: 'Existing',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: [],
|
||||
},
|
||||
],
|
||||
'existing'
|
||||
);
|
||||
|
||||
await service.loadConsoleContext();
|
||||
|
||||
expect(store.tenants().length).toBe(0);
|
||||
expect(store.selectedTenantId()).toBeNull();
|
||||
});
|
||||
});
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import {
|
||||
AUTHORITY_CONSOLE_API,
|
||||
AuthorityConsoleApi,
|
||||
TenantCatalogResponseDto,
|
||||
} from '../api/authority-console.client';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { ConsoleSessionService } from './console-session.service';
|
||||
import { ConsoleSessionStore } from './console-session.store';
|
||||
|
||||
class MockConsoleApi implements AuthorityConsoleApi {
|
||||
private createTenantResponse(): TenantCatalogResponseDto {
|
||||
return {
|
||||
tenants: [
|
||||
{
|
||||
id: 'tenant-default',
|
||||
displayName: 'Tenant Default',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.console'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
listTenants() {
|
||||
return of(this.createTenantResponse());
|
||||
}
|
||||
|
||||
getProfile() {
|
||||
return of({
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'Console User',
|
||||
tenant: 'tenant-default',
|
||||
sessionId: 'session-1',
|
||||
roles: ['role.console'],
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: '2025-10-31T12:00:00Z',
|
||||
authenticationTime: '2025-10-31T12:00:00Z',
|
||||
expiresAt: '2025-10-31T12:10:00Z',
|
||||
freshAuth: true,
|
||||
});
|
||||
}
|
||||
|
||||
introspectToken() {
|
||||
return of({
|
||||
active: true,
|
||||
tenant: 'tenant-default',
|
||||
subject: 'user-1',
|
||||
clientId: 'console-web',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
issuedAt: '2025-10-31T12:00:00Z',
|
||||
authenticationTime: '2025-10-31T12:00:00Z',
|
||||
expiresAt: '2025-10-31T12:10:00Z',
|
||||
freshAuth: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class MockAuthSessionStore {
|
||||
private tenantIdValue: string | null = 'tenant-default';
|
||||
private readonly sessionValue = {
|
||||
tenantId: 'tenant-default',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationTimeEpochMs: Date.parse('2025-10-31T12:00:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAtEpochMs: Date.parse('2025-10-31T12:05:00Z'),
|
||||
};
|
||||
|
||||
session = () => this.sessionValue as any;
|
||||
|
||||
getActiveTenantId(): string | null {
|
||||
return this.tenantIdValue;
|
||||
}
|
||||
|
||||
setTenantId(tenantId: string | null): void {
|
||||
this.tenantIdValue = tenantId;
|
||||
this.sessionValue.tenantId = tenantId ?? 'tenant-default';
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConsoleSessionService', () => {
|
||||
let service: ConsoleSessionService;
|
||||
let store: ConsoleSessionStore;
|
||||
let authStore: MockAuthSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ConsoleSessionStore,
|
||||
ConsoleSessionService,
|
||||
{ provide: AUTHORITY_CONSOLE_API, useClass: MockConsoleApi },
|
||||
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(ConsoleSessionService);
|
||||
store = TestBed.inject(ConsoleSessionStore);
|
||||
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
|
||||
});
|
||||
|
||||
it('loads console context for active tenant', async () => {
|
||||
await service.loadConsoleContext();
|
||||
|
||||
expect(store.tenants().length).toBe(1);
|
||||
expect(store.selectedTenantId()).toBe('tenant-default');
|
||||
expect(store.profile()?.displayName).toBe('Console User');
|
||||
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
||||
});
|
||||
|
||||
it('clears store when no tenant available', async () => {
|
||||
authStore.setTenantId(null);
|
||||
store.setTenants(
|
||||
[
|
||||
{
|
||||
id: 'existing',
|
||||
displayName: 'Existing',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: [],
|
||||
},
|
||||
],
|
||||
'existing'
|
||||
);
|
||||
|
||||
await service.loadConsoleContext();
|
||||
|
||||
expect(store.tenants().length).toBe(0);
|
||||
expect(store.selectedTenantId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,161 +1,161 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
AUTHORITY_CONSOLE_API,
|
||||
AuthorityConsoleApi,
|
||||
AuthorityTenantViewDto,
|
||||
ConsoleProfileDto,
|
||||
ConsoleTokenIntrospectionDto,
|
||||
} from '../api/authority-console.client';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import {
|
||||
ConsoleProfile,
|
||||
ConsoleSessionStore,
|
||||
ConsoleTenant,
|
||||
ConsoleTokenInfo,
|
||||
} from './console-session.store';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsoleSessionService {
|
||||
private readonly api = inject<AuthorityConsoleApi>(AUTHORITY_CONSOLE_API);
|
||||
private readonly store = inject(ConsoleSessionStore);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
|
||||
async loadConsoleContext(tenantId?: string | null): Promise<void> {
|
||||
const activeTenant =
|
||||
(tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||
|
||||
if (!activeTenant) {
|
||||
this.store.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.setSelectedTenant(activeTenant);
|
||||
this.store.setLoading(true);
|
||||
this.store.setError(null);
|
||||
|
||||
try {
|
||||
const tenantResponse = await firstValueFrom(
|
||||
this.api.listTenants(activeTenant)
|
||||
);
|
||||
const tenants = (tenantResponse.tenants ?? []).map((tenant) =>
|
||||
this.mapTenant(tenant)
|
||||
);
|
||||
|
||||
const [profileDto, tokenDto] = await Promise.all([
|
||||
firstValueFrom(this.api.getProfile(activeTenant)),
|
||||
firstValueFrom(this.api.introspectToken(activeTenant)),
|
||||
]);
|
||||
|
||||
const profile = this.mapProfile(profileDto);
|
||||
const tokenInfo = this.mapTokenInfo(tokenDto);
|
||||
|
||||
this.store.setContext({
|
||||
tenants,
|
||||
profile,
|
||||
token: tokenInfo,
|
||||
selectedTenantId: activeTenant,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load console context', error);
|
||||
this.store.setError('Unable to load console context.');
|
||||
} finally {
|
||||
this.store.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async switchTenant(tenantId: string): Promise<void> {
|
||||
if (!tenantId || tenantId === this.store.selectedTenantId()) {
|
||||
return this.loadConsoleContext(tenantId);
|
||||
}
|
||||
|
||||
this.store.setSelectedTenant(tenantId);
|
||||
await this.loadConsoleContext(tenantId);
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
await this.loadConsoleContext(this.store.selectedTenantId());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
private mapTenant(dto: AuthorityTenantViewDto): ConsoleTenant {
|
||||
const roles = Array.isArray(dto.defaultRoles)
|
||||
? dto.defaultRoles
|
||||
.map((role) => role.trim())
|
||||
.filter((role) => role.length > 0)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: dto.id,
|
||||
displayName: dto.displayName || dto.id,
|
||||
status: dto.status ?? 'active',
|
||||
isolationMode: dto.isolationMode ?? 'shared',
|
||||
defaultRoles: roles,
|
||||
};
|
||||
}
|
||||
|
||||
private mapProfile(dto: ConsoleProfileDto): ConsoleProfile {
|
||||
return {
|
||||
subjectId: dto.subjectId ?? null,
|
||||
username: dto.username ?? null,
|
||||
displayName: dto.displayName ?? dto.username ?? dto.subjectId ?? null,
|
||||
tenant: dto.tenant,
|
||||
sessionId: dto.sessionId ?? null,
|
||||
roles: [...(dto.roles ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
),
|
||||
authenticationMethods: [...(dto.authenticationMethods ?? [])],
|
||||
issuedAt: this.parseInstant(dto.issuedAt),
|
||||
authenticationTime: this.parseInstant(dto.authenticationTime),
|
||||
expiresAt: this.parseInstant(dto.expiresAt),
|
||||
freshAuth: !!dto.freshAuth,
|
||||
};
|
||||
}
|
||||
|
||||
private mapTokenInfo(dto: ConsoleTokenIntrospectionDto): ConsoleTokenInfo {
|
||||
const session = this.authSession.session();
|
||||
const freshAuthExpiresAt =
|
||||
session?.freshAuthExpiresAtEpochMs != null
|
||||
? new Date(session.freshAuthExpiresAtEpochMs)
|
||||
: null;
|
||||
|
||||
const authenticationTime =
|
||||
session?.authenticationTimeEpochMs != null
|
||||
? new Date(session.authenticationTimeEpochMs)
|
||||
: this.parseInstant(dto.authenticationTime);
|
||||
|
||||
return {
|
||||
active: !!dto.active,
|
||||
tenant: dto.tenant,
|
||||
subject: dto.subject ?? null,
|
||||
clientId: dto.clientId ?? null,
|
||||
tokenId: dto.tokenId ?? null,
|
||||
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
),
|
||||
issuedAt: this.parseInstant(dto.issuedAt),
|
||||
authenticationTime,
|
||||
expiresAt: this.parseInstant(dto.expiresAt),
|
||||
freshAuthActive: session?.freshAuthActive ?? !!dto.freshAuth,
|
||||
freshAuthExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
private parseInstant(value: string | null | undefined): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
}
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
AUTHORITY_CONSOLE_API,
|
||||
AuthorityConsoleApi,
|
||||
AuthorityTenantViewDto,
|
||||
ConsoleProfileDto,
|
||||
ConsoleTokenIntrospectionDto,
|
||||
} from '../api/authority-console.client';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import {
|
||||
ConsoleProfile,
|
||||
ConsoleSessionStore,
|
||||
ConsoleTenant,
|
||||
ConsoleTokenInfo,
|
||||
} from './console-session.store';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsoleSessionService {
|
||||
private readonly api = inject<AuthorityConsoleApi>(AUTHORITY_CONSOLE_API);
|
||||
private readonly store = inject(ConsoleSessionStore);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
|
||||
async loadConsoleContext(tenantId?: string | null): Promise<void> {
|
||||
const activeTenant =
|
||||
(tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||
|
||||
if (!activeTenant) {
|
||||
this.store.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.setSelectedTenant(activeTenant);
|
||||
this.store.setLoading(true);
|
||||
this.store.setError(null);
|
||||
|
||||
try {
|
||||
const tenantResponse = await firstValueFrom(
|
||||
this.api.listTenants(activeTenant)
|
||||
);
|
||||
const tenants = (tenantResponse.tenants ?? []).map((tenant) =>
|
||||
this.mapTenant(tenant)
|
||||
);
|
||||
|
||||
const [profileDto, tokenDto] = await Promise.all([
|
||||
firstValueFrom(this.api.getProfile(activeTenant)),
|
||||
firstValueFrom(this.api.introspectToken(activeTenant)),
|
||||
]);
|
||||
|
||||
const profile = this.mapProfile(profileDto);
|
||||
const tokenInfo = this.mapTokenInfo(tokenDto);
|
||||
|
||||
this.store.setContext({
|
||||
tenants,
|
||||
profile,
|
||||
token: tokenInfo,
|
||||
selectedTenantId: activeTenant,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load console context', error);
|
||||
this.store.setError('Unable to load console context.');
|
||||
} finally {
|
||||
this.store.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async switchTenant(tenantId: string): Promise<void> {
|
||||
if (!tenantId || tenantId === this.store.selectedTenantId()) {
|
||||
return this.loadConsoleContext(tenantId);
|
||||
}
|
||||
|
||||
this.store.setSelectedTenant(tenantId);
|
||||
await this.loadConsoleContext(tenantId);
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
await this.loadConsoleContext(this.store.selectedTenantId());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
private mapTenant(dto: AuthorityTenantViewDto): ConsoleTenant {
|
||||
const roles = Array.isArray(dto.defaultRoles)
|
||||
? dto.defaultRoles
|
||||
.map((role) => role.trim())
|
||||
.filter((role) => role.length > 0)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: dto.id,
|
||||
displayName: dto.displayName || dto.id,
|
||||
status: dto.status ?? 'active',
|
||||
isolationMode: dto.isolationMode ?? 'shared',
|
||||
defaultRoles: roles,
|
||||
};
|
||||
}
|
||||
|
||||
private mapProfile(dto: ConsoleProfileDto): ConsoleProfile {
|
||||
return {
|
||||
subjectId: dto.subjectId ?? null,
|
||||
username: dto.username ?? null,
|
||||
displayName: dto.displayName ?? dto.username ?? dto.subjectId ?? null,
|
||||
tenant: dto.tenant,
|
||||
sessionId: dto.sessionId ?? null,
|
||||
roles: [...(dto.roles ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
),
|
||||
authenticationMethods: [...(dto.authenticationMethods ?? [])],
|
||||
issuedAt: this.parseInstant(dto.issuedAt),
|
||||
authenticationTime: this.parseInstant(dto.authenticationTime),
|
||||
expiresAt: this.parseInstant(dto.expiresAt),
|
||||
freshAuth: !!dto.freshAuth,
|
||||
};
|
||||
}
|
||||
|
||||
private mapTokenInfo(dto: ConsoleTokenIntrospectionDto): ConsoleTokenInfo {
|
||||
const session = this.authSession.session();
|
||||
const freshAuthExpiresAt =
|
||||
session?.freshAuthExpiresAtEpochMs != null
|
||||
? new Date(session.freshAuthExpiresAtEpochMs)
|
||||
: null;
|
||||
|
||||
const authenticationTime =
|
||||
session?.authenticationTimeEpochMs != null
|
||||
? new Date(session.authenticationTimeEpochMs)
|
||||
: this.parseInstant(dto.authenticationTime);
|
||||
|
||||
return {
|
||||
active: !!dto.active,
|
||||
tenant: dto.tenant,
|
||||
subject: dto.subject ?? null,
|
||||
clientId: dto.clientId ?? null,
|
||||
tokenId: dto.tokenId ?? null,
|
||||
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
),
|
||||
issuedAt: this.parseInstant(dto.issuedAt),
|
||||
authenticationTime,
|
||||
expiresAt: this.parseInstant(dto.expiresAt),
|
||||
freshAuthActive: session?.freshAuthActive ?? !!dto.freshAuth,
|
||||
freshAuthExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
private parseInstant(value: string | null | undefined): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +1,123 @@
|
||||
import { ConsoleSessionStore } from './console-session.store';
|
||||
|
||||
describe('ConsoleSessionStore', () => {
|
||||
let store: ConsoleSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new ConsoleSessionStore();
|
||||
});
|
||||
|
||||
it('tracks tenants and selection', () => {
|
||||
const tenants = [
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.a'],
|
||||
},
|
||||
{
|
||||
id: 'tenant-b',
|
||||
displayName: 'Tenant B',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.b'],
|
||||
},
|
||||
];
|
||||
|
||||
const selected = store.setTenants(tenants, 'tenant-b');
|
||||
expect(selected).toBe('tenant-b');
|
||||
expect(store.selectedTenantId()).toBe('tenant-b');
|
||||
expect(store.tenants().length).toBe(2);
|
||||
});
|
||||
|
||||
it('sets context with profile and token info', () => {
|
||||
const tenants = [
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.a'],
|
||||
},
|
||||
];
|
||||
|
||||
store.setContext({
|
||||
tenants,
|
||||
selectedTenantId: 'tenant-a',
|
||||
profile: {
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'User Example',
|
||||
tenant: 'tenant-a',
|
||||
sessionId: 'session-123',
|
||||
roles: ['role.a'],
|
||||
scopes: ['scope.a'],
|
||||
audiences: ['aud'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuth: true,
|
||||
},
|
||||
token: {
|
||||
active: true,
|
||||
tenant: 'tenant-a',
|
||||
subject: 'user-1',
|
||||
clientId: 'client',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['scope.a'],
|
||||
audiences: ['aud'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.selectedTenantId()).toBe('tenant-a');
|
||||
expect(store.profile()?.displayName).toBe('User Example');
|
||||
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
||||
expect(store.hasContext()).toBeTrue();
|
||||
});
|
||||
|
||||
it('clears state', () => {
|
||||
store.setTenants(
|
||||
[
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: [],
|
||||
},
|
||||
],
|
||||
'tenant-a'
|
||||
);
|
||||
store.setProfile({
|
||||
subjectId: null,
|
||||
username: null,
|
||||
displayName: null,
|
||||
tenant: 'tenant-a',
|
||||
sessionId: null,
|
||||
roles: [],
|
||||
scopes: [],
|
||||
audiences: [],
|
||||
authenticationMethods: [],
|
||||
issuedAt: null,
|
||||
authenticationTime: null,
|
||||
expiresAt: null,
|
||||
freshAuth: false,
|
||||
});
|
||||
|
||||
store.clear();
|
||||
|
||||
expect(store.tenants().length).toBe(0);
|
||||
expect(store.selectedTenantId()).toBeNull();
|
||||
expect(store.profile()).toBeNull();
|
||||
expect(store.tokenInfo()).toBeNull();
|
||||
expect(store.loading()).toBeFalse();
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
});
|
||||
import { ConsoleSessionStore } from './console-session.store';
|
||||
|
||||
describe('ConsoleSessionStore', () => {
|
||||
let store: ConsoleSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new ConsoleSessionStore();
|
||||
});
|
||||
|
||||
it('tracks tenants and selection', () => {
|
||||
const tenants = [
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.a'],
|
||||
},
|
||||
{
|
||||
id: 'tenant-b',
|
||||
displayName: 'Tenant B',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.b'],
|
||||
},
|
||||
];
|
||||
|
||||
const selected = store.setTenants(tenants, 'tenant-b');
|
||||
expect(selected).toBe('tenant-b');
|
||||
expect(store.selectedTenantId()).toBe('tenant-b');
|
||||
expect(store.tenants().length).toBe(2);
|
||||
});
|
||||
|
||||
it('sets context with profile and token info', () => {
|
||||
const tenants = [
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.a'],
|
||||
},
|
||||
];
|
||||
|
||||
store.setContext({
|
||||
tenants,
|
||||
selectedTenantId: 'tenant-a',
|
||||
profile: {
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'User Example',
|
||||
tenant: 'tenant-a',
|
||||
sessionId: 'session-123',
|
||||
roles: ['role.a'],
|
||||
scopes: ['scope.a'],
|
||||
audiences: ['aud'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuth: true,
|
||||
},
|
||||
token: {
|
||||
active: true,
|
||||
tenant: 'tenant-a',
|
||||
subject: 'user-1',
|
||||
clientId: 'client',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['scope.a'],
|
||||
audiences: ['aud'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.selectedTenantId()).toBe('tenant-a');
|
||||
expect(store.profile()?.displayName).toBe('User Example');
|
||||
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
||||
expect(store.hasContext()).toBeTrue();
|
||||
});
|
||||
|
||||
it('clears state', () => {
|
||||
store.setTenants(
|
||||
[
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: [],
|
||||
},
|
||||
],
|
||||
'tenant-a'
|
||||
);
|
||||
store.setProfile({
|
||||
subjectId: null,
|
||||
username: null,
|
||||
displayName: null,
|
||||
tenant: 'tenant-a',
|
||||
sessionId: null,
|
||||
roles: [],
|
||||
scopes: [],
|
||||
audiences: [],
|
||||
authenticationMethods: [],
|
||||
issuedAt: null,
|
||||
authenticationTime: null,
|
||||
expiresAt: null,
|
||||
freshAuth: false,
|
||||
});
|
||||
|
||||
store.clear();
|
||||
|
||||
expect(store.tenants().length).toBe(0);
|
||||
expect(store.selectedTenantId()).toBeNull();
|
||||
expect(store.profile()).toBeNull();
|
||||
expect(store.tokenInfo()).toBeNull();
|
||||
expect(store.loading()).toBeFalse();
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,137 +1,137 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
export interface ConsoleTenant {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly status: string;
|
||||
readonly isolationMode: string;
|
||||
readonly defaultRoles: readonly string[];
|
||||
}
|
||||
|
||||
export interface ConsoleProfile {
|
||||
readonly subjectId: string | null;
|
||||
readonly username: string | null;
|
||||
readonly displayName: string | null;
|
||||
readonly tenant: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationMethods: readonly string[];
|
||||
readonly issuedAt: Date | null;
|
||||
readonly authenticationTime: Date | null;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleTokenInfo {
|
||||
readonly active: boolean;
|
||||
readonly tenant: string;
|
||||
readonly subject: string | null;
|
||||
readonly clientId: string | null;
|
||||
readonly tokenId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly issuedAt: Date | null;
|
||||
readonly authenticationTime: Date | null;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly freshAuthActive: boolean;
|
||||
readonly freshAuthExpiresAt: Date | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsoleSessionStore {
|
||||
private readonly tenantsSignal = signal<ConsoleTenant[]>([]);
|
||||
private readonly selectedTenantIdSignal = signal<string | null>(null);
|
||||
private readonly profileSignal = signal<ConsoleProfile | null>(null);
|
||||
private readonly tokenSignal = signal<ConsoleTokenInfo | null>(null);
|
||||
private readonly loadingSignal = signal(false);
|
||||
private readonly errorSignal = signal<string | null>(null);
|
||||
|
||||
readonly tenants = computed(() => this.tenantsSignal());
|
||||
readonly selectedTenantId = computed(() => this.selectedTenantIdSignal());
|
||||
readonly profile = computed(() => this.profileSignal());
|
||||
readonly tokenInfo = computed(() => this.tokenSignal());
|
||||
readonly loading = computed(() => this.loadingSignal());
|
||||
readonly error = computed(() => this.errorSignal());
|
||||
readonly currentTenant = computed(() => {
|
||||
const tenantId = this.selectedTenantIdSignal();
|
||||
if (!tenantId) return null;
|
||||
return this.tenantsSignal().find(tenant => tenant.id === tenantId) ?? null;
|
||||
});
|
||||
readonly hasContext = computed(
|
||||
() =>
|
||||
this.tenantsSignal().length > 0 ||
|
||||
this.profileSignal() !== null ||
|
||||
this.tokenSignal() !== null
|
||||
);
|
||||
|
||||
setLoading(loading: boolean): void {
|
||||
this.loadingSignal.set(loading);
|
||||
}
|
||||
|
||||
setError(message: string | null): void {
|
||||
this.errorSignal.set(message);
|
||||
}
|
||||
|
||||
setContext(context: {
|
||||
tenants: ConsoleTenant[];
|
||||
profile: ConsoleProfile | null;
|
||||
token: ConsoleTokenInfo | null;
|
||||
selectedTenantId?: string | null;
|
||||
}): void {
|
||||
const selected = this.setTenants(context.tenants, context.selectedTenantId);
|
||||
this.profileSignal.set(context.profile);
|
||||
this.tokenSignal.set(context.token);
|
||||
this.selectedTenantIdSignal.set(selected);
|
||||
}
|
||||
|
||||
setProfile(profile: ConsoleProfile | null): void {
|
||||
this.profileSignal.set(profile);
|
||||
}
|
||||
|
||||
setTokenInfo(token: ConsoleTokenInfo | null): void {
|
||||
this.tokenSignal.set(token);
|
||||
}
|
||||
|
||||
setTenants(
|
||||
tenants: ConsoleTenant[],
|
||||
preferredTenantId?: string | null
|
||||
): string | null {
|
||||
this.tenantsSignal.set(tenants);
|
||||
const currentSelection = this.selectedTenantIdSignal();
|
||||
const fallbackSelection =
|
||||
tenants.length > 0 ? tenants[0].id : null;
|
||||
|
||||
const nextSelection =
|
||||
(preferredTenantId &&
|
||||
tenants.some((tenant) => tenant.id === preferredTenantId) &&
|
||||
preferredTenantId) ||
|
||||
(currentSelection &&
|
||||
tenants.some((tenant) => tenant.id === currentSelection) &&
|
||||
currentSelection) ||
|
||||
fallbackSelection;
|
||||
|
||||
this.selectedTenantIdSignal.set(nextSelection);
|
||||
return nextSelection;
|
||||
}
|
||||
|
||||
setSelectedTenant(tenantId: string | null): void {
|
||||
this.selectedTenantIdSignal.set(tenantId);
|
||||
}
|
||||
|
||||
currentTenantSnapshot(): ConsoleTenant | null {
|
||||
return this.currentTenant();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tenantsSignal.set([]);
|
||||
this.selectedTenantIdSignal.set(null);
|
||||
this.profileSignal.set(null);
|
||||
this.tokenSignal.set(null);
|
||||
this.loadingSignal.set(false);
|
||||
this.errorSignal.set(null);
|
||||
}
|
||||
}
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
export interface ConsoleTenant {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly status: string;
|
||||
readonly isolationMode: string;
|
||||
readonly defaultRoles: readonly string[];
|
||||
}
|
||||
|
||||
export interface ConsoleProfile {
|
||||
readonly subjectId: string | null;
|
||||
readonly username: string | null;
|
||||
readonly displayName: string | null;
|
||||
readonly tenant: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationMethods: readonly string[];
|
||||
readonly issuedAt: Date | null;
|
||||
readonly authenticationTime: Date | null;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleTokenInfo {
|
||||
readonly active: boolean;
|
||||
readonly tenant: string;
|
||||
readonly subject: string | null;
|
||||
readonly clientId: string | null;
|
||||
readonly tokenId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly issuedAt: Date | null;
|
||||
readonly authenticationTime: Date | null;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly freshAuthActive: boolean;
|
||||
readonly freshAuthExpiresAt: Date | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsoleSessionStore {
|
||||
private readonly tenantsSignal = signal<ConsoleTenant[]>([]);
|
||||
private readonly selectedTenantIdSignal = signal<string | null>(null);
|
||||
private readonly profileSignal = signal<ConsoleProfile | null>(null);
|
||||
private readonly tokenSignal = signal<ConsoleTokenInfo | null>(null);
|
||||
private readonly loadingSignal = signal(false);
|
||||
private readonly errorSignal = signal<string | null>(null);
|
||||
|
||||
readonly tenants = computed(() => this.tenantsSignal());
|
||||
readonly selectedTenantId = computed(() => this.selectedTenantIdSignal());
|
||||
readonly profile = computed(() => this.profileSignal());
|
||||
readonly tokenInfo = computed(() => this.tokenSignal());
|
||||
readonly loading = computed(() => this.loadingSignal());
|
||||
readonly error = computed(() => this.errorSignal());
|
||||
readonly currentTenant = computed(() => {
|
||||
const tenantId = this.selectedTenantIdSignal();
|
||||
if (!tenantId) return null;
|
||||
return this.tenantsSignal().find(tenant => tenant.id === tenantId) ?? null;
|
||||
});
|
||||
readonly hasContext = computed(
|
||||
() =>
|
||||
this.tenantsSignal().length > 0 ||
|
||||
this.profileSignal() !== null ||
|
||||
this.tokenSignal() !== null
|
||||
);
|
||||
|
||||
setLoading(loading: boolean): void {
|
||||
this.loadingSignal.set(loading);
|
||||
}
|
||||
|
||||
setError(message: string | null): void {
|
||||
this.errorSignal.set(message);
|
||||
}
|
||||
|
||||
setContext(context: {
|
||||
tenants: ConsoleTenant[];
|
||||
profile: ConsoleProfile | null;
|
||||
token: ConsoleTokenInfo | null;
|
||||
selectedTenantId?: string | null;
|
||||
}): void {
|
||||
const selected = this.setTenants(context.tenants, context.selectedTenantId);
|
||||
this.profileSignal.set(context.profile);
|
||||
this.tokenSignal.set(context.token);
|
||||
this.selectedTenantIdSignal.set(selected);
|
||||
}
|
||||
|
||||
setProfile(profile: ConsoleProfile | null): void {
|
||||
this.profileSignal.set(profile);
|
||||
}
|
||||
|
||||
setTokenInfo(token: ConsoleTokenInfo | null): void {
|
||||
this.tokenSignal.set(token);
|
||||
}
|
||||
|
||||
setTenants(
|
||||
tenants: ConsoleTenant[],
|
||||
preferredTenantId?: string | null
|
||||
): string | null {
|
||||
this.tenantsSignal.set(tenants);
|
||||
const currentSelection = this.selectedTenantIdSignal();
|
||||
const fallbackSelection =
|
||||
tenants.length > 0 ? tenants[0].id : null;
|
||||
|
||||
const nextSelection =
|
||||
(preferredTenantId &&
|
||||
tenants.some((tenant) => tenant.id === preferredTenantId) &&
|
||||
preferredTenantId) ||
|
||||
(currentSelection &&
|
||||
tenants.some((tenant) => tenant.id === currentSelection) &&
|
||||
currentSelection) ||
|
||||
fallbackSelection;
|
||||
|
||||
this.selectedTenantIdSignal.set(nextSelection);
|
||||
return nextSelection;
|
||||
}
|
||||
|
||||
setSelectedTenant(tenantId: string | null): void {
|
||||
this.selectedTenantIdSignal.set(tenantId);
|
||||
}
|
||||
|
||||
currentTenantSnapshot(): ConsoleTenant | null {
|
||||
return this.currentTenant();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tenantsSignal.set([]);
|
||||
this.selectedTenantIdSignal.set(null);
|
||||
this.profileSignal.set(null);
|
||||
this.tokenSignal.set(null);
|
||||
this.loadingSignal.set(false);
|
||||
this.errorSignal.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export class NavigationService {
|
||||
const _ = this.activeRoute(); // Subscribe to route changes
|
||||
this._mobileMenuOpen.set(false);
|
||||
this._activeDropdown.set(null);
|
||||
});
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface OperatorContext {
|
||||
readonly reason: string;
|
||||
readonly ticket: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class OperatorContextService {
|
||||
private readonly contextSignal = signal<OperatorContext | null>(null);
|
||||
|
||||
readonly context = this.contextSignal.asReadonly();
|
||||
|
||||
setContext(reason: string, ticket: string): void {
|
||||
const normalizedReason = reason.trim();
|
||||
const normalizedTicket = ticket.trim();
|
||||
if (!normalizedReason || !normalizedTicket) {
|
||||
throw new Error(
|
||||
'operator_reason and operator_ticket must be provided for orchestrator control actions.'
|
||||
);
|
||||
}
|
||||
|
||||
this.contextSignal.set({ reason: normalizedReason, ticket: normalizedTicket });
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.contextSignal.set(null);
|
||||
}
|
||||
|
||||
snapshot(): OperatorContext | null {
|
||||
return this.contextSignal();
|
||||
}
|
||||
}
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface OperatorContext {
|
||||
readonly reason: string;
|
||||
readonly ticket: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class OperatorContextService {
|
||||
private readonly contextSignal = signal<OperatorContext | null>(null);
|
||||
|
||||
readonly context = this.contextSignal.asReadonly();
|
||||
|
||||
setContext(reason: string, ticket: string): void {
|
||||
const normalizedReason = reason.trim();
|
||||
const normalizedTicket = ticket.trim();
|
||||
if (!normalizedReason || !normalizedTicket) {
|
||||
throw new Error(
|
||||
'operator_reason and operator_ticket must be provided for orchestrator control actions.'
|
||||
);
|
||||
}
|
||||
|
||||
this.contextSignal.set({ reason: normalizedReason, ticket: normalizedTicket });
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.contextSignal.set(null);
|
||||
}
|
||||
|
||||
snapshot(): OperatorContext | null {
|
||||
return this.contextSignal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import {
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { OperatorContextService } from './operator-context.service';
|
||||
|
||||
export const OPERATOR_METADATA_SENTINEL_HEADER = 'X-Stella-Require-Operator';
|
||||
export const OPERATOR_REASON_HEADER = 'X-Stella-Operator-Reason';
|
||||
export const OPERATOR_TICKET_HEADER = 'X-Stella-Operator-Ticket';
|
||||
|
||||
@Injectable()
|
||||
export class OperatorMetadataInterceptor implements HttpInterceptor {
|
||||
constructor(private readonly context: OperatorContextService) {}
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
if (!request.headers.has(OPERATOR_METADATA_SENTINEL_HEADER)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
const current = this.context.snapshot();
|
||||
const headers = request.headers.delete(OPERATOR_METADATA_SENTINEL_HEADER);
|
||||
|
||||
if (!current) {
|
||||
return next.handle(request.clone({ headers }));
|
||||
}
|
||||
|
||||
const enriched = headers
|
||||
.set(OPERATOR_REASON_HEADER, current.reason)
|
||||
.set(OPERATOR_TICKET_HEADER, current.ticket);
|
||||
|
||||
return next.handle(request.clone({ headers: enriched }));
|
||||
}
|
||||
}
|
||||
import {
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { OperatorContextService } from './operator-context.service';
|
||||
|
||||
export const OPERATOR_METADATA_SENTINEL_HEADER = 'X-Stella-Require-Operator';
|
||||
export const OPERATOR_REASON_HEADER = 'X-Stella-Operator-Reason';
|
||||
export const OPERATOR_TICKET_HEADER = 'X-Stella-Operator-Ticket';
|
||||
|
||||
@Injectable()
|
||||
export class OperatorMetadataInterceptor implements HttpInterceptor {
|
||||
constructor(private readonly context: OperatorContextService) {}
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
if (!request.headers.has(OPERATOR_METADATA_SENTINEL_HEADER)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
const current = this.context.snapshot();
|
||||
const headers = request.headers.delete(OPERATOR_METADATA_SENTINEL_HEADER);
|
||||
|
||||
if (!current) {
|
||||
return next.handle(request.clone({ headers }));
|
||||
}
|
||||
|
||||
const enriched = headers
|
||||
.set(OPERATOR_REASON_HEADER, current.reason)
|
||||
.set(OPERATOR_TICKET_HEADER, current.ticket);
|
||||
|
||||
return next.handle(request.clone({ headers: enriched }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ import {
|
||||
.stat-value.failed { color: #dc2626; }
|
||||
.stat-value.pending { color: #d97706; }
|
||||
.stat-value.throttled { color: #2563eb; }
|
||||
.stat-value.rate { color: #6366f1; }
|
||||
.stat-value.rate { color: #D4920A; }
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -239,7 +239,7 @@ interface ConfigSubTab {
|
||||
.sent-icon { background: #10b981; }
|
||||
.failed-icon { background: #ef4444; }
|
||||
.pending-icon { background: #f59e0b; }
|
||||
.rate-icon { background: #6366f1; }
|
||||
.rate-icon { background: #D4920A; }
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
|
||||
@@ -214,7 +214,7 @@ export const OBJECT_LINK_METADATA: Record<ObjectLinkType, { icon: string; color:
|
||||
reach: { icon: 'git-branch', color: '#8b5cf6', label: 'Reachability' },
|
||||
runtime: { icon: 'activity', color: '#f59e0b', label: 'Runtime' },
|
||||
vex: { icon: 'shield', color: '#10b981', label: 'VEX' },
|
||||
attest: { icon: 'file-signature', color: '#6366f1', label: 'Attestation' },
|
||||
attest: { icon: 'file-signature', color: '#D4920A', label: 'Attestation' },
|
||||
auth: { icon: 'key', color: '#ef4444', label: 'Auth' },
|
||||
docs: { icon: 'book', color: '#64748b', label: 'Docs' },
|
||||
finding: { icon: 'alert-triangle', color: '#f97316', label: 'Finding' },
|
||||
|
||||
@@ -147,7 +147,7 @@ import {
|
||||
.chip--reach { --chip-color: #8b5cf6; --chip-bg: rgba(139, 92, 246, 0.1); --chip-border: rgba(139, 92, 246, 0.2); }
|
||||
.chip--runtime { --chip-color: #f59e0b; --chip-bg: rgba(245, 158, 11, 0.1); --chip-border: rgba(245, 158, 11, 0.2); }
|
||||
.chip--vex { --chip-color: #10b981; --chip-bg: rgba(16, 185, 129, 0.1); --chip-border: rgba(16, 185, 129, 0.2); }
|
||||
.chip--attest { --chip-color: #6366f1; --chip-bg: rgba(99, 102, 241, 0.1); --chip-border: rgba(99, 102, 241, 0.2); }
|
||||
.chip--attest { --chip-color: #D4920A; --chip-bg: rgba(245, 166, 35, 0.1); --chip-border: rgba(245, 166, 35, 0.2); }
|
||||
.chip--auth { --chip-color: #ef4444; --chip-bg: rgba(239, 68, 68, 0.1); --chip-border: rgba(239, 68, 68, 0.2); }
|
||||
.chip--docs { --chip-color: #64748b; --chip-bg: rgba(100, 116, 139, 0.1); --chip-border: rgba(100, 116, 139, 0.2); }
|
||||
.chip--finding { --chip-color: #f97316; --chip-bg: rgba(249, 115, 22, 0.1); --chip-border: rgba(249, 115, 22, 0.2); }
|
||||
|
||||
@@ -207,7 +207,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
}
|
||||
|
||||
.evidence-type-badge.type-patch {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
|
||||
@@ -471,7 +471,7 @@ import type {
|
||||
}
|
||||
|
||||
.citation-type.type-patch {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
|
||||
@@ -549,7 +549,7 @@ import type {
|
||||
}
|
||||
|
||||
.step-type.type-vex_document {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,184 +1,184 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { AocClient } from '../../core/api/aoc.client';
|
||||
import {
|
||||
AocVerificationRequest,
|
||||
AocVerificationResult,
|
||||
AocViolationDetail,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
type VerifyState = 'idle' | 'running' | 'completed' | 'error';
|
||||
|
||||
export interface CliParityGuidance {
|
||||
command: string;
|
||||
description: string;
|
||||
flags: { flag: string; description: string }[];
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-verify-action',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './verify-action.component.html',
|
||||
styleUrls: ['./verify-action.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class VerifyActionComponent {
|
||||
private readonly aocClient = inject(AocClient);
|
||||
|
||||
/** Tenant ID to verify */
|
||||
readonly tenantId = input.required<string>();
|
||||
|
||||
/** Time window in hours (default 24h) */
|
||||
readonly windowHours = input(24);
|
||||
|
||||
/** Maximum documents to check */
|
||||
readonly limit = input(10000);
|
||||
|
||||
/** Emits when verification completes */
|
||||
readonly verified = output<AocVerificationResult>();
|
||||
|
||||
/** Emits when user clicks on a violation */
|
||||
readonly selectViolation = output<AocViolationDetail>();
|
||||
|
||||
readonly state = signal<VerifyState>('idle');
|
||||
readonly result = signal<AocVerificationResult | null>(null);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly progress = signal(0);
|
||||
readonly showCliGuidance = signal(false);
|
||||
|
||||
readonly statusIcon = computed(() => {
|
||||
switch (this.state()) {
|
||||
case 'idle':
|
||||
return '[ ]';
|
||||
case 'running':
|
||||
return '[~]';
|
||||
case 'completed':
|
||||
return this.result()?.status === 'passed' ? '[+]' : '[!]';
|
||||
case 'error':
|
||||
return '[X]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
});
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
switch (this.state()) {
|
||||
case 'idle':
|
||||
return 'Ready to verify';
|
||||
case 'running':
|
||||
return 'Verification in progress...';
|
||||
case 'completed':
|
||||
const r = this.result();
|
||||
if (!r) return 'Completed';
|
||||
return r.status === 'passed'
|
||||
? 'Verification passed'
|
||||
: r.status === 'failed'
|
||||
? 'Verification failed'
|
||||
: 'Verification completed with warnings';
|
||||
case 'error':
|
||||
return 'Verification error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
readonly resultSummary = computed(() => {
|
||||
const r = this.result();
|
||||
if (!r) return null;
|
||||
return {
|
||||
passRate: ((r.passedCount / r.checkedCount) * 100).toFixed(2),
|
||||
violationCount: r.violations.length,
|
||||
uniqueCodes: [...new Set(r.violations.map((v) => v.violationCode))],
|
||||
};
|
||||
});
|
||||
|
||||
readonly cliGuidance: CliParityGuidance = {
|
||||
command: 'stella aoc verify',
|
||||
description:
|
||||
'Run the same verification from CLI for automation, CI/CD pipelines, or detailed output.',
|
||||
flags: [
|
||||
{ flag: '--tenant', description: 'Tenant ID to verify' },
|
||||
{ flag: '--since', description: 'Start time (ISO8601 or duration like "24h")' },
|
||||
{ flag: '--limit', description: 'Maximum documents to check' },
|
||||
{ flag: '--output', description: 'Output format: json, table, summary' },
|
||||
{ flag: '--fail-on-violation', description: 'Exit with code 1 if any violations found' },
|
||||
{ flag: '--verbose', description: 'Show detailed violation information' },
|
||||
],
|
||||
examples: [
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h',
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h --output json > report.json',
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h --fail-on-violation',
|
||||
],
|
||||
};
|
||||
|
||||
async runVerification(): Promise<void> {
|
||||
if (this.state() === 'running') return;
|
||||
|
||||
this.state.set('running');
|
||||
this.error.set(null);
|
||||
this.result.set(null);
|
||||
this.progress.set(0);
|
||||
|
||||
// Simulate progress updates
|
||||
const progressInterval = setInterval(() => {
|
||||
this.progress.update((p) => Math.min(p + Math.random() * 15, 90));
|
||||
}, 200);
|
||||
|
||||
const since = new Date();
|
||||
since.setHours(since.getHours() - this.windowHours());
|
||||
|
||||
const request: AocVerificationRequest = {
|
||||
tenantId: this.tenantId(),
|
||||
since: since.toISOString(),
|
||||
limit: this.limit(),
|
||||
};
|
||||
|
||||
this.aocClient.verify(request).subscribe({
|
||||
next: (result) => {
|
||||
clearInterval(progressInterval);
|
||||
this.progress.set(100);
|
||||
this.result.set(result);
|
||||
this.state.set('completed');
|
||||
this.verified.emit(result);
|
||||
},
|
||||
error: (err) => {
|
||||
clearInterval(progressInterval);
|
||||
this.state.set('error');
|
||||
this.error.set(err.message || 'Verification failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state.set('idle');
|
||||
this.result.set(null);
|
||||
this.error.set(null);
|
||||
this.progress.set(0);
|
||||
}
|
||||
|
||||
toggleCliGuidance(): void {
|
||||
this.showCliGuidance.update((v) => !v);
|
||||
}
|
||||
|
||||
onSelectViolation(violation: AocViolationDetail): void {
|
||||
this.selectViolation.emit(violation);
|
||||
}
|
||||
|
||||
copyCommand(command: string): void {
|
||||
navigator.clipboard.writeText(command);
|
||||
}
|
||||
|
||||
getCliCommand(): string {
|
||||
return `stella aoc verify --tenant ${this.tenantId()} --since ${this.windowHours()}h`;
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { AocClient } from '../../core/api/aoc.client';
|
||||
import {
|
||||
AocVerificationRequest,
|
||||
AocVerificationResult,
|
||||
AocViolationDetail,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
type VerifyState = 'idle' | 'running' | 'completed' | 'error';
|
||||
|
||||
export interface CliParityGuidance {
|
||||
command: string;
|
||||
description: string;
|
||||
flags: { flag: string; description: string }[];
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-verify-action',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './verify-action.component.html',
|
||||
styleUrls: ['./verify-action.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class VerifyActionComponent {
|
||||
private readonly aocClient = inject(AocClient);
|
||||
|
||||
/** Tenant ID to verify */
|
||||
readonly tenantId = input.required<string>();
|
||||
|
||||
/** Time window in hours (default 24h) */
|
||||
readonly windowHours = input(24);
|
||||
|
||||
/** Maximum documents to check */
|
||||
readonly limit = input(10000);
|
||||
|
||||
/** Emits when verification completes */
|
||||
readonly verified = output<AocVerificationResult>();
|
||||
|
||||
/** Emits when user clicks on a violation */
|
||||
readonly selectViolation = output<AocViolationDetail>();
|
||||
|
||||
readonly state = signal<VerifyState>('idle');
|
||||
readonly result = signal<AocVerificationResult | null>(null);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly progress = signal(0);
|
||||
readonly showCliGuidance = signal(false);
|
||||
|
||||
readonly statusIcon = computed(() => {
|
||||
switch (this.state()) {
|
||||
case 'idle':
|
||||
return '[ ]';
|
||||
case 'running':
|
||||
return '[~]';
|
||||
case 'completed':
|
||||
return this.result()?.status === 'passed' ? '[+]' : '[!]';
|
||||
case 'error':
|
||||
return '[X]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
});
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
switch (this.state()) {
|
||||
case 'idle':
|
||||
return 'Ready to verify';
|
||||
case 'running':
|
||||
return 'Verification in progress...';
|
||||
case 'completed':
|
||||
const r = this.result();
|
||||
if (!r) return 'Completed';
|
||||
return r.status === 'passed'
|
||||
? 'Verification passed'
|
||||
: r.status === 'failed'
|
||||
? 'Verification failed'
|
||||
: 'Verification completed with warnings';
|
||||
case 'error':
|
||||
return 'Verification error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
readonly resultSummary = computed(() => {
|
||||
const r = this.result();
|
||||
if (!r) return null;
|
||||
return {
|
||||
passRate: ((r.passedCount / r.checkedCount) * 100).toFixed(2),
|
||||
violationCount: r.violations.length,
|
||||
uniqueCodes: [...new Set(r.violations.map((v) => v.violationCode))],
|
||||
};
|
||||
});
|
||||
|
||||
readonly cliGuidance: CliParityGuidance = {
|
||||
command: 'stella aoc verify',
|
||||
description:
|
||||
'Run the same verification from CLI for automation, CI/CD pipelines, or detailed output.',
|
||||
flags: [
|
||||
{ flag: '--tenant', description: 'Tenant ID to verify' },
|
||||
{ flag: '--since', description: 'Start time (ISO8601 or duration like "24h")' },
|
||||
{ flag: '--limit', description: 'Maximum documents to check' },
|
||||
{ flag: '--output', description: 'Output format: json, table, summary' },
|
||||
{ flag: '--fail-on-violation', description: 'Exit with code 1 if any violations found' },
|
||||
{ flag: '--verbose', description: 'Show detailed violation information' },
|
||||
],
|
||||
examples: [
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h',
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h --output json > report.json',
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h --fail-on-violation',
|
||||
],
|
||||
};
|
||||
|
||||
async runVerification(): Promise<void> {
|
||||
if (this.state() === 'running') return;
|
||||
|
||||
this.state.set('running');
|
||||
this.error.set(null);
|
||||
this.result.set(null);
|
||||
this.progress.set(0);
|
||||
|
||||
// Simulate progress updates
|
||||
const progressInterval = setInterval(() => {
|
||||
this.progress.update((p) => Math.min(p + Math.random() * 15, 90));
|
||||
}, 200);
|
||||
|
||||
const since = new Date();
|
||||
since.setHours(since.getHours() - this.windowHours());
|
||||
|
||||
const request: AocVerificationRequest = {
|
||||
tenantId: this.tenantId(),
|
||||
since: since.toISOString(),
|
||||
limit: this.limit(),
|
||||
};
|
||||
|
||||
this.aocClient.verify(request).subscribe({
|
||||
next: (result) => {
|
||||
clearInterval(progressInterval);
|
||||
this.progress.set(100);
|
||||
this.result.set(result);
|
||||
this.state.set('completed');
|
||||
this.verified.emit(result);
|
||||
},
|
||||
error: (err) => {
|
||||
clearInterval(progressInterval);
|
||||
this.state.set('error');
|
||||
this.error.set(err.message || 'Verification failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state.set('idle');
|
||||
this.result.set(null);
|
||||
this.error.set(null);
|
||||
this.progress.set(0);
|
||||
}
|
||||
|
||||
toggleCliGuidance(): void {
|
||||
this.showCliGuidance.update((v) => !v);
|
||||
}
|
||||
|
||||
onSelectViolation(violation: AocViolationDetail): void {
|
||||
this.selectViolation.emit(violation);
|
||||
}
|
||||
|
||||
copyCommand(command: string): void {
|
||||
navigator.clipboard.writeText(command);
|
||||
}
|
||||
|
||||
getCliCommand(): string {
|
||||
return `stella aoc verify --tenant ${this.tenantId()} --since ${this.windowHours()}h`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,182 +1,182 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
AocViolationDetail,
|
||||
AocViolationGroup,
|
||||
AocDocumentView,
|
||||
AocProvenance,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
type ViewMode = 'by-violation' | 'by-document';
|
||||
|
||||
@Component({
|
||||
selector: 'app-violation-drilldown',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './violation-drilldown.component.html',
|
||||
styleUrls: ['./violation-drilldown.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ViolationDrilldownComponent {
|
||||
/** Violation groups to display */
|
||||
readonly violationGroups = input.required<AocViolationGroup[]>();
|
||||
|
||||
/** Document views for by-document mode */
|
||||
readonly documentViews = input<AocDocumentView[]>([]);
|
||||
|
||||
/** Emits when user clicks on a document */
|
||||
readonly selectDocument = output<string>();
|
||||
|
||||
/** Emits when user wants to view raw document */
|
||||
readonly viewRawDocument = output<string>();
|
||||
|
||||
/** Current view mode */
|
||||
readonly viewMode = signal<ViewMode>('by-violation');
|
||||
|
||||
/** Currently expanded violation code */
|
||||
readonly expandedCode = signal<string | null>(null);
|
||||
|
||||
/** Currently expanded document ID */
|
||||
readonly expandedDocId = signal<string | null>(null);
|
||||
|
||||
/** Search filter */
|
||||
readonly searchFilter = signal('');
|
||||
|
||||
readonly filteredGroups = computed(() => {
|
||||
const filter = this.searchFilter().toLowerCase();
|
||||
if (!filter) return this.violationGroups();
|
||||
return this.violationGroups().filter(
|
||||
(g) =>
|
||||
g.code.toLowerCase().includes(filter) ||
|
||||
g.description.toLowerCase().includes(filter) ||
|
||||
g.violations.some(
|
||||
(v) =>
|
||||
v.documentId.toLowerCase().includes(filter) ||
|
||||
v.field?.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
readonly filteredDocuments = computed(() => {
|
||||
const filter = this.searchFilter().toLowerCase();
|
||||
if (!filter) return this.documentViews();
|
||||
return this.documentViews().filter(
|
||||
(d) =>
|
||||
d.documentId.toLowerCase().includes(filter) ||
|
||||
d.documentType.toLowerCase().includes(filter) ||
|
||||
d.violations.some(
|
||||
(v) =>
|
||||
v.violationCode.toLowerCase().includes(filter) ||
|
||||
v.field?.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
readonly totalViolations = computed(() =>
|
||||
this.violationGroups().reduce((sum, g) => sum + g.violations.length, 0)
|
||||
);
|
||||
|
||||
readonly totalDocuments = computed(() => {
|
||||
const docIds = new Set<string>();
|
||||
for (const group of this.violationGroups()) {
|
||||
for (const v of group.violations) {
|
||||
docIds.add(v.documentId);
|
||||
}
|
||||
}
|
||||
return docIds.size;
|
||||
});
|
||||
|
||||
readonly severityCounts = computed(() => {
|
||||
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
for (const group of this.violationGroups()) {
|
||||
counts[group.severity] += group.violations.length;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
toggleGroup(code: string): void {
|
||||
this.expandedCode.update((current) => (current === code ? null : code));
|
||||
}
|
||||
|
||||
toggleDocument(docId: string): void {
|
||||
this.expandedDocId.update((current) => (current === docId ? null : docId));
|
||||
}
|
||||
|
||||
onSearch(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchFilter.set(input.value);
|
||||
}
|
||||
|
||||
onSelectDocument(docId: string): void {
|
||||
this.selectDocument.emit(docId);
|
||||
}
|
||||
|
||||
onViewRaw(docId: string): void {
|
||||
this.viewRawDocument.emit(docId);
|
||||
}
|
||||
|
||||
getSeverityIcon(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return '!!';
|
||||
case 'high':
|
||||
return '!';
|
||||
case 'medium':
|
||||
return '~';
|
||||
default:
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
getSourceTypeIcon(sourceType?: string): string {
|
||||
switch (sourceType) {
|
||||
case 'registry':
|
||||
return '[R]';
|
||||
case 'git':
|
||||
return '[G]';
|
||||
case 'upload':
|
||||
return '[U]';
|
||||
case 'api':
|
||||
return '[A]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
}
|
||||
|
||||
formatDigest(digest: string, length = 12): string {
|
||||
if (digest.length <= length) return digest;
|
||||
return digest.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
isFieldHighlighted(doc: AocDocumentView, field: string): boolean {
|
||||
return doc.highlightedFields.includes(field);
|
||||
}
|
||||
|
||||
getFieldValue(content: Record<string, unknown> | undefined, path: string): string {
|
||||
if (!content) return 'N/A';
|
||||
const parts = path.split('.');
|
||||
let current: unknown = content;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return 'N/A';
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
if (current == null) return 'null';
|
||||
if (typeof current === 'object') return JSON.stringify(current);
|
||||
return String(current);
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
AocViolationDetail,
|
||||
AocViolationGroup,
|
||||
AocDocumentView,
|
||||
AocProvenance,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
type ViewMode = 'by-violation' | 'by-document';
|
||||
|
||||
@Component({
|
||||
selector: 'app-violation-drilldown',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './violation-drilldown.component.html',
|
||||
styleUrls: ['./violation-drilldown.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ViolationDrilldownComponent {
|
||||
/** Violation groups to display */
|
||||
readonly violationGroups = input.required<AocViolationGroup[]>();
|
||||
|
||||
/** Document views for by-document mode */
|
||||
readonly documentViews = input<AocDocumentView[]>([]);
|
||||
|
||||
/** Emits when user clicks on a document */
|
||||
readonly selectDocument = output<string>();
|
||||
|
||||
/** Emits when user wants to view raw document */
|
||||
readonly viewRawDocument = output<string>();
|
||||
|
||||
/** Current view mode */
|
||||
readonly viewMode = signal<ViewMode>('by-violation');
|
||||
|
||||
/** Currently expanded violation code */
|
||||
readonly expandedCode = signal<string | null>(null);
|
||||
|
||||
/** Currently expanded document ID */
|
||||
readonly expandedDocId = signal<string | null>(null);
|
||||
|
||||
/** Search filter */
|
||||
readonly searchFilter = signal('');
|
||||
|
||||
readonly filteredGroups = computed(() => {
|
||||
const filter = this.searchFilter().toLowerCase();
|
||||
if (!filter) return this.violationGroups();
|
||||
return this.violationGroups().filter(
|
||||
(g) =>
|
||||
g.code.toLowerCase().includes(filter) ||
|
||||
g.description.toLowerCase().includes(filter) ||
|
||||
g.violations.some(
|
||||
(v) =>
|
||||
v.documentId.toLowerCase().includes(filter) ||
|
||||
v.field?.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
readonly filteredDocuments = computed(() => {
|
||||
const filter = this.searchFilter().toLowerCase();
|
||||
if (!filter) return this.documentViews();
|
||||
return this.documentViews().filter(
|
||||
(d) =>
|
||||
d.documentId.toLowerCase().includes(filter) ||
|
||||
d.documentType.toLowerCase().includes(filter) ||
|
||||
d.violations.some(
|
||||
(v) =>
|
||||
v.violationCode.toLowerCase().includes(filter) ||
|
||||
v.field?.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
readonly totalViolations = computed(() =>
|
||||
this.violationGroups().reduce((sum, g) => sum + g.violations.length, 0)
|
||||
);
|
||||
|
||||
readonly totalDocuments = computed(() => {
|
||||
const docIds = new Set<string>();
|
||||
for (const group of this.violationGroups()) {
|
||||
for (const v of group.violations) {
|
||||
docIds.add(v.documentId);
|
||||
}
|
||||
}
|
||||
return docIds.size;
|
||||
});
|
||||
|
||||
readonly severityCounts = computed(() => {
|
||||
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
for (const group of this.violationGroups()) {
|
||||
counts[group.severity] += group.violations.length;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
toggleGroup(code: string): void {
|
||||
this.expandedCode.update((current) => (current === code ? null : code));
|
||||
}
|
||||
|
||||
toggleDocument(docId: string): void {
|
||||
this.expandedDocId.update((current) => (current === docId ? null : docId));
|
||||
}
|
||||
|
||||
onSearch(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchFilter.set(input.value);
|
||||
}
|
||||
|
||||
onSelectDocument(docId: string): void {
|
||||
this.selectDocument.emit(docId);
|
||||
}
|
||||
|
||||
onViewRaw(docId: string): void {
|
||||
this.viewRawDocument.emit(docId);
|
||||
}
|
||||
|
||||
getSeverityIcon(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return '!!';
|
||||
case 'high':
|
||||
return '!';
|
||||
case 'medium':
|
||||
return '~';
|
||||
default:
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
getSourceTypeIcon(sourceType?: string): string {
|
||||
switch (sourceType) {
|
||||
case 'registry':
|
||||
return '[R]';
|
||||
case 'git':
|
||||
return '[G]';
|
||||
case 'upload':
|
||||
return '[U]';
|
||||
case 'api':
|
||||
return '[A]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
}
|
||||
|
||||
formatDigest(digest: string, length = 12): string {
|
||||
if (digest.length <= length) return digest;
|
||||
return digest.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
isFieldHighlighted(doc: AocDocumentView, field: string): boolean {
|
||||
return doc.highlightedFields.includes(field);
|
||||
}
|
||||
|
||||
getFieldValue(content: Record<string, unknown> | undefined, path: string): string {
|
||||
if (!content) return 'N/A';
|
||||
const parts = path.split('.');
|
||||
let current: unknown = content;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return 'N/A';
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
if (current == null) return 'null';
|
||||
if (typeof current === 'object') return JSON.stringify(current);
|
||||
return String(current);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
|
||||
.stat-card.authority { border-left: 4px solid #8b5cf6; }
|
||||
.stat-card.vex { border-left: 4px solid #10b981; }
|
||||
.stat-card.integrations { border-left: 4px solid #f59e0b; }
|
||||
.stat-card.orchestrator { border-left: 4px solid #6366f1; }
|
||||
.stat-card.orchestrator { border-left: 4px solid #D4920A; }
|
||||
.anomaly-alerts { margin-bottom: 2rem; }
|
||||
.anomaly-alerts h2 { margin: 0 0 1rem; font-size: 1.1rem; }
|
||||
.alert-list { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth-callback',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<section class="auth-callback">
|
||||
<p *ngIf="state() === 'processing'">Completing sign-in…</p>
|
||||
<p *ngIf="state() === 'error'" class="error">
|
||||
We were unable to complete the sign-in flow. Please try again.
|
||||
</p>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.auth-callback {
|
||||
margin: 4rem auto;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AuthCallbackComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly auth = inject(AuthorityAuthService);
|
||||
|
||||
readonly state = signal<'processing' | 'error'>('processing');
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const params = this.route.snapshot.queryParamMap;
|
||||
const searchParams = new URLSearchParams();
|
||||
params.keys.forEach((key) => {
|
||||
const value = params.get(key);
|
||||
if (value != null) {
|
||||
searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.auth.completeLoginFromRedirect(searchParams);
|
||||
const returnUrl = result.returnUrl ?? '/';
|
||||
await this.router.navigateByUrl(returnUrl, { replaceUrl: true });
|
||||
} catch {
|
||||
this.state.set('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth-callback',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<section class="auth-callback">
|
||||
<p *ngIf="state() === 'processing'">Completing sign-in…</p>
|
||||
<p *ngIf="state() === 'error'" class="error">
|
||||
We were unable to complete the sign-in flow. Please try again.
|
||||
</p>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.auth-callback {
|
||||
margin: 4rem auto;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AuthCallbackComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly auth = inject(AuthorityAuthService);
|
||||
|
||||
readonly state = signal<'processing' | 'error'>('processing');
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const params = this.route.snapshot.queryParamMap;
|
||||
const searchParams = new URLSearchParams();
|
||||
params.keys.forEach((key) => {
|
||||
const value = params.get(key);
|
||||
if (value != null) {
|
||||
searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.auth.completeLoginFromRedirect(searchParams);
|
||||
const returnUrl = result.returnUrl ?? '/';
|
||||
await this.router.navigateByUrl(returnUrl, { replaceUrl: true });
|
||||
} catch {
|
||||
this.state.set('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,226 +1,226 @@
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.console-profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.console-profile__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.console-profile__subtitle {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
|
||||
box-shadow var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
background: var(--color-brand-primary-hover);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.75;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.console-profile__loading {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-brand-light);
|
||||
color: var(--color-brand-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.console-profile__error {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
border: 1px solid var(--color-status-error);
|
||||
}
|
||||
|
||||
.console-profile__card {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-4);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chip,
|
||||
.tenant-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background-color: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.chip--active {
|
||||
background-color: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.chip--inactive {
|
||||
background-color: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.tenant-chip {
|
||||
background-color: var(--color-brand-light);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.tenant-count {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tenant-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: var(--space-2-5);
|
||||
}
|
||||
|
||||
.tenant-list__item--active button {
|
||||
border-color: var(--color-brand-primary);
|
||||
background-color: var(--color-brand-light);
|
||||
}
|
||||
|
||||
.tenant-list button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
box-shadow var(--motion-duration-fast) var(--motion-ease-default),
|
||||
transform var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-list__heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tenant-meta {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.fresh-auth {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.fresh-auth--active {
|
||||
background-color: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.fresh-auth--stale {
|
||||
background-color: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
.console-profile__empty {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.console-profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.console-profile__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.console-profile__subtitle {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
|
||||
box-shadow var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
background: var(--color-brand-primary-hover);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.75;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.console-profile__loading {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-brand-light);
|
||||
color: var(--color-brand-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.console-profile__error {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
border: 1px solid var(--color-status-error);
|
||||
}
|
||||
|
||||
.console-profile__card {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-4);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chip,
|
||||
.tenant-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background-color: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.chip--active {
|
||||
background-color: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.chip--inactive {
|
||||
background-color: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.tenant-chip {
|
||||
background-color: var(--color-brand-light);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.tenant-count {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tenant-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: var(--space-2-5);
|
||||
}
|
||||
|
||||
.tenant-list__item--active button {
|
||||
border-color: var(--color-brand-primary);
|
||||
background-color: var(--color-brand-light);
|
||||
}
|
||||
|
||||
.tenant-list button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
box-shadow var(--motion-duration-fast) var(--motion-ease-default),
|
||||
transform var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-list__heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tenant-meta {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.fresh-auth {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.fresh-auth--active {
|
||||
background-color: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.fresh-auth--stale {
|
||||
background-color: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
.console-profile__empty {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -1,110 +1,110 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||
import { ConsoleProfileComponent } from './console-profile.component';
|
||||
|
||||
class MockConsoleSessionService {
|
||||
loadConsoleContext = jasmine
|
||||
.createSpy('loadConsoleContext')
|
||||
.and.returnValue(Promise.resolve());
|
||||
refresh = jasmine
|
||||
.createSpy('refresh')
|
||||
.and.returnValue(Promise.resolve());
|
||||
switchTenant = jasmine
|
||||
.createSpy('switchTenant')
|
||||
.and.returnValue(Promise.resolve());
|
||||
}
|
||||
|
||||
describe('ConsoleProfileComponent', () => {
|
||||
let fixture: ComponentFixture<ConsoleProfileComponent>;
|
||||
let service: MockConsoleSessionService;
|
||||
let store: ConsoleSessionStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ConsoleProfileComponent],
|
||||
providers: [
|
||||
ConsoleSessionStore,
|
||||
{ provide: ConsoleSessionService, useClass: MockConsoleSessionService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
service = TestBed.inject(
|
||||
ConsoleSessionService
|
||||
) as unknown as MockConsoleSessionService;
|
||||
store = TestBed.inject(ConsoleSessionStore);
|
||||
fixture = TestBed.createComponent(ConsoleProfileComponent);
|
||||
});
|
||||
|
||||
it('renders profile and tenant information', async () => {
|
||||
store.setContext({
|
||||
tenants: [
|
||||
{
|
||||
id: 'tenant-default',
|
||||
displayName: 'Tenant Default',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.console'],
|
||||
},
|
||||
],
|
||||
selectedTenantId: 'tenant-default',
|
||||
profile: {
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'Console User',
|
||||
tenant: 'tenant-default',
|
||||
sessionId: 'session-1',
|
||||
roles: ['role.console'],
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuth: true,
|
||||
},
|
||||
token: {
|
||||
active: true,
|
||||
tenant: 'tenant-default',
|
||||
subject: 'user-1',
|
||||
clientId: 'console-web',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain(
|
||||
'Console Session'
|
||||
);
|
||||
expect(compiled.querySelector('.tenant-name')?.textContent).toContain(
|
||||
'Tenant Default'
|
||||
);
|
||||
expect(compiled.querySelector('dd')?.textContent).toContain('Console User');
|
||||
expect(service.loadConsoleContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invokes refresh on demand', async () => {
|
||||
store.clear();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const button = fixture.nativeElement.querySelector(
|
||||
'button[type="button"]'
|
||||
) as HTMLButtonElement;
|
||||
button.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(service.refresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||
import { ConsoleProfileComponent } from './console-profile.component';
|
||||
|
||||
class MockConsoleSessionService {
|
||||
loadConsoleContext = jasmine
|
||||
.createSpy('loadConsoleContext')
|
||||
.and.returnValue(Promise.resolve());
|
||||
refresh = jasmine
|
||||
.createSpy('refresh')
|
||||
.and.returnValue(Promise.resolve());
|
||||
switchTenant = jasmine
|
||||
.createSpy('switchTenant')
|
||||
.and.returnValue(Promise.resolve());
|
||||
}
|
||||
|
||||
describe('ConsoleProfileComponent', () => {
|
||||
let fixture: ComponentFixture<ConsoleProfileComponent>;
|
||||
let service: MockConsoleSessionService;
|
||||
let store: ConsoleSessionStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ConsoleProfileComponent],
|
||||
providers: [
|
||||
ConsoleSessionStore,
|
||||
{ provide: ConsoleSessionService, useClass: MockConsoleSessionService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
service = TestBed.inject(
|
||||
ConsoleSessionService
|
||||
) as unknown as MockConsoleSessionService;
|
||||
store = TestBed.inject(ConsoleSessionStore);
|
||||
fixture = TestBed.createComponent(ConsoleProfileComponent);
|
||||
});
|
||||
|
||||
it('renders profile and tenant information', async () => {
|
||||
store.setContext({
|
||||
tenants: [
|
||||
{
|
||||
id: 'tenant-default',
|
||||
displayName: 'Tenant Default',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.console'],
|
||||
},
|
||||
],
|
||||
selectedTenantId: 'tenant-default',
|
||||
profile: {
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'Console User',
|
||||
tenant: 'tenant-default',
|
||||
sessionId: 'session-1',
|
||||
roles: ['role.console'],
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuth: true,
|
||||
},
|
||||
token: {
|
||||
active: true,
|
||||
tenant: 'tenant-default',
|
||||
subject: 'user-1',
|
||||
clientId: 'console-web',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain(
|
||||
'Console Session'
|
||||
);
|
||||
expect(compiled.querySelector('.tenant-name')?.textContent).toContain(
|
||||
'Tenant Default'
|
||||
);
|
||||
expect(compiled.querySelector('dd')?.textContent).toContain('Console User');
|
||||
expect(service.loadConsoleContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invokes refresh on demand', async () => {
|
||||
store.clear();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const button = fixture.nativeElement.querySelector(
|
||||
'button[type="button"]'
|
||||
) as HTMLButtonElement;
|
||||
button.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(service.refresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
|
||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-console-profile',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './console-profile.component.html',
|
||||
styleUrls: ['./console-profile.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ConsoleProfileComponent implements OnInit {
|
||||
private readonly store = inject(ConsoleSessionStore);
|
||||
private readonly service = inject(ConsoleSessionService);
|
||||
|
||||
readonly loading = this.store.loading;
|
||||
readonly error = this.store.error;
|
||||
readonly profile = this.store.profile;
|
||||
readonly tokenInfo = this.store.tokenInfo;
|
||||
readonly tenants = this.store.tenants;
|
||||
readonly selectedTenantId = this.store.selectedTenantId;
|
||||
|
||||
readonly hasProfile = computed(() => this.profile() !== null);
|
||||
readonly tenantCount = computed(() => this.tenants().length);
|
||||
readonly freshAuthState = computed(() => {
|
||||
const token = this.tokenInfo();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
active: token.freshAuthActive,
|
||||
expiresAt: token.freshAuthExpiresAt,
|
||||
};
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
if (!this.store.hasContext()) {
|
||||
try {
|
||||
await this.service.loadConsoleContext();
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
await this.service.refresh();
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
|
||||
async selectTenant(tenantId: string): Promise<void> {
|
||||
try {
|
||||
await this.service.switchTenant(tenantId);
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
|
||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-console-profile',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './console-profile.component.html',
|
||||
styleUrls: ['./console-profile.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ConsoleProfileComponent implements OnInit {
|
||||
private readonly store = inject(ConsoleSessionStore);
|
||||
private readonly service = inject(ConsoleSessionService);
|
||||
|
||||
readonly loading = this.store.loading;
|
||||
readonly error = this.store.error;
|
||||
readonly profile = this.store.profile;
|
||||
readonly tokenInfo = this.store.tokenInfo;
|
||||
readonly tenants = this.store.tenants;
|
||||
readonly selectedTenantId = this.store.selectedTenantId;
|
||||
|
||||
readonly hasProfile = computed(() => this.profile() !== null);
|
||||
readonly tenantCount = computed(() => this.tenants().length);
|
||||
readonly freshAuthState = computed(() => {
|
||||
const token = this.tokenInfo();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
active: token.freshAuthActive,
|
||||
expiresAt: token.freshAuthExpiresAt,
|
||||
};
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
if (!this.store.hasContext()) {
|
||||
try {
|
||||
await this.service.loadConsoleContext();
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
await this.service.refresh();
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
|
||||
async selectTenant(tenantId: string): Promise<void> {
|
||||
try {
|
||||
await this.service.switchTenant(tenantId);
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ export interface DashboardAiData {
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background: #4f46e5;
|
||||
background: #F5A623;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
@@ -293,7 +293,7 @@ export interface DashboardAiData {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
@@ -302,7 +302,7 @@ export interface DashboardAiData {
|
||||
.ai-risk-drivers__evidence-link:hover,
|
||||
.ai-risk-drivers__action:hover {
|
||||
background: #eef2ff;
|
||||
border-color: #a5b4fc;
|
||||
border-color: #FFCF70;
|
||||
}
|
||||
|
||||
.ai-risk-drivers__empty {
|
||||
|
||||
@@ -1,350 +1,350 @@
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.sources-dashboard {
|
||||
padding: var(--space-6);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-6);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-base);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&-primary {
|
||||
background-color: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-brand-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&-secondary {
|
||||
background-color: transparent;
|
||||
border-color: var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-surface-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-12);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--color-border-primary);
|
||||
border-top-color: var(--color-brand-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--color-status-error);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.tile {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
overflow: hidden;
|
||||
|
||||
&-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin: 0;
|
||||
background: var(--color-surface-secondary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&-content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.tile-pass-fail {
|
||||
&.excellent .metric-large .value { color: var(--color-status-success); }
|
||||
&.good .metric-large .value { color: var(--color-status-success); }
|
||||
&.warning .metric-large .value { color: var(--color-status-warning); }
|
||||
&.critical .metric-large .value { color: var(--color-status-error); }
|
||||
}
|
||||
|
||||
.metric-large {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
.value {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: 1;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-details {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
&.pass { color: var(--color-status-success); }
|
||||
&.fail { color: var(--color-status-error); }
|
||||
&.total { color: var(--color-text-primary); }
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.violations-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.violation-item {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: var(--space-2);
|
||||
background: var(--color-surface-secondary);
|
||||
|
||||
&.severity-critical { border-left: 3px solid var(--color-severity-critical); }
|
||||
&.severity-high { border-left: 3px solid var(--color-severity-high); }
|
||||
&.severity-medium { border-left: 3px solid var(--color-severity-medium); }
|
||||
&.severity-low { border-left: 3px solid var(--color-severity-low); }
|
||||
}
|
||||
|
||||
.violation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.violation-code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.violation-count {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.violation-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.violation-time {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.throughput-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.throughput-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.value {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.tile-throughput {
|
||||
&.critical .throughput-item .value { color: var(--color-status-error); }
|
||||
&.warning .throughput-item .value { color: var(--color-status-warning); }
|
||||
}
|
||||
|
||||
.verification-result {
|
||||
margin-top: var(--space-6);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
&.status-passed { background: var(--color-status-success-bg); border-color: var(--color-status-success); }
|
||||
&.status-failed { background: var(--color-status-error-bg); border-color: var(--color-status-error); }
|
||||
&.status-partial { background: var(--color-status-warning-bg); border-color: var(--color-status-warning); }
|
||||
|
||||
h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&.status-passed .status-badge { background: var(--color-status-success); color: var(--color-text-inverse); }
|
||||
&.status-failed .status-badge { background: var(--color-status-error); color: var(--color-text-inverse); }
|
||||
&.status-partial .status-badge { background: var(--color-status-warning); color: var(--color-text-inverse); }
|
||||
}
|
||||
|
||||
.violations-details {
|
||||
margin: var(--space-3) 0;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-brand-primary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.violation-list {
|
||||
margin-top: var(--space-2);
|
||||
padding-left: var(--space-5);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
li {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cli-hint {
|
||||
margin: var(--space-3) 0 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
code {
|
||||
background: var(--color-surface-tertiary);
|
||||
padding: var(--space-0-5) var(--space-1-5);
|
||||
border-radius: var(--radius-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.time-window {
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@include screen-below-md {
|
||||
.sources-dashboard {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.sources-dashboard {
|
||||
padding: var(--space-6);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-6);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-base);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&-primary {
|
||||
background-color: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-brand-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&-secondary {
|
||||
background-color: transparent;
|
||||
border-color: var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-surface-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-12);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--color-border-primary);
|
||||
border-top-color: var(--color-brand-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--color-status-error);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.tile {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
overflow: hidden;
|
||||
|
||||
&-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin: 0;
|
||||
background: var(--color-surface-secondary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&-content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.tile-pass-fail {
|
||||
&.excellent .metric-large .value { color: var(--color-status-success); }
|
||||
&.good .metric-large .value { color: var(--color-status-success); }
|
||||
&.warning .metric-large .value { color: var(--color-status-warning); }
|
||||
&.critical .metric-large .value { color: var(--color-status-error); }
|
||||
}
|
||||
|
||||
.metric-large {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
.value {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: 1;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-details {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
&.pass { color: var(--color-status-success); }
|
||||
&.fail { color: var(--color-status-error); }
|
||||
&.total { color: var(--color-text-primary); }
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.violations-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.violation-item {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: var(--space-2);
|
||||
background: var(--color-surface-secondary);
|
||||
|
||||
&.severity-critical { border-left: 3px solid var(--color-severity-critical); }
|
||||
&.severity-high { border-left: 3px solid var(--color-severity-high); }
|
||||
&.severity-medium { border-left: 3px solid var(--color-severity-medium); }
|
||||
&.severity-low { border-left: 3px solid var(--color-severity-low); }
|
||||
}
|
||||
|
||||
.violation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.violation-code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.violation-count {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.violation-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.violation-time {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.throughput-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.throughput-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.value {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.tile-throughput {
|
||||
&.critical .throughput-item .value { color: var(--color-status-error); }
|
||||
&.warning .throughput-item .value { color: var(--color-status-warning); }
|
||||
}
|
||||
|
||||
.verification-result {
|
||||
margin-top: var(--space-6);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
&.status-passed { background: var(--color-status-success-bg); border-color: var(--color-status-success); }
|
||||
&.status-failed { background: var(--color-status-error-bg); border-color: var(--color-status-error); }
|
||||
&.status-partial { background: var(--color-status-warning-bg); border-color: var(--color-status-warning); }
|
||||
|
||||
h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&.status-passed .status-badge { background: var(--color-status-success); color: var(--color-text-inverse); }
|
||||
&.status-failed .status-badge { background: var(--color-status-error); color: var(--color-text-inverse); }
|
||||
&.status-partial .status-badge { background: var(--color-status-warning); color: var(--color-text-inverse); }
|
||||
}
|
||||
|
||||
.violations-details {
|
||||
margin: var(--space-3) 0;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-brand-primary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.violation-list {
|
||||
margin-top: var(--space-2);
|
||||
padding-left: var(--space-5);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
li {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cli-hint {
|
||||
margin: var(--space-3) 0 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
code {
|
||||
background: var(--color-surface-tertiary);
|
||||
padding: var(--space-0-5) var(--space-1-5);
|
||||
border-radius: var(--radius-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.time-window {
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@include screen-below-md {
|
||||
.sources-dashboard {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +1,111 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { AocClient } from '../../core/api/aoc.client';
|
||||
import {
|
||||
AocMetrics,
|
||||
AocViolationSummary,
|
||||
AocVerificationResult,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sources-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './sources-dashboard.component.html',
|
||||
styleUrls: ['./sources-dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SourcesDashboardComponent implements OnInit {
|
||||
private readonly aocClient = inject(AocClient);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly metrics = signal<AocMetrics | null>(null);
|
||||
readonly verifying = signal(false);
|
||||
readonly verificationResult = signal<AocVerificationResult | null>(null);
|
||||
|
||||
readonly passRate = computed(() => {
|
||||
const m = this.metrics();
|
||||
return m ? m.passRate.toFixed(2) : '0.00';
|
||||
});
|
||||
|
||||
readonly passRateClass = computed(() => {
|
||||
const m = this.metrics();
|
||||
if (!m) return 'neutral';
|
||||
if (m.passRate >= 99.5) return 'excellent';
|
||||
if (m.passRate >= 95) return 'good';
|
||||
if (m.passRate >= 90) return 'warning';
|
||||
return 'critical';
|
||||
});
|
||||
|
||||
readonly throughputStatus = computed(() => {
|
||||
const m = this.metrics();
|
||||
if (!m) return 'neutral';
|
||||
if (m.ingestThroughput.queueDepth > 100) return 'critical';
|
||||
if (m.ingestThroughput.queueDepth > 50) return 'warning';
|
||||
return 'good';
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMetrics();
|
||||
}
|
||||
|
||||
loadMetrics(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.aocClient.getMetrics('default').subscribe({
|
||||
next: (metrics) => {
|
||||
this.metrics.set(metrics);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load AOC metrics');
|
||||
this.loading.set(false);
|
||||
console.error('AOC metrics error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onVerifyLast24h(): void {
|
||||
this.verifying.set(true);
|
||||
this.verificationResult.set(null);
|
||||
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
this.aocClient.verify({ tenantId: 'default', since }).subscribe({
|
||||
next: (result) => {
|
||||
this.verificationResult.set(result);
|
||||
this.verifying.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.verifying.set(false);
|
||||
console.error('AOC verification error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getSeverityClass(severity: AocViolationSummary['severity']): string {
|
||||
return 'severity-' + severity;
|
||||
}
|
||||
|
||||
formatRelativeTime(isoDate: string): string {
|
||||
const date = new Date(isoDate);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return diffMins + 'm ago';
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return diffHours + 'h ago';
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return diffDays + 'd ago';
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { AocClient } from '../../core/api/aoc.client';
|
||||
import {
|
||||
AocMetrics,
|
||||
AocViolationSummary,
|
||||
AocVerificationResult,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sources-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './sources-dashboard.component.html',
|
||||
styleUrls: ['./sources-dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SourcesDashboardComponent implements OnInit {
|
||||
private readonly aocClient = inject(AocClient);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly metrics = signal<AocMetrics | null>(null);
|
||||
readonly verifying = signal(false);
|
||||
readonly verificationResult = signal<AocVerificationResult | null>(null);
|
||||
|
||||
readonly passRate = computed(() => {
|
||||
const m = this.metrics();
|
||||
return m ? m.passRate.toFixed(2) : '0.00';
|
||||
});
|
||||
|
||||
readonly passRateClass = computed(() => {
|
||||
const m = this.metrics();
|
||||
if (!m) return 'neutral';
|
||||
if (m.passRate >= 99.5) return 'excellent';
|
||||
if (m.passRate >= 95) return 'good';
|
||||
if (m.passRate >= 90) return 'warning';
|
||||
return 'critical';
|
||||
});
|
||||
|
||||
readonly throughputStatus = computed(() => {
|
||||
const m = this.metrics();
|
||||
if (!m) return 'neutral';
|
||||
if (m.ingestThroughput.queueDepth > 100) return 'critical';
|
||||
if (m.ingestThroughput.queueDepth > 50) return 'warning';
|
||||
return 'good';
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMetrics();
|
||||
}
|
||||
|
||||
loadMetrics(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.aocClient.getMetrics('default').subscribe({
|
||||
next: (metrics) => {
|
||||
this.metrics.set(metrics);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load AOC metrics');
|
||||
this.loading.set(false);
|
||||
console.error('AOC metrics error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onVerifyLast24h(): void {
|
||||
this.verifying.set(true);
|
||||
this.verificationResult.set(null);
|
||||
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
this.aocClient.verify({ tenantId: 'default', since }).subscribe({
|
||||
next: (result) => {
|
||||
this.verificationResult.set(result);
|
||||
this.verifying.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.verifying.set(false);
|
||||
console.error('AOC verification error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getSeverityClass(severity: AocViolationSummary['severity']): string {
|
||||
return 'severity-' + severity;
|
||||
}
|
||||
|
||||
formatRelativeTime(isoDate: string): string {
|
||||
const date = new Date(isoDate);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return diffMins + 'm ago';
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return diffHours + 'h ago';
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return diffDays + 'd ago';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,200 +1,200 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { EvidenceData } from '../../core/api/evidence.models';
|
||||
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
|
||||
import { EvidencePanelComponent } from './evidence-panel.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-evidence-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, EvidencePanelComponent],
|
||||
providers: [
|
||||
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
|
||||
],
|
||||
template: `
|
||||
<div class="evidence-page">
|
||||
@if (loading()) {
|
||||
<div class="evidence-page__loading">
|
||||
<div class="spinner" aria-label="Loading evidence"></div>
|
||||
<p>Loading evidence for {{ advisoryId() }}...</p>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="evidence-page__error" role="alert">
|
||||
<h2>Error Loading Evidence</h2>
|
||||
<p>{{ error() }}</p>
|
||||
<button type="button" (click)="reload()">Retry</button>
|
||||
</div>
|
||||
} @else if (evidenceData()) {
|
||||
<app-evidence-panel
|
||||
[advisoryId]="advisoryId()"
|
||||
[evidenceData]="evidenceData()"
|
||||
(close)="onClose()"
|
||||
(downloadDocument)="onDownload($event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="evidence-page__empty">
|
||||
<h2>No Advisory ID</h2>
|
||||
<p>Please provide an advisory ID to view evidence.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.evidence-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.evidence-page__loading,
|
||||
.evidence-page__error,
|
||||
.evidence-page__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.evidence-page__loading .spinner {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.evidence-page__loading p {
|
||||
margin-top: 1rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.evidence-page__error {
|
||||
border: 1px solid #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.evidence-page__error h2 {
|
||||
color: #dc2626;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__error p {
|
||||
color: #991b1b;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.evidence-page__error button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #dc2626;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.evidence-page__error button:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.evidence-page__empty h2 {
|
||||
color: #374151;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__empty p {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EvidencePageComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly evidenceApi = inject(EVIDENCE_API);
|
||||
|
||||
readonly advisoryId = signal<string>('');
|
||||
readonly evidenceData = signal<EvidenceData | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
// React to route param changes
|
||||
effect(() => {
|
||||
const params = this.route.snapshot.paramMap;
|
||||
const id = params.get('advisoryId');
|
||||
if (id) {
|
||||
this.advisoryId.set(id);
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
private loadEvidence(advisoryId: string): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
|
||||
next: (data) => {
|
||||
this.evidenceData.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Failed to load evidence');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
const id = this.advisoryId();
|
||||
if (id) {
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.router.navigate(['/vulnerabilities']);
|
||||
}
|
||||
|
||||
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
|
||||
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${event.type}-${event.id}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Download failed:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { EvidenceData } from '../../core/api/evidence.models';
|
||||
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
|
||||
import { EvidencePanelComponent } from './evidence-panel.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-evidence-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, EvidencePanelComponent],
|
||||
providers: [
|
||||
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
|
||||
],
|
||||
template: `
|
||||
<div class="evidence-page">
|
||||
@if (loading()) {
|
||||
<div class="evidence-page__loading">
|
||||
<div class="spinner" aria-label="Loading evidence"></div>
|
||||
<p>Loading evidence for {{ advisoryId() }}...</p>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="evidence-page__error" role="alert">
|
||||
<h2>Error Loading Evidence</h2>
|
||||
<p>{{ error() }}</p>
|
||||
<button type="button" (click)="reload()">Retry</button>
|
||||
</div>
|
||||
} @else if (evidenceData()) {
|
||||
<app-evidence-panel
|
||||
[advisoryId]="advisoryId()"
|
||||
[evidenceData]="evidenceData()"
|
||||
(close)="onClose()"
|
||||
(downloadDocument)="onDownload($event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="evidence-page__empty">
|
||||
<h2>No Advisory ID</h2>
|
||||
<p>Please provide an advisory ID to view evidence.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.evidence-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.evidence-page__loading,
|
||||
.evidence-page__error,
|
||||
.evidence-page__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.evidence-page__loading .spinner {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.evidence-page__loading p {
|
||||
margin-top: 1rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.evidence-page__error {
|
||||
border: 1px solid #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.evidence-page__error h2 {
|
||||
color: #dc2626;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__error p {
|
||||
color: #991b1b;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.evidence-page__error button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #dc2626;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.evidence-page__error button:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.evidence-page__empty h2 {
|
||||
color: #374151;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__empty p {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EvidencePageComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly evidenceApi = inject(EVIDENCE_API);
|
||||
|
||||
readonly advisoryId = signal<string>('');
|
||||
readonly evidenceData = signal<EvidenceData | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
// React to route param changes
|
||||
effect(() => {
|
||||
const params = this.route.snapshot.paramMap;
|
||||
const id = params.get('advisoryId');
|
||||
if (id) {
|
||||
this.advisoryId.set(id);
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
private loadEvidence(advisoryId: string): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
|
||||
next: (data) => {
|
||||
this.evidenceData.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Failed to load evidence');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
const id = this.advisoryId();
|
||||
if (id) {
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.router.navigate(['/vulnerabilities']);
|
||||
}
|
||||
|
||||
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
|
||||
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${event.type}-${event.id}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Download failed:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,2 @@
|
||||
export { EvidencePanelComponent } from './evidence-panel.component';
|
||||
export { EvidencePageComponent } from './evidence-page.component';
|
||||
export { EvidencePanelComponent } from './evidence-panel.component';
|
||||
export { EvidencePageComponent } from './evidence-page.component';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,278 +1,278 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Exception,
|
||||
ExceptionStatus,
|
||||
ExceptionType,
|
||||
ExceptionFilter,
|
||||
ExceptionSortOption,
|
||||
ExceptionTransition,
|
||||
EXCEPTION_TRANSITIONS,
|
||||
KANBAN_COLUMNS,
|
||||
} from '../../core/api/exception.models';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-center',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './exception-center.component.html',
|
||||
styleUrls: ['./exception-center.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ExceptionCenterComponent {
|
||||
/** All exceptions */
|
||||
readonly exceptions = input.required<Exception[]>();
|
||||
|
||||
/** Current user role for transition permissions */
|
||||
readonly userRole = input<string>('user');
|
||||
|
||||
/** Emits when creating new exception */
|
||||
readonly create = output<void>();
|
||||
|
||||
/** Emits when selecting an exception */
|
||||
readonly select = output<Exception>();
|
||||
|
||||
/** Emits when performing a workflow transition */
|
||||
readonly transition = output<{ exception: Exception; to: ExceptionStatus }>();
|
||||
|
||||
/** Emits when viewing audit log */
|
||||
readonly viewAudit = output<Exception>();
|
||||
|
||||
readonly viewMode = signal<ViewMode>('list');
|
||||
readonly filter = signal<ExceptionFilter>({});
|
||||
readonly sort = signal<ExceptionSortOption>({ field: 'updatedAt', direction: 'desc' });
|
||||
readonly expandedId = signal<string | null>(null);
|
||||
readonly showFilters = signal(false);
|
||||
|
||||
readonly kanbanColumns = KANBAN_COLUMNS;
|
||||
|
||||
readonly filteredExceptions = computed(() => {
|
||||
let result = [...this.exceptions()];
|
||||
const f = this.filter();
|
||||
|
||||
// Apply filters
|
||||
if (f.status && f.status.length > 0) {
|
||||
result = result.filter((e) => f.status!.includes(e.status));
|
||||
}
|
||||
if (f.type && f.type.length > 0) {
|
||||
result = result.filter((e) => f.type!.includes(e.type));
|
||||
}
|
||||
if (f.severity && f.severity.length > 0) {
|
||||
result = result.filter((e) => f.severity!.includes(e.severity));
|
||||
}
|
||||
if (f.search) {
|
||||
const search = f.search.toLowerCase();
|
||||
result = result.filter(
|
||||
(e) =>
|
||||
e.title.toLowerCase().includes(search) ||
|
||||
e.justification.toLowerCase().includes(search) ||
|
||||
e.id.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
if (f.tags && f.tags.length > 0) {
|
||||
result = result.filter((e) => f.tags!.some((t) => e.tags.includes(t)));
|
||||
}
|
||||
if (f.expiringSoon) {
|
||||
result = result.filter((e) => e.timebox.isWarning && !e.timebox.isExpired);
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
const s = this.sort();
|
||||
result.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (s.field) {
|
||||
case 'createdAt':
|
||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
break;
|
||||
case 'updatedAt':
|
||||
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
break;
|
||||
case 'expiresAt':
|
||||
cmp = new Date(a.timebox.expiresAt).getTime() - new Date(b.timebox.expiresAt).getTime();
|
||||
break;
|
||||
case 'severity':
|
||||
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
cmp = sevOrder[a.severity] - sevOrder[b.severity];
|
||||
break;
|
||||
case 'title':
|
||||
cmp = a.title.localeCompare(b.title);
|
||||
break;
|
||||
}
|
||||
return s.direction === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
readonly exceptionsByStatus = computed(() => {
|
||||
const byStatus = new Map<ExceptionStatus, Exception[]>();
|
||||
for (const col of KANBAN_COLUMNS) {
|
||||
byStatus.set(col.status, []);
|
||||
}
|
||||
for (const exc of this.filteredExceptions()) {
|
||||
const list = byStatus.get(exc.status) || [];
|
||||
list.push(exc);
|
||||
byStatus.set(exc.status, list);
|
||||
}
|
||||
return byStatus;
|
||||
});
|
||||
|
||||
readonly statusCounts = computed(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const exc of this.exceptions()) {
|
||||
counts[exc.status] = (counts[exc.status] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
readonly allTags = computed(() => {
|
||||
const tags = new Set<string>();
|
||||
for (const exc of this.exceptions()) {
|
||||
for (const tag of exc.tags) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
return Array.from(tags).sort();
|
||||
});
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
toggleFilters(): void {
|
||||
this.showFilters.update((v) => !v);
|
||||
}
|
||||
|
||||
toggleStatusFilter(status: ExceptionStatus): void {
|
||||
const current = this.filter().status || [];
|
||||
const newStatuses = current.includes(status)
|
||||
? current.filter((s) => s !== status)
|
||||
: [...current, status];
|
||||
this.updateFilter('status', newStatuses.length > 0 ? newStatuses : undefined);
|
||||
}
|
||||
|
||||
toggleTypeFilter(type: ExceptionType): void {
|
||||
const current = this.filter().type || [];
|
||||
const newTypes = current.includes(type)
|
||||
? current.filter((t) => t !== type)
|
||||
: [...current, type];
|
||||
this.updateFilter('type', newTypes.length > 0 ? newTypes : undefined);
|
||||
}
|
||||
|
||||
toggleSeverityFilter(severity: string): void {
|
||||
const current = this.filter().severity || [];
|
||||
const newSeverities = current.includes(severity)
|
||||
? current.filter((s) => s !== severity)
|
||||
: [...current, severity];
|
||||
this.updateFilter('severity', newSeverities.length > 0 ? newSeverities : undefined);
|
||||
}
|
||||
|
||||
toggleTagFilter(tag: string): void {
|
||||
const current = this.filter().tags || [];
|
||||
const newTags = current.includes(tag)
|
||||
? current.filter((t) => t !== tag)
|
||||
: [...current, tag];
|
||||
this.updateFilter('tags', newTags.length > 0 ? newTags : undefined);
|
||||
}
|
||||
|
||||
updateFilter(key: keyof ExceptionFilter, value: unknown): void {
|
||||
this.filter.update((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filter.set({});
|
||||
}
|
||||
|
||||
setSort(field: ExceptionSortOption['field']): void {
|
||||
this.sort.update((s) => ({
|
||||
field,
|
||||
direction: s.field === field && s.direction === 'desc' ? 'asc' : 'desc',
|
||||
}));
|
||||
}
|
||||
|
||||
toggleExpand(id: string): void {
|
||||
this.expandedId.update((current) => (current === id ? null : id));
|
||||
}
|
||||
|
||||
onCreate(): void {
|
||||
this.create.emit();
|
||||
}
|
||||
|
||||
onSelect(exc: Exception): void {
|
||||
this.select.emit(exc);
|
||||
}
|
||||
|
||||
onTransition(exc: Exception, to: ExceptionStatus): void {
|
||||
this.transition.emit({ exception: exc, to });
|
||||
}
|
||||
|
||||
onViewAudit(exc: Exception): void {
|
||||
this.viewAudit.emit(exc);
|
||||
}
|
||||
|
||||
getAvailableTransitions(exc: Exception): ExceptionTransition[] {
|
||||
return EXCEPTION_TRANSITIONS.filter(
|
||||
(t) => t.from === exc.status && t.allowedRoles.includes(this.userRole())
|
||||
);
|
||||
}
|
||||
|
||||
getStatusIcon(status: ExceptionStatus): string {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return '[D]';
|
||||
case 'pending_review':
|
||||
return '[?]';
|
||||
case 'approved':
|
||||
return '[+]';
|
||||
case 'rejected':
|
||||
return '[~]';
|
||||
case 'expired':
|
||||
return '[X]';
|
||||
case 'revoked':
|
||||
return '[!]';
|
||||
default:
|
||||
return '[-]';
|
||||
}
|
||||
}
|
||||
|
||||
getTypeIcon(type: ExceptionType): string {
|
||||
switch (type) {
|
||||
case 'vulnerability':
|
||||
return 'V';
|
||||
case 'license':
|
||||
return 'L';
|
||||
case 'policy':
|
||||
return 'P';
|
||||
case 'entropy':
|
||||
return 'E';
|
||||
case 'determinism':
|
||||
return 'D';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
getSeverityClass(severity: string): string {
|
||||
return 'severity-' + severity;
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatRemainingDays(days: number): string {
|
||||
if (days < 0) return 'Expired';
|
||||
if (days === 0) return 'Expires today';
|
||||
if (days === 1) return '1 day left';
|
||||
return days + ' days left';
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Exception,
|
||||
ExceptionStatus,
|
||||
ExceptionType,
|
||||
ExceptionFilter,
|
||||
ExceptionSortOption,
|
||||
ExceptionTransition,
|
||||
EXCEPTION_TRANSITIONS,
|
||||
KANBAN_COLUMNS,
|
||||
} from '../../core/api/exception.models';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-center',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './exception-center.component.html',
|
||||
styleUrls: ['./exception-center.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ExceptionCenterComponent {
|
||||
/** All exceptions */
|
||||
readonly exceptions = input.required<Exception[]>();
|
||||
|
||||
/** Current user role for transition permissions */
|
||||
readonly userRole = input<string>('user');
|
||||
|
||||
/** Emits when creating new exception */
|
||||
readonly create = output<void>();
|
||||
|
||||
/** Emits when selecting an exception */
|
||||
readonly select = output<Exception>();
|
||||
|
||||
/** Emits when performing a workflow transition */
|
||||
readonly transition = output<{ exception: Exception; to: ExceptionStatus }>();
|
||||
|
||||
/** Emits when viewing audit log */
|
||||
readonly viewAudit = output<Exception>();
|
||||
|
||||
readonly viewMode = signal<ViewMode>('list');
|
||||
readonly filter = signal<ExceptionFilter>({});
|
||||
readonly sort = signal<ExceptionSortOption>({ field: 'updatedAt', direction: 'desc' });
|
||||
readonly expandedId = signal<string | null>(null);
|
||||
readonly showFilters = signal(false);
|
||||
|
||||
readonly kanbanColumns = KANBAN_COLUMNS;
|
||||
|
||||
readonly filteredExceptions = computed(() => {
|
||||
let result = [...this.exceptions()];
|
||||
const f = this.filter();
|
||||
|
||||
// Apply filters
|
||||
if (f.status && f.status.length > 0) {
|
||||
result = result.filter((e) => f.status!.includes(e.status));
|
||||
}
|
||||
if (f.type && f.type.length > 0) {
|
||||
result = result.filter((e) => f.type!.includes(e.type));
|
||||
}
|
||||
if (f.severity && f.severity.length > 0) {
|
||||
result = result.filter((e) => f.severity!.includes(e.severity));
|
||||
}
|
||||
if (f.search) {
|
||||
const search = f.search.toLowerCase();
|
||||
result = result.filter(
|
||||
(e) =>
|
||||
e.title.toLowerCase().includes(search) ||
|
||||
e.justification.toLowerCase().includes(search) ||
|
||||
e.id.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
if (f.tags && f.tags.length > 0) {
|
||||
result = result.filter((e) => f.tags!.some((t) => e.tags.includes(t)));
|
||||
}
|
||||
if (f.expiringSoon) {
|
||||
result = result.filter((e) => e.timebox.isWarning && !e.timebox.isExpired);
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
const s = this.sort();
|
||||
result.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (s.field) {
|
||||
case 'createdAt':
|
||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
break;
|
||||
case 'updatedAt':
|
||||
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
break;
|
||||
case 'expiresAt':
|
||||
cmp = new Date(a.timebox.expiresAt).getTime() - new Date(b.timebox.expiresAt).getTime();
|
||||
break;
|
||||
case 'severity':
|
||||
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
cmp = sevOrder[a.severity] - sevOrder[b.severity];
|
||||
break;
|
||||
case 'title':
|
||||
cmp = a.title.localeCompare(b.title);
|
||||
break;
|
||||
}
|
||||
return s.direction === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
readonly exceptionsByStatus = computed(() => {
|
||||
const byStatus = new Map<ExceptionStatus, Exception[]>();
|
||||
for (const col of KANBAN_COLUMNS) {
|
||||
byStatus.set(col.status, []);
|
||||
}
|
||||
for (const exc of this.filteredExceptions()) {
|
||||
const list = byStatus.get(exc.status) || [];
|
||||
list.push(exc);
|
||||
byStatus.set(exc.status, list);
|
||||
}
|
||||
return byStatus;
|
||||
});
|
||||
|
||||
readonly statusCounts = computed(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const exc of this.exceptions()) {
|
||||
counts[exc.status] = (counts[exc.status] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
readonly allTags = computed(() => {
|
||||
const tags = new Set<string>();
|
||||
for (const exc of this.exceptions()) {
|
||||
for (const tag of exc.tags) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
return Array.from(tags).sort();
|
||||
});
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
toggleFilters(): void {
|
||||
this.showFilters.update((v) => !v);
|
||||
}
|
||||
|
||||
toggleStatusFilter(status: ExceptionStatus): void {
|
||||
const current = this.filter().status || [];
|
||||
const newStatuses = current.includes(status)
|
||||
? current.filter((s) => s !== status)
|
||||
: [...current, status];
|
||||
this.updateFilter('status', newStatuses.length > 0 ? newStatuses : undefined);
|
||||
}
|
||||
|
||||
toggleTypeFilter(type: ExceptionType): void {
|
||||
const current = this.filter().type || [];
|
||||
const newTypes = current.includes(type)
|
||||
? current.filter((t) => t !== type)
|
||||
: [...current, type];
|
||||
this.updateFilter('type', newTypes.length > 0 ? newTypes : undefined);
|
||||
}
|
||||
|
||||
toggleSeverityFilter(severity: string): void {
|
||||
const current = this.filter().severity || [];
|
||||
const newSeverities = current.includes(severity)
|
||||
? current.filter((s) => s !== severity)
|
||||
: [...current, severity];
|
||||
this.updateFilter('severity', newSeverities.length > 0 ? newSeverities : undefined);
|
||||
}
|
||||
|
||||
toggleTagFilter(tag: string): void {
|
||||
const current = this.filter().tags || [];
|
||||
const newTags = current.includes(tag)
|
||||
? current.filter((t) => t !== tag)
|
||||
: [...current, tag];
|
||||
this.updateFilter('tags', newTags.length > 0 ? newTags : undefined);
|
||||
}
|
||||
|
||||
updateFilter(key: keyof ExceptionFilter, value: unknown): void {
|
||||
this.filter.update((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filter.set({});
|
||||
}
|
||||
|
||||
setSort(field: ExceptionSortOption['field']): void {
|
||||
this.sort.update((s) => ({
|
||||
field,
|
||||
direction: s.field === field && s.direction === 'desc' ? 'asc' : 'desc',
|
||||
}));
|
||||
}
|
||||
|
||||
toggleExpand(id: string): void {
|
||||
this.expandedId.update((current) => (current === id ? null : id));
|
||||
}
|
||||
|
||||
onCreate(): void {
|
||||
this.create.emit();
|
||||
}
|
||||
|
||||
onSelect(exc: Exception): void {
|
||||
this.select.emit(exc);
|
||||
}
|
||||
|
||||
onTransition(exc: Exception, to: ExceptionStatus): void {
|
||||
this.transition.emit({ exception: exc, to });
|
||||
}
|
||||
|
||||
onViewAudit(exc: Exception): void {
|
||||
this.viewAudit.emit(exc);
|
||||
}
|
||||
|
||||
getAvailableTransitions(exc: Exception): ExceptionTransition[] {
|
||||
return EXCEPTION_TRANSITIONS.filter(
|
||||
(t) => t.from === exc.status && t.allowedRoles.includes(this.userRole())
|
||||
);
|
||||
}
|
||||
|
||||
getStatusIcon(status: ExceptionStatus): string {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return '[D]';
|
||||
case 'pending_review':
|
||||
return '[?]';
|
||||
case 'approved':
|
||||
return '[+]';
|
||||
case 'rejected':
|
||||
return '[~]';
|
||||
case 'expired':
|
||||
return '[X]';
|
||||
case 'revoked':
|
||||
return '[!]';
|
||||
default:
|
||||
return '[-]';
|
||||
}
|
||||
}
|
||||
|
||||
getTypeIcon(type: ExceptionType): string {
|
||||
switch (type) {
|
||||
case 'vulnerability':
|
||||
return 'V';
|
||||
case 'license':
|
||||
return 'L';
|
||||
case 'policy':
|
||||
return 'P';
|
||||
case 'entropy':
|
||||
return 'E';
|
||||
case 'determinism':
|
||||
return 'D';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
getSeverityClass(severity: string): string {
|
||||
return 'severity-' + severity;
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatRemainingDays(days: number): string {
|
||||
if (days < 0) return 'Expired';
|
||||
if (days === 0) return 'Expires today';
|
||||
if (days === 1) return '1 day left';
|
||||
return days + ' days left';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,441 +1,441 @@
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.draft-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
// Header
|
||||
.draft-inline__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border-secondary);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.draft-inline__source {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Error
|
||||
.draft-inline__error {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
border: 1px solid var(--color-status-error);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
// Scope
|
||||
.draft-inline__scope {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.draft-inline__scope-label {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.draft-inline__scope-value {
|
||||
color: var(--color-text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.draft-inline__scope-type {
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
background: var(--color-brand-light);
|
||||
color: var(--color-brand-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// Vulnerabilities preview
|
||||
.draft-inline__vulns,
|
||||
.draft-inline__components {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.draft-inline__vulns-label,
|
||||
.draft-inline__components-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.draft-inline__vulns-list,
|
||||
.draft-inline__components-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.vuln-chip {
|
||||
display: inline-flex;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
|
||||
&--more {
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.component-chip {
|
||||
display: inline-flex;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
background: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&--more {
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
// Form
|
||||
.draft-inline__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3-5);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
&--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-brand-light);
|
||||
}
|
||||
|
||||
&--small {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-brand-light);
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Severity chips
|
||||
.severity-chips {
|
||||
display: flex;
|
||||
gap: var(--space-1-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.severity-option {
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--selected .severity-chip {
|
||||
box-shadow: 0 0 0 2px currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.severity-chip {
|
||||
display: inline-flex;
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
transition: box-shadow var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&--critical {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-severity-critical);
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: var(--color-severity-high-bg);
|
||||
color: var(--color-severity-high);
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: var(--color-severity-medium-bg);
|
||||
color: var(--color-severity-medium);
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
}
|
||||
|
||||
// Template chips
|
||||
.template-chips {
|
||||
display: flex;
|
||||
gap: var(--space-1-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.template-chip {
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
transition: all var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-secondary);
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: var(--color-brand-light);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// Timebox
|
||||
.timebox-quick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.timebox-btn {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-tertiary);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.timebox-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Simulation
|
||||
.draft-inline__simulation {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
.simulation-toggle {
|
||||
padding: var(--space-1-5) var(--space-3);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-brand-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-brand-light);
|
||||
}
|
||||
}
|
||||
|
||||
.simulation-result {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.simulation-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-0-5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.simulation-stat__label {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.simulation-stat__value {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&--high {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
&--moderate {
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
&--low {
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
.draft-inline__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
// Buttons
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-brand-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
&--text {
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@include screen-below-sm {
|
||||
.simulation-result {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.draft-inline__footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.severity-chips,
|
||||
.template-chips {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.draft-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
// Header
|
||||
.draft-inline__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border-secondary);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.draft-inline__source {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Error
|
||||
.draft-inline__error {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
border: 1px solid var(--color-status-error);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
// Scope
|
||||
.draft-inline__scope {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.draft-inline__scope-label {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.draft-inline__scope-value {
|
||||
color: var(--color-text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.draft-inline__scope-type {
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
background: var(--color-brand-light);
|
||||
color: var(--color-brand-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// Vulnerabilities preview
|
||||
.draft-inline__vulns,
|
||||
.draft-inline__components {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.draft-inline__vulns-label,
|
||||
.draft-inline__components-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.draft-inline__vulns-list,
|
||||
.draft-inline__components-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.vuln-chip {
|
||||
display: inline-flex;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
|
||||
&--more {
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.component-chip {
|
||||
display: inline-flex;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
background: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&--more {
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
// Form
|
||||
.draft-inline__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3-5);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
&--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-brand-light);
|
||||
}
|
||||
|
||||
&--small {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-brand-light);
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Severity chips
|
||||
.severity-chips {
|
||||
display: flex;
|
||||
gap: var(--space-1-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.severity-option {
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--selected .severity-chip {
|
||||
box-shadow: 0 0 0 2px currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.severity-chip {
|
||||
display: inline-flex;
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
transition: box-shadow var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&--critical {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-severity-critical);
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: var(--color-severity-high-bg);
|
||||
color: var(--color-severity-high);
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: var(--color-severity-medium-bg);
|
||||
color: var(--color-severity-medium);
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
}
|
||||
|
||||
// Template chips
|
||||
.template-chips {
|
||||
display: flex;
|
||||
gap: var(--space-1-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.template-chip {
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
transition: all var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-secondary);
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: var(--color-brand-light);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// Timebox
|
||||
.timebox-quick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.timebox-btn {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-tertiary);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.timebox-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Simulation
|
||||
.draft-inline__simulation {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
.simulation-toggle {
|
||||
padding: var(--space-1-5) var(--space-3);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-brand-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-brand-light);
|
||||
}
|
||||
}
|
||||
|
||||
.simulation-result {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.simulation-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-0-5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.simulation-stat__label {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.simulation-stat__value {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&--high {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
&--moderate {
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
&--low {
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
.draft-inline__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
// Buttons
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-brand-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
&--text {
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@include screen-below-sm {
|
||||
.simulation-result {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.draft-inline__footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.severity-chips,
|
||||
.template-chips {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
|
||||
import {
|
||||
EXCEPTION_API,
|
||||
ExceptionApi,
|
||||
@@ -27,36 +27,36 @@ import {
|
||||
ExceptionSeverity,
|
||||
ExceptionScopeType,
|
||||
} from '../../core/api/exception.contract.models';
|
||||
|
||||
export interface ExceptionDraftContext {
|
||||
readonly vulnIds?: readonly string[];
|
||||
readonly componentPurls?: readonly string[];
|
||||
readonly assetIds?: readonly string[];
|
||||
readonly tenantId?: string;
|
||||
readonly suggestedName?: string;
|
||||
readonly suggestedSeverity?: ExceptionSeverity;
|
||||
readonly sourceType: 'vulnerability' | 'component' | 'asset' | 'graph';
|
||||
readonly sourceLabel: string;
|
||||
}
|
||||
|
||||
const QUICK_TEMPLATES = [
|
||||
{ id: 'risk-accepted', label: 'Risk Accepted', text: 'Risk has been reviewed and formally accepted.' },
|
||||
{ id: 'compensating-control', label: 'Compensating Control', text: 'Compensating controls in place: ' },
|
||||
{ id: 'false-positive', label: 'False Positive', text: 'This finding is a false positive because: ' },
|
||||
{ id: 'scheduled-fix', label: 'Scheduled Fix', text: 'Fix scheduled for deployment. Timeline: ' },
|
||||
] as const;
|
||||
|
||||
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
|
||||
{ value: 'critical', label: 'Critical' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-draft-inline',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
|
||||
export interface ExceptionDraftContext {
|
||||
readonly vulnIds?: readonly string[];
|
||||
readonly componentPurls?: readonly string[];
|
||||
readonly assetIds?: readonly string[];
|
||||
readonly tenantId?: string;
|
||||
readonly suggestedName?: string;
|
||||
readonly suggestedSeverity?: ExceptionSeverity;
|
||||
readonly sourceType: 'vulnerability' | 'component' | 'asset' | 'graph';
|
||||
readonly sourceLabel: string;
|
||||
}
|
||||
|
||||
const QUICK_TEMPLATES = [
|
||||
{ id: 'risk-accepted', label: 'Risk Accepted', text: 'Risk has been reviewed and formally accepted.' },
|
||||
{ id: 'compensating-control', label: 'Compensating Control', text: 'Compensating controls in place: ' },
|
||||
{ id: 'false-positive', label: 'False Positive', text: 'This finding is a false positive because: ' },
|
||||
{ id: 'scheduled-fix', label: 'Scheduled Fix', text: 'Fix scheduled for deployment. Timeline: ' },
|
||||
] as const;
|
||||
|
||||
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
|
||||
{ value: 'critical', label: 'Critical' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-draft-inline',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './exception-draft-inline.component.html',
|
||||
styleUrls: ['./exception-draft-inline.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -65,148 +65,148 @@ export class ExceptionDraftInlineComponent implements OnInit {
|
||||
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
|
||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
@Input() context!: ExceptionDraftContext;
|
||||
@Output() readonly created = new EventEmitter<Exception>();
|
||||
@Output() readonly cancelled = new EventEmitter<void>();
|
||||
@Output() readonly openFullWizard = new EventEmitter<ExceptionDraftContext>();
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly showSimulation = signal(false);
|
||||
|
||||
readonly quickTemplates = QUICK_TEMPLATES;
|
||||
readonly severityOptions = SEVERITY_OPTIONS;
|
||||
|
||||
readonly draftForm = this.formBuilder.group({
|
||||
name: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(3)],
|
||||
}),
|
||||
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
|
||||
justificationTemplate: this.formBuilder.control('risk-accepted'),
|
||||
justificationText: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(20)],
|
||||
}),
|
||||
timeboxDays: this.formBuilder.control(30),
|
||||
});
|
||||
|
||||
|
||||
@Input() context!: ExceptionDraftContext;
|
||||
@Output() readonly created = new EventEmitter<Exception>();
|
||||
@Output() readonly cancelled = new EventEmitter<void>();
|
||||
@Output() readonly openFullWizard = new EventEmitter<ExceptionDraftContext>();
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly showSimulation = signal(false);
|
||||
|
||||
readonly quickTemplates = QUICK_TEMPLATES;
|
||||
readonly severityOptions = SEVERITY_OPTIONS;
|
||||
|
||||
readonly draftForm = this.formBuilder.group({
|
||||
name: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(3)],
|
||||
}),
|
||||
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
|
||||
justificationTemplate: this.formBuilder.control('risk-accepted'),
|
||||
justificationText: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(20)],
|
||||
}),
|
||||
timeboxDays: this.formBuilder.control(30),
|
||||
});
|
||||
|
||||
readonly scopeType = computed<ExceptionScopeType>(() => {
|
||||
if (this.context?.componentPurls?.length) return 'component';
|
||||
if (this.context?.assetIds?.length) return 'asset';
|
||||
if (this.context?.tenantId) return 'tenant';
|
||||
return 'global';
|
||||
});
|
||||
|
||||
readonly scopeSummary = computed(() => {
|
||||
const ctx = this.context;
|
||||
const items: string[] = [];
|
||||
|
||||
if (ctx?.vulnIds?.length) {
|
||||
items.push(`${ctx.vulnIds.length} vulnerabilit${ctx.vulnIds.length === 1 ? 'y' : 'ies'}`);
|
||||
}
|
||||
if (ctx?.componentPurls?.length) {
|
||||
items.push(`${ctx.componentPurls.length} component${ctx.componentPurls.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (ctx?.assetIds?.length) {
|
||||
items.push(`${ctx.assetIds.length} asset${ctx.assetIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (ctx?.tenantId) {
|
||||
items.push(`Tenant: ${ctx.tenantId}`);
|
||||
}
|
||||
|
||||
return items.length > 0 ? items.join(', ') : 'Global scope';
|
||||
});
|
||||
|
||||
readonly simulationResult = computed(() => {
|
||||
if (!this.showSimulation()) return null;
|
||||
|
||||
const vulnCount = this.context?.vulnIds?.length ?? 0;
|
||||
const componentCount = this.context?.componentPurls?.length ?? 0;
|
||||
|
||||
return {
|
||||
affectedFindings: vulnCount * Math.max(1, componentCount),
|
||||
policyImpact: this.draftForm.controls.severity.value === 'critical' ? 'high' : 'moderate',
|
||||
coverageEstimate: `~${Math.min(100, vulnCount * 15 + componentCount * 10)}%`,
|
||||
};
|
||||
});
|
||||
|
||||
readonly canSubmit = computed(() => {
|
||||
return this.draftForm.valid && !this.loading();
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.context?.suggestedName) {
|
||||
this.draftForm.patchValue({ name: this.context.suggestedName });
|
||||
}
|
||||
if (this.context?.suggestedSeverity) {
|
||||
this.draftForm.patchValue({ severity: this.context.suggestedSeverity });
|
||||
}
|
||||
|
||||
const defaultTemplate = this.quickTemplates[0];
|
||||
this.draftForm.patchValue({ justificationText: defaultTemplate.text });
|
||||
}
|
||||
|
||||
selectTemplate(templateId: string): void {
|
||||
const template = this.quickTemplates.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
this.draftForm.patchValue({
|
||||
justificationTemplate: templateId,
|
||||
justificationText: template.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleSimulation(): void {
|
||||
this.showSimulation.set(!this.showSimulation());
|
||||
}
|
||||
|
||||
async submitDraft(): Promise<void> {
|
||||
if (!this.canSubmit()) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
try {
|
||||
const formValue = this.draftForm.getRawValue();
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() + formValue.timeboxDays);
|
||||
|
||||
const exception: Partial<Exception> = {
|
||||
name: formValue.name,
|
||||
severity: formValue.severity,
|
||||
status: 'draft',
|
||||
scope: {
|
||||
type: this.scopeType(),
|
||||
tenantId: this.context?.tenantId,
|
||||
assetIds: this.context?.assetIds ? [...this.context.assetIds] : undefined,
|
||||
componentPurls: this.context?.componentPurls ? [...this.context.componentPurls] : undefined,
|
||||
vulnIds: this.context?.vulnIds ? [...this.context.vulnIds] : undefined,
|
||||
},
|
||||
justification: {
|
||||
template: formValue.justificationTemplate,
|
||||
text: formValue.justificationText,
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
readonly scopeSummary = computed(() => {
|
||||
const ctx = this.context;
|
||||
const items: string[] = [];
|
||||
|
||||
if (ctx?.vulnIds?.length) {
|
||||
items.push(`${ctx.vulnIds.length} vulnerabilit${ctx.vulnIds.length === 1 ? 'y' : 'ies'}`);
|
||||
}
|
||||
if (ctx?.componentPurls?.length) {
|
||||
items.push(`${ctx.componentPurls.length} component${ctx.componentPurls.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (ctx?.assetIds?.length) {
|
||||
items.push(`${ctx.assetIds.length} asset${ctx.assetIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (ctx?.tenantId) {
|
||||
items.push(`Tenant: ${ctx.tenantId}`);
|
||||
}
|
||||
|
||||
return items.length > 0 ? items.join(', ') : 'Global scope';
|
||||
});
|
||||
|
||||
readonly simulationResult = computed(() => {
|
||||
if (!this.showSimulation()) return null;
|
||||
|
||||
const vulnCount = this.context?.vulnIds?.length ?? 0;
|
||||
const componentCount = this.context?.componentPurls?.length ?? 0;
|
||||
|
||||
return {
|
||||
affectedFindings: vulnCount * Math.max(1, componentCount),
|
||||
policyImpact: this.draftForm.controls.severity.value === 'critical' ? 'high' : 'moderate',
|
||||
coverageEstimate: `~${Math.min(100, vulnCount * 15 + componentCount * 10)}%`,
|
||||
};
|
||||
});
|
||||
|
||||
readonly canSubmit = computed(() => {
|
||||
return this.draftForm.valid && !this.loading();
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.context?.suggestedName) {
|
||||
this.draftForm.patchValue({ name: this.context.suggestedName });
|
||||
}
|
||||
if (this.context?.suggestedSeverity) {
|
||||
this.draftForm.patchValue({ severity: this.context.suggestedSeverity });
|
||||
}
|
||||
|
||||
const defaultTemplate = this.quickTemplates[0];
|
||||
this.draftForm.patchValue({ justificationText: defaultTemplate.text });
|
||||
}
|
||||
|
||||
selectTemplate(templateId: string): void {
|
||||
const template = this.quickTemplates.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
this.draftForm.patchValue({
|
||||
justificationTemplate: templateId,
|
||||
justificationText: template.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleSimulation(): void {
|
||||
this.showSimulation.set(!this.showSimulation());
|
||||
}
|
||||
|
||||
async submitDraft(): Promise<void> {
|
||||
if (!this.canSubmit()) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
try {
|
||||
const formValue = this.draftForm.getRawValue();
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() + formValue.timeboxDays);
|
||||
|
||||
const exception: Partial<Exception> = {
|
||||
name: formValue.name,
|
||||
severity: formValue.severity,
|
||||
status: 'draft',
|
||||
scope: {
|
||||
type: this.scopeType(),
|
||||
tenantId: this.context?.tenantId,
|
||||
assetIds: this.context?.assetIds ? [...this.context.assetIds] : undefined,
|
||||
componentPurls: this.context?.componentPurls ? [...this.context.componentPurls] : undefined,
|
||||
vulnIds: this.context?.vulnIds ? [...this.context.vulnIds] : undefined,
|
||||
},
|
||||
justification: {
|
||||
template: formValue.justificationTemplate,
|
||||
text: formValue.justificationText,
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const created = await firstValueFrom(this.api.createException(exception));
|
||||
this.created.emit(created);
|
||||
this.router.navigate(['/exceptions', created.exceptionId]);
|
||||
} catch (err) {
|
||||
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelled.emit();
|
||||
}
|
||||
|
||||
expandToFullWizard(): void {
|
||||
this.openFullWizard.emit(this.context);
|
||||
}
|
||||
}
|
||||
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelled.emit();
|
||||
}
|
||||
|
||||
expandToFullWizard(): void {
|
||||
this.openFullWizard.emit(this.context);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -379,7 +379,7 @@ import {
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background: #EEF2FF;
|
||||
color: #4338CA;
|
||||
color: #E09115;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -507,7 +507,7 @@ import {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
background: #EEF2FF;
|
||||
color: #4338CA;
|
||||
color: #E09115;
|
||||
border-radius: 3px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -515,14 +515,14 @@ type SbomSourceType = 'file' | 'oci';
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #4338CA;
|
||||
color: #E09115;
|
||||
}
|
||||
|
||||
.remove-pattern {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
color: #6366F1;
|
||||
color: #D4920A;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
|
||||
@@ -206,7 +206,7 @@ const VIEWPORT_PADDING = 100;
|
||||
|
||||
<!-- Selection filter -->
|
||||
<filter id="selection-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#4f46e5" flood-opacity="0.5"/>
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#F5A623" flood-opacity="0.5"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
@@ -390,7 +390,7 @@ const VIEWPORT_PADDING = 100;
|
||||
[attr.width]="viewportBounds().maxX - viewportBounds().minX"
|
||||
[attr.height]="viewportBounds().maxY - viewportBounds().minY"
|
||||
fill="none"
|
||||
stroke="#4f46e5"
|
||||
stroke="#F5A623"
|
||||
stroke-width="8"
|
||||
/>
|
||||
</svg>
|
||||
@@ -413,7 +413,7 @@ const VIEWPORT_PADDING = 100;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #4f46e5;
|
||||
outline: 2px solid #F5A623;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -459,7 +459,7 @@ const VIEWPORT_PADDING = 100;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #4f46e5;
|
||||
outline: 2px solid #F5A623;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -507,16 +507,16 @@ const VIEWPORT_PADDING = 100;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #4f46e5;
|
||||
background: #F5A623;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #4338ca;
|
||||
background: #E09115;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #4f46e5;
|
||||
outline: 2px solid #F5A623;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
@@ -555,7 +555,7 @@ const VIEWPORT_PADDING = 100;
|
||||
|
||||
&--highlighted {
|
||||
stroke-width: 3;
|
||||
stroke: #4f46e5;
|
||||
stroke: #F5A623;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,14 +573,14 @@ const VIEWPORT_PADDING = 100;
|
||||
&--selected {
|
||||
.node-bg {
|
||||
filter: url(#selection-glow);
|
||||
stroke: #4f46e5 !important;
|
||||
stroke: #F5A623 !important;
|
||||
stroke-width: 3 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&--highlighted:not(.node-group--selected) {
|
||||
.node-bg {
|
||||
stroke: #818cf8 !important;
|
||||
stroke: #F5B84A !important;
|
||||
stroke-width: 2 !important;
|
||||
}
|
||||
}
|
||||
@@ -595,7 +595,7 @@ const VIEWPORT_PADDING = 100;
|
||||
outline: none;
|
||||
|
||||
.node-bg {
|
||||
stroke: #4f46e5 !important;
|
||||
stroke: #F5A623 !important;
|
||||
stroke-width: 3 !important;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,476 +1,476 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostListener,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
ExceptionDraftContext,
|
||||
ExceptionDraftInlineComponent,
|
||||
} from '../exceptions/exception-draft-inline.component';
|
||||
import {
|
||||
ExceptionBadgeComponent,
|
||||
ExceptionBadgeData,
|
||||
ExceptionExplainComponent,
|
||||
ExceptionExplainData,
|
||||
} from '../../shared/components';
|
||||
import {
|
||||
AUTH_SERVICE,
|
||||
AuthService,
|
||||
MockAuthService,
|
||||
StellaOpsScopes,
|
||||
} from '../../core/auth';
|
||||
import { GraphCanvasComponent, CanvasNode, CanvasEdge } from './graph-canvas.component';
|
||||
import { GraphOverlaysComponent, GraphOverlayState } from './graph-overlays.component';
|
||||
|
||||
export interface GraphNode {
|
||||
readonly id: string;
|
||||
readonly type: 'asset' | 'component' | 'vulnerability';
|
||||
readonly name: string;
|
||||
readonly purl?: string;
|
||||
readonly version?: string;
|
||||
readonly severity?: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly vulnCount?: number;
|
||||
readonly hasException?: boolean;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly type: 'depends_on' | 'has_vulnerability' | 'child_of';
|
||||
}
|
||||
|
||||
const MOCK_NODES: GraphNode[] = [
|
||||
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
|
||||
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
|
||||
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
|
||||
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1, hasException: true },
|
||||
{ id: 'comp-curl', type: 'component', name: 'curl', purl: 'pkg:deb/debian/curl@7.88.1-10', version: '7.88.1-10', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-nghttp2', type: 'component', name: 'nghttp2', purl: 'pkg:npm/nghttp2@1.55.0', version: '1.55.0', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-golang-net', type: 'component', name: 'golang.org/x/net', purl: 'pkg:golang/golang.org/x/net@0.15.0', version: '0.15.0', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-zlib', type: 'component', name: 'zlib', purl: 'pkg:deb/debian/zlib@1.2.13', version: '1.2.13', severity: 'medium', vulnCount: 1 },
|
||||
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
|
||||
{ id: 'vuln-log4j-dos', type: 'vulnerability', name: 'CVE-2021-45046', severity: 'critical', hasException: true },
|
||||
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical', hasException: true },
|
||||
{ id: 'vuln-http2-reset', type: 'vulnerability', name: 'CVE-2023-44487', severity: 'high' },
|
||||
{ id: 'vuln-curl-heap', type: 'vulnerability', name: 'CVE-2023-38545', severity: 'high' },
|
||||
];
|
||||
|
||||
const MOCK_EDGES: GraphEdge[] = [
|
||||
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-curl', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-nghttp2', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-zlib', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-curl', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-golang-net', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
|
||||
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
|
||||
{ source: 'comp-log4j', target: 'vuln-log4j-dos', type: 'has_vulnerability' },
|
||||
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
|
||||
{ source: 'comp-nghttp2', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||
{ source: 'comp-golang-net', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
|
||||
];
|
||||
|
||||
type ViewMode = 'hierarchy' | 'flat' | 'canvas';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-explorer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent, GraphCanvasComponent, GraphOverlaysComponent],
|
||||
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
|
||||
templateUrl: './graph-explorer.component.html',
|
||||
styleUrls: ['./graph-explorer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GraphExplorerComponent implements OnInit {
|
||||
private readonly authService = inject(AUTH_SERVICE);
|
||||
|
||||
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
|
||||
readonly canViewGraph = computed(() => this.authService.canViewGraph());
|
||||
readonly canEditGraph = computed(() => this.authService.canEditGraph());
|
||||
readonly canExportGraph = computed(() => this.authService.canExportGraph());
|
||||
readonly canSimulate = computed(() => this.authService.canSimulate());
|
||||
readonly canCreateException = computed(() =>
|
||||
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
|
||||
);
|
||||
|
||||
// Current user info
|
||||
readonly currentUser = computed(() => this.authService.user());
|
||||
readonly userScopes = computed(() => this.authService.scopes());
|
||||
|
||||
// View state
|
||||
readonly loading = signal(false);
|
||||
readonly message = signal<string | null>(null);
|
||||
readonly messageType = signal<'success' | 'error' | 'info'>('info');
|
||||
readonly viewMode = signal<ViewMode>('canvas'); // Default to canvas view
|
||||
|
||||
// Data
|
||||
readonly nodes = signal<GraphNode[]>([]);
|
||||
readonly edges = signal<GraphEdge[]>([]);
|
||||
readonly selectedNodeId = signal<string | null>(null);
|
||||
|
||||
// Exception draft state
|
||||
readonly showExceptionDraft = signal(false);
|
||||
|
||||
// Exception explain state
|
||||
readonly showExceptionExplain = signal(false);
|
||||
readonly explainNodeId = signal<string | null>(null);
|
||||
|
||||
// Filters
|
||||
readonly showVulnerabilities = signal(true);
|
||||
readonly showComponents = signal(true);
|
||||
readonly showAssets = signal(true);
|
||||
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
|
||||
|
||||
// Overlay state
|
||||
readonly overlayState = signal<GraphOverlayState | null>(null);
|
||||
readonly simulationMode = signal(false);
|
||||
readonly pathViewState = signal<{ enabled: boolean; type: string }>({ enabled: false, type: 'shortest' });
|
||||
readonly timeTravelState = signal<{ enabled: boolean; snapshot: string }>({ enabled: false, snapshot: 'current' });
|
||||
|
||||
// Computed: node IDs for overlay component
|
||||
readonly nodeIds = computed(() => this.filteredNodes().map(n => n.id));
|
||||
|
||||
// Computed: filtered nodes
|
||||
readonly filteredNodes = computed(() => {
|
||||
let items = [...this.nodes()];
|
||||
const showVulns = this.showVulnerabilities();
|
||||
const showComps = this.showComponents();
|
||||
const showAssetNodes = this.showAssets();
|
||||
const severity = this.filterSeverity();
|
||||
|
||||
items = items.filter((n) => {
|
||||
if (n.type === 'vulnerability' && !showVulns) return false;
|
||||
if (n.type === 'component' && !showComps) return false;
|
||||
if (n.type === 'asset' && !showAssetNodes) return false;
|
||||
if (severity !== 'all' && n.severity && n.severity !== severity) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
// Computed: canvas nodes (filtered for canvas view)
|
||||
readonly canvasNodes = computed<CanvasNode[]>(() => {
|
||||
return this.filteredNodes().map(n => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
name: n.name,
|
||||
purl: n.purl,
|
||||
version: n.version,
|
||||
severity: n.severity,
|
||||
vulnCount: n.vulnCount,
|
||||
hasException: n.hasException,
|
||||
}));
|
||||
});
|
||||
|
||||
// Computed: canvas edges (filtered based on visible nodes)
|
||||
readonly canvasEdges = computed<CanvasEdge[]>(() => {
|
||||
const visibleIds = new Set(this.filteredNodes().map(n => n.id));
|
||||
return this.edges()
|
||||
.filter(e => visibleIds.has(e.source) && visibleIds.has(e.target))
|
||||
.map(e => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: e.type,
|
||||
}));
|
||||
});
|
||||
|
||||
// Computed: assets
|
||||
readonly assets = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'asset');
|
||||
});
|
||||
|
||||
// Computed: components
|
||||
readonly components = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'component');
|
||||
});
|
||||
|
||||
// Computed: vulnerabilities
|
||||
readonly vulnerabilities = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'vulnerability');
|
||||
});
|
||||
|
||||
// Computed: selected node
|
||||
readonly selectedNode = computed(() => {
|
||||
const id = this.selectedNodeId();
|
||||
if (!id) return null;
|
||||
return this.nodes().find((n) => n.id === id) ?? null;
|
||||
});
|
||||
|
||||
// Computed: related nodes for selected
|
||||
readonly relatedNodes = computed(() => {
|
||||
const selectedId = this.selectedNodeId();
|
||||
if (!selectedId) return [];
|
||||
|
||||
const edgeList = this.edges();
|
||||
const relatedIds = new Set<string>();
|
||||
|
||||
edgeList.forEach((e) => {
|
||||
if (e.source === selectedId) relatedIds.add(e.target);
|
||||
if (e.target === selectedId) relatedIds.add(e.source);
|
||||
});
|
||||
|
||||
return this.nodes().filter((n) => relatedIds.has(n.id));
|
||||
});
|
||||
|
||||
// Get exception badge data for a node
|
||||
getExceptionBadgeData(node: GraphNode): ExceptionBadgeData | null {
|
||||
if (!node.hasException) return null;
|
||||
return {
|
||||
exceptionId: `exc-${node.id}`,
|
||||
status: 'approved',
|
||||
severity: node.severity ?? 'medium',
|
||||
name: `${node.name} Exception`,
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
justificationSummary: 'Risk accepted with compensating controls.',
|
||||
approvedBy: 'Security Team',
|
||||
};
|
||||
}
|
||||
|
||||
// Computed: explain data for selected node
|
||||
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
|
||||
const nodeId = this.explainNodeId();
|
||||
if (!nodeId) return null;
|
||||
|
||||
const node = this.nodes().find((n) => n.id === nodeId);
|
||||
if (!node || !node.hasException) return null;
|
||||
|
||||
const relatedComps = this.edges()
|
||||
.filter((e) => e.source === nodeId || e.target === nodeId)
|
||||
.map((e) => (e.source === nodeId ? e.target : e.source))
|
||||
.map((id) => this.nodes().find((n) => n.id === id))
|
||||
.filter((n): n is GraphNode => n !== undefined && n.type === 'component');
|
||||
|
||||
return {
|
||||
exceptionId: `exc-${node.id}`,
|
||||
name: `${node.name} Exception`,
|
||||
status: 'approved',
|
||||
severity: node.severity ?? 'medium',
|
||||
scope: {
|
||||
type: node.type === 'vulnerability' ? 'vulnerability' : 'component',
|
||||
vulnIds: node.type === 'vulnerability' ? [node.name] : undefined,
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
},
|
||||
justification: {
|
||||
template: 'risk-accepted',
|
||||
text: 'Risk accepted with compensating controls in place. The affected item is in a controlled environment.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
autoRenew: false,
|
||||
},
|
||||
approvedBy: 'Security Team',
|
||||
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
impact: {
|
||||
affectedFindings: 1,
|
||||
affectedAssets: 1,
|
||||
policyOverrides: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Computed: exception draft context
|
||||
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
|
||||
const node = this.selectedNode();
|
||||
if (!node) return null;
|
||||
|
||||
if (node.type === 'component') {
|
||||
const relatedVulns = this.relatedNodes().filter((n) => n.type === 'vulnerability');
|
||||
return {
|
||||
componentPurls: node.purl ? [node.purl] : undefined,
|
||||
vulnIds: relatedVulns.map((v) => v.name),
|
||||
suggestedName: `${node.name}-exception`,
|
||||
suggestedSeverity: node.severity ?? 'medium',
|
||||
sourceType: 'component',
|
||||
sourceLabel: `${node.name}@${node.version}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'vulnerability') {
|
||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||
return {
|
||||
vulnIds: [node.name],
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
suggestedName: `${node.name.toLowerCase()}-exception`,
|
||||
suggestedSeverity: node.severity ?? 'medium',
|
||||
sourceType: 'vulnerability',
|
||||
sourceLabel: node.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'asset') {
|
||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||
return {
|
||||
assetIds: [node.name],
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
suggestedName: `${node.name}-exception`,
|
||||
sourceType: 'asset',
|
||||
sourceLabel: node.name,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
loadData(): void {
|
||||
this.loading.set(true);
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
this.nodes.set([...MOCK_NODES]);
|
||||
this.edges.set([...MOCK_EDGES]);
|
||||
this.loading.set(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// View mode
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
// Filters
|
||||
toggleVulnerabilities(): void {
|
||||
this.showVulnerabilities.set(!this.showVulnerabilities());
|
||||
}
|
||||
|
||||
toggleComponents(): void {
|
||||
this.showComponents.set(!this.showComponents());
|
||||
}
|
||||
|
||||
toggleAssets(): void {
|
||||
this.showAssets.set(!this.showAssets());
|
||||
}
|
||||
|
||||
setSeverityFilter(severity: 'all' | 'critical' | 'high' | 'medium' | 'low'): void {
|
||||
this.filterSeverity.set(severity);
|
||||
}
|
||||
|
||||
// Selection
|
||||
selectNode(nodeId: string): void {
|
||||
this.selectedNodeId.set(nodeId);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedNodeId.set(null);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
// Exception drafting
|
||||
startExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(true);
|
||||
}
|
||||
|
||||
cancelExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
onExceptionCreated(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
this.showMessage('Exception draft created successfully', 'success');
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
openFullWizard(): void {
|
||||
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
|
||||
}
|
||||
|
||||
// Exception explain
|
||||
onViewExceptionDetails(exceptionId: string): void {
|
||||
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
|
||||
}
|
||||
|
||||
onExplainException(exceptionId: string): void {
|
||||
// Find the node with this exception ID
|
||||
const node = this.nodes().find((n) => `exc-${n.id}` === exceptionId);
|
||||
if (node) {
|
||||
this.explainNodeId.set(node.id);
|
||||
this.showExceptionExplain.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
closeExplain(): void {
|
||||
this.showExceptionExplain.set(false);
|
||||
this.explainNodeId.set(null);
|
||||
}
|
||||
|
||||
viewExceptionFromExplain(exceptionId: string): void {
|
||||
this.closeExplain();
|
||||
this.onViewExceptionDetails(exceptionId);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
getNodeTypeIcon(type: GraphNode['type']): string {
|
||||
switch (type) {
|
||||
case 'asset':
|
||||
return '📦';
|
||||
case 'component':
|
||||
return '🧩';
|
||||
case 'vulnerability':
|
||||
return '⚠️';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
getSeverityClass(severity: string | undefined): string {
|
||||
if (!severity) return '';
|
||||
return `severity--${severity}`;
|
||||
}
|
||||
|
||||
getNodeClass(node: GraphNode): string {
|
||||
const classes = [`node--${node.type}`];
|
||||
if (node.severity) classes.push(`node--${node.severity}`);
|
||||
if (node.hasException) classes.push('node--excepted');
|
||||
if (this.selectedNodeId() === node.id) classes.push('node--selected');
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
trackByNode = (_: number, item: GraphNode) => item.id;
|
||||
|
||||
// Overlay handlers
|
||||
onOverlayStateChange(state: GraphOverlayState): void {
|
||||
this.overlayState.set(state);
|
||||
}
|
||||
|
||||
onSimulationModeChange(enabled: boolean): void {
|
||||
this.simulationMode.set(enabled);
|
||||
this.showMessage(enabled ? 'Simulation mode enabled' : 'Simulation mode disabled', 'info');
|
||||
}
|
||||
|
||||
onPathViewChange(state: { enabled: boolean; type: string }): void {
|
||||
this.pathViewState.set(state);
|
||||
if (state.enabled) {
|
||||
this.showMessage(`Path view enabled: ${state.type}`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
onTimeTravelChange(state: { enabled: boolean; snapshot: string }): void {
|
||||
this.timeTravelState.set(state);
|
||||
if (state.enabled && state.snapshot !== 'current') {
|
||||
this.showMessage(`Viewing snapshot: ${state.snapshot}`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
onShowDiffRequest(snapshot: string): void {
|
||||
this.showMessage(`Loading diff for ${snapshot}...`, 'info');
|
||||
}
|
||||
|
||||
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
|
||||
this.message.set(text);
|
||||
this.messageType.set(type);
|
||||
setTimeout(() => this.message.set(null), 5000);
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostListener,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
ExceptionDraftContext,
|
||||
ExceptionDraftInlineComponent,
|
||||
} from '../exceptions/exception-draft-inline.component';
|
||||
import {
|
||||
ExceptionBadgeComponent,
|
||||
ExceptionBadgeData,
|
||||
ExceptionExplainComponent,
|
||||
ExceptionExplainData,
|
||||
} from '../../shared/components';
|
||||
import {
|
||||
AUTH_SERVICE,
|
||||
AuthService,
|
||||
MockAuthService,
|
||||
StellaOpsScopes,
|
||||
} from '../../core/auth';
|
||||
import { GraphCanvasComponent, CanvasNode, CanvasEdge } from './graph-canvas.component';
|
||||
import { GraphOverlaysComponent, GraphOverlayState } from './graph-overlays.component';
|
||||
|
||||
export interface GraphNode {
|
||||
readonly id: string;
|
||||
readonly type: 'asset' | 'component' | 'vulnerability';
|
||||
readonly name: string;
|
||||
readonly purl?: string;
|
||||
readonly version?: string;
|
||||
readonly severity?: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly vulnCount?: number;
|
||||
readonly hasException?: boolean;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly type: 'depends_on' | 'has_vulnerability' | 'child_of';
|
||||
}
|
||||
|
||||
const MOCK_NODES: GraphNode[] = [
|
||||
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
|
||||
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
|
||||
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
|
||||
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1, hasException: true },
|
||||
{ id: 'comp-curl', type: 'component', name: 'curl', purl: 'pkg:deb/debian/curl@7.88.1-10', version: '7.88.1-10', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-nghttp2', type: 'component', name: 'nghttp2', purl: 'pkg:npm/nghttp2@1.55.0', version: '1.55.0', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-golang-net', type: 'component', name: 'golang.org/x/net', purl: 'pkg:golang/golang.org/x/net@0.15.0', version: '0.15.0', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-zlib', type: 'component', name: 'zlib', purl: 'pkg:deb/debian/zlib@1.2.13', version: '1.2.13', severity: 'medium', vulnCount: 1 },
|
||||
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
|
||||
{ id: 'vuln-log4j-dos', type: 'vulnerability', name: 'CVE-2021-45046', severity: 'critical', hasException: true },
|
||||
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical', hasException: true },
|
||||
{ id: 'vuln-http2-reset', type: 'vulnerability', name: 'CVE-2023-44487', severity: 'high' },
|
||||
{ id: 'vuln-curl-heap', type: 'vulnerability', name: 'CVE-2023-38545', severity: 'high' },
|
||||
];
|
||||
|
||||
const MOCK_EDGES: GraphEdge[] = [
|
||||
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-curl', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-nghttp2', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-zlib', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-curl', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-golang-net', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
|
||||
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
|
||||
{ source: 'comp-log4j', target: 'vuln-log4j-dos', type: 'has_vulnerability' },
|
||||
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
|
||||
{ source: 'comp-nghttp2', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||
{ source: 'comp-golang-net', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
|
||||
];
|
||||
|
||||
type ViewMode = 'hierarchy' | 'flat' | 'canvas';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-explorer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent, GraphCanvasComponent, GraphOverlaysComponent],
|
||||
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
|
||||
templateUrl: './graph-explorer.component.html',
|
||||
styleUrls: ['./graph-explorer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GraphExplorerComponent implements OnInit {
|
||||
private readonly authService = inject(AUTH_SERVICE);
|
||||
|
||||
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
|
||||
readonly canViewGraph = computed(() => this.authService.canViewGraph());
|
||||
readonly canEditGraph = computed(() => this.authService.canEditGraph());
|
||||
readonly canExportGraph = computed(() => this.authService.canExportGraph());
|
||||
readonly canSimulate = computed(() => this.authService.canSimulate());
|
||||
readonly canCreateException = computed(() =>
|
||||
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
|
||||
);
|
||||
|
||||
// Current user info
|
||||
readonly currentUser = computed(() => this.authService.user());
|
||||
readonly userScopes = computed(() => this.authService.scopes());
|
||||
|
||||
// View state
|
||||
readonly loading = signal(false);
|
||||
readonly message = signal<string | null>(null);
|
||||
readonly messageType = signal<'success' | 'error' | 'info'>('info');
|
||||
readonly viewMode = signal<ViewMode>('canvas'); // Default to canvas view
|
||||
|
||||
// Data
|
||||
readonly nodes = signal<GraphNode[]>([]);
|
||||
readonly edges = signal<GraphEdge[]>([]);
|
||||
readonly selectedNodeId = signal<string | null>(null);
|
||||
|
||||
// Exception draft state
|
||||
readonly showExceptionDraft = signal(false);
|
||||
|
||||
// Exception explain state
|
||||
readonly showExceptionExplain = signal(false);
|
||||
readonly explainNodeId = signal<string | null>(null);
|
||||
|
||||
// Filters
|
||||
readonly showVulnerabilities = signal(true);
|
||||
readonly showComponents = signal(true);
|
||||
readonly showAssets = signal(true);
|
||||
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
|
||||
|
||||
// Overlay state
|
||||
readonly overlayState = signal<GraphOverlayState | null>(null);
|
||||
readonly simulationMode = signal(false);
|
||||
readonly pathViewState = signal<{ enabled: boolean; type: string }>({ enabled: false, type: 'shortest' });
|
||||
readonly timeTravelState = signal<{ enabled: boolean; snapshot: string }>({ enabled: false, snapshot: 'current' });
|
||||
|
||||
// Computed: node IDs for overlay component
|
||||
readonly nodeIds = computed(() => this.filteredNodes().map(n => n.id));
|
||||
|
||||
// Computed: filtered nodes
|
||||
readonly filteredNodes = computed(() => {
|
||||
let items = [...this.nodes()];
|
||||
const showVulns = this.showVulnerabilities();
|
||||
const showComps = this.showComponents();
|
||||
const showAssetNodes = this.showAssets();
|
||||
const severity = this.filterSeverity();
|
||||
|
||||
items = items.filter((n) => {
|
||||
if (n.type === 'vulnerability' && !showVulns) return false;
|
||||
if (n.type === 'component' && !showComps) return false;
|
||||
if (n.type === 'asset' && !showAssetNodes) return false;
|
||||
if (severity !== 'all' && n.severity && n.severity !== severity) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
// Computed: canvas nodes (filtered for canvas view)
|
||||
readonly canvasNodes = computed<CanvasNode[]>(() => {
|
||||
return this.filteredNodes().map(n => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
name: n.name,
|
||||
purl: n.purl,
|
||||
version: n.version,
|
||||
severity: n.severity,
|
||||
vulnCount: n.vulnCount,
|
||||
hasException: n.hasException,
|
||||
}));
|
||||
});
|
||||
|
||||
// Computed: canvas edges (filtered based on visible nodes)
|
||||
readonly canvasEdges = computed<CanvasEdge[]>(() => {
|
||||
const visibleIds = new Set(this.filteredNodes().map(n => n.id));
|
||||
return this.edges()
|
||||
.filter(e => visibleIds.has(e.source) && visibleIds.has(e.target))
|
||||
.map(e => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: e.type,
|
||||
}));
|
||||
});
|
||||
|
||||
// Computed: assets
|
||||
readonly assets = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'asset');
|
||||
});
|
||||
|
||||
// Computed: components
|
||||
readonly components = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'component');
|
||||
});
|
||||
|
||||
// Computed: vulnerabilities
|
||||
readonly vulnerabilities = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'vulnerability');
|
||||
});
|
||||
|
||||
// Computed: selected node
|
||||
readonly selectedNode = computed(() => {
|
||||
const id = this.selectedNodeId();
|
||||
if (!id) return null;
|
||||
return this.nodes().find((n) => n.id === id) ?? null;
|
||||
});
|
||||
|
||||
// Computed: related nodes for selected
|
||||
readonly relatedNodes = computed(() => {
|
||||
const selectedId = this.selectedNodeId();
|
||||
if (!selectedId) return [];
|
||||
|
||||
const edgeList = this.edges();
|
||||
const relatedIds = new Set<string>();
|
||||
|
||||
edgeList.forEach((e) => {
|
||||
if (e.source === selectedId) relatedIds.add(e.target);
|
||||
if (e.target === selectedId) relatedIds.add(e.source);
|
||||
});
|
||||
|
||||
return this.nodes().filter((n) => relatedIds.has(n.id));
|
||||
});
|
||||
|
||||
// Get exception badge data for a node
|
||||
getExceptionBadgeData(node: GraphNode): ExceptionBadgeData | null {
|
||||
if (!node.hasException) return null;
|
||||
return {
|
||||
exceptionId: `exc-${node.id}`,
|
||||
status: 'approved',
|
||||
severity: node.severity ?? 'medium',
|
||||
name: `${node.name} Exception`,
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
justificationSummary: 'Risk accepted with compensating controls.',
|
||||
approvedBy: 'Security Team',
|
||||
};
|
||||
}
|
||||
|
||||
// Computed: explain data for selected node
|
||||
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
|
||||
const nodeId = this.explainNodeId();
|
||||
if (!nodeId) return null;
|
||||
|
||||
const node = this.nodes().find((n) => n.id === nodeId);
|
||||
if (!node || !node.hasException) return null;
|
||||
|
||||
const relatedComps = this.edges()
|
||||
.filter((e) => e.source === nodeId || e.target === nodeId)
|
||||
.map((e) => (e.source === nodeId ? e.target : e.source))
|
||||
.map((id) => this.nodes().find((n) => n.id === id))
|
||||
.filter((n): n is GraphNode => n !== undefined && n.type === 'component');
|
||||
|
||||
return {
|
||||
exceptionId: `exc-${node.id}`,
|
||||
name: `${node.name} Exception`,
|
||||
status: 'approved',
|
||||
severity: node.severity ?? 'medium',
|
||||
scope: {
|
||||
type: node.type === 'vulnerability' ? 'vulnerability' : 'component',
|
||||
vulnIds: node.type === 'vulnerability' ? [node.name] : undefined,
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
},
|
||||
justification: {
|
||||
template: 'risk-accepted',
|
||||
text: 'Risk accepted with compensating controls in place. The affected item is in a controlled environment.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
autoRenew: false,
|
||||
},
|
||||
approvedBy: 'Security Team',
|
||||
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
impact: {
|
||||
affectedFindings: 1,
|
||||
affectedAssets: 1,
|
||||
policyOverrides: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Computed: exception draft context
|
||||
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
|
||||
const node = this.selectedNode();
|
||||
if (!node) return null;
|
||||
|
||||
if (node.type === 'component') {
|
||||
const relatedVulns = this.relatedNodes().filter((n) => n.type === 'vulnerability');
|
||||
return {
|
||||
componentPurls: node.purl ? [node.purl] : undefined,
|
||||
vulnIds: relatedVulns.map((v) => v.name),
|
||||
suggestedName: `${node.name}-exception`,
|
||||
suggestedSeverity: node.severity ?? 'medium',
|
||||
sourceType: 'component',
|
||||
sourceLabel: `${node.name}@${node.version}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'vulnerability') {
|
||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||
return {
|
||||
vulnIds: [node.name],
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
suggestedName: `${node.name.toLowerCase()}-exception`,
|
||||
suggestedSeverity: node.severity ?? 'medium',
|
||||
sourceType: 'vulnerability',
|
||||
sourceLabel: node.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'asset') {
|
||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||
return {
|
||||
assetIds: [node.name],
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
suggestedName: `${node.name}-exception`,
|
||||
sourceType: 'asset',
|
||||
sourceLabel: node.name,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
loadData(): void {
|
||||
this.loading.set(true);
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
this.nodes.set([...MOCK_NODES]);
|
||||
this.edges.set([...MOCK_EDGES]);
|
||||
this.loading.set(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// View mode
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
// Filters
|
||||
toggleVulnerabilities(): void {
|
||||
this.showVulnerabilities.set(!this.showVulnerabilities());
|
||||
}
|
||||
|
||||
toggleComponents(): void {
|
||||
this.showComponents.set(!this.showComponents());
|
||||
}
|
||||
|
||||
toggleAssets(): void {
|
||||
this.showAssets.set(!this.showAssets());
|
||||
}
|
||||
|
||||
setSeverityFilter(severity: 'all' | 'critical' | 'high' | 'medium' | 'low'): void {
|
||||
this.filterSeverity.set(severity);
|
||||
}
|
||||
|
||||
// Selection
|
||||
selectNode(nodeId: string): void {
|
||||
this.selectedNodeId.set(nodeId);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedNodeId.set(null);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
// Exception drafting
|
||||
startExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(true);
|
||||
}
|
||||
|
||||
cancelExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
onExceptionCreated(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
this.showMessage('Exception draft created successfully', 'success');
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
openFullWizard(): void {
|
||||
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
|
||||
}
|
||||
|
||||
// Exception explain
|
||||
onViewExceptionDetails(exceptionId: string): void {
|
||||
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
|
||||
}
|
||||
|
||||
onExplainException(exceptionId: string): void {
|
||||
// Find the node with this exception ID
|
||||
const node = this.nodes().find((n) => `exc-${n.id}` === exceptionId);
|
||||
if (node) {
|
||||
this.explainNodeId.set(node.id);
|
||||
this.showExceptionExplain.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
closeExplain(): void {
|
||||
this.showExceptionExplain.set(false);
|
||||
this.explainNodeId.set(null);
|
||||
}
|
||||
|
||||
viewExceptionFromExplain(exceptionId: string): void {
|
||||
this.closeExplain();
|
||||
this.onViewExceptionDetails(exceptionId);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
getNodeTypeIcon(type: GraphNode['type']): string {
|
||||
switch (type) {
|
||||
case 'asset':
|
||||
return '📦';
|
||||
case 'component':
|
||||
return '🧩';
|
||||
case 'vulnerability':
|
||||
return '⚠️';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
getSeverityClass(severity: string | undefined): string {
|
||||
if (!severity) return '';
|
||||
return `severity--${severity}`;
|
||||
}
|
||||
|
||||
getNodeClass(node: GraphNode): string {
|
||||
const classes = [`node--${node.type}`];
|
||||
if (node.severity) classes.push(`node--${node.severity}`);
|
||||
if (node.hasException) classes.push('node--excepted');
|
||||
if (this.selectedNodeId() === node.id) classes.push('node--selected');
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
trackByNode = (_: number, item: GraphNode) => item.id;
|
||||
|
||||
// Overlay handlers
|
||||
onOverlayStateChange(state: GraphOverlayState): void {
|
||||
this.overlayState.set(state);
|
||||
}
|
||||
|
||||
onSimulationModeChange(enabled: boolean): void {
|
||||
this.simulationMode.set(enabled);
|
||||
this.showMessage(enabled ? 'Simulation mode enabled' : 'Simulation mode disabled', 'info');
|
||||
}
|
||||
|
||||
onPathViewChange(state: { enabled: boolean; type: string }): void {
|
||||
this.pathViewState.set(state);
|
||||
if (state.enabled) {
|
||||
this.showMessage(`Path view enabled: ${state.type}`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
onTimeTravelChange(state: { enabled: boolean; snapshot: string }): void {
|
||||
this.timeTravelState.set(state);
|
||||
if (state.enabled && state.snapshot !== 'current') {
|
||||
this.showMessage(`Viewing snapshot: ${state.snapshot}`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
onShowDiffRequest(snapshot: string): void {
|
||||
this.showMessage(`Loading diff for ${snapshot}...`, 'info');
|
||||
}
|
||||
|
||||
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
|
||||
this.message.set(text);
|
||||
this.messageType.set(type);
|
||||
setTimeout(() => this.message.set(null), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,18 +494,18 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #F5A623;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
background: #F5A623;
|
||||
border-color: #F5A623;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #4338ca;
|
||||
border-color: #4338ca;
|
||||
background: #E09115;
|
||||
border-color: #E09115;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -649,7 +649,7 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -681,8 +681,8 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #F5A623;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -769,8 +769,8 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #F5A623;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -844,7 +844,7 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,11 +872,11 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: #4f46e5;
|
||||
background: #F5A623;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #4338ca;
|
||||
background: #E09115;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@@ -156,7 +156,7 @@ import { GraphAccessibilityService, HotkeyBinding } from './graph-accessibility.
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #4f46e5;
|
||||
outline: 2px solid #F5A623;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,18 +606,18 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--overlay-color, #4f46e5);
|
||||
border-color: var(--overlay-color, #F5A623);
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: var(--overlay-color, #4f46e5);
|
||||
background: color-mix(in srgb, var(--overlay-color, #4f46e5) 10%, white);
|
||||
color: var(--overlay-color, #4f46e5);
|
||||
border-color: var(--overlay-color, #F5A623);
|
||||
background: color-mix(in srgb, var(--overlay-color, #F5A623) 10%, white);
|
||||
color: var(--overlay-color, #F5A623);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--overlay-color, #4f46e5);
|
||||
outline: 2px solid var(--overlay-color, #F5A623);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -634,7 +634,7 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--overlay-color, #4f46e5);
|
||||
background: var(--overlay-color, #F5A623);
|
||||
}
|
||||
|
||||
/* Simulation toggle */
|
||||
@@ -869,14 +869,14 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1023,7 +1023,7 @@ export class GraphOverlaysComponent implements OnChanges {
|
||||
|
||||
// Overlay configurations
|
||||
readonly overlayConfigs = signal<OverlayConfig[]>([
|
||||
{ type: 'policy', enabled: false, label: 'Policy', icon: '📋', color: '#4f46e5' },
|
||||
{ type: 'policy', enabled: false, label: 'Policy', icon: '📋', color: '#F5A623' },
|
||||
{ type: 'evidence', enabled: false, label: 'Evidence', icon: '🔍', color: '#0ea5e9' },
|
||||
{ type: 'license', enabled: false, label: 'License', icon: '📜', color: '#22c55e' },
|
||||
{ type: 'exposure', enabled: false, label: 'Exposure', icon: '🌐', color: '#ef4444' },
|
||||
|
||||
@@ -610,7 +610,7 @@ function generateMockDiff(): SbomDiff {
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
background: white;
|
||||
|
||||
&::after {
|
||||
@@ -620,7 +620,7 @@ function generateMockDiff(): SbomDiff {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #4f46e5;
|
||||
background: #F5A623;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -704,8 +704,8 @@ function generateMockDiff(): SbomDiff {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #F5A623;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,7 +748,7 @@ function generateMockDiff(): SbomDiff {
|
||||
font-size: 0.8125rem;
|
||||
|
||||
a {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
@@ -776,7 +776,7 @@ function generateMockDiff(): SbomDiff {
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
@@ -866,11 +866,11 @@ function generateMockDiff(): SbomDiff {
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
@@ -989,11 +989,11 @@ function generateMockDiff(): SbomDiff {
|
||||
cursor: pointer;
|
||||
|
||||
&--primary {
|
||||
background: #4f46e5;
|
||||
background: #F5A623;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #4338ca;
|
||||
background: #E09115;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1003,8 +1003,8 @@ function generateMockDiff(): SbomDiff {
|
||||
color: #475569;
|
||||
|
||||
&:hover {
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #F5A623;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1151,8 +1151,8 @@ function generateMockDiff(): SbomDiff {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #F5A623;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@@ -52,7 +52,7 @@ import { GateDisplay, GateChangeDisplay, GateType } from '../models/reachability
|
||||
}
|
||||
}
|
||||
|
||||
.gate-chip.auth { border-left-color: #6366f1; }
|
||||
.gate-chip.auth { border-left-color: #D4920A; }
|
||||
.gate-chip.feature-flag { border-left-color: #f59e0b; }
|
||||
.gate-chip.config { border-left-color: #8b5cf6; }
|
||||
.gate-chip.runtime { border-left-color: #ec4899; }
|
||||
|
||||
@@ -1,394 +1,394 @@
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.notify-panel {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.notify-panel__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-6);
|
||||
|
||||
h1 {
|
||||
margin: var(--space-1) 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notify-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.notify-card {
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.notify-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: var(--color-brand-light);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.notify-message {
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.channel-list,
|
||||
.rule-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.channel-item,
|
||||
.rule-item {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-primary);
|
||||
color: inherit;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
transform var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&.active {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: var(--color-brand-light);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.channel-meta,
|
||||
.rule-meta {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.channel-status {
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.channel-status--enabled {
|
||||
border-color: var(--color-status-success);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
label span {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: inherit;
|
||||
padding: var(--space-2);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
input {
|
||||
width: auto;
|
||||
accent-color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.notify-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.notify-actions button {
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--space-1-5) var(--space-4);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
transition: opacity var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-brand-primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.notify-actions .ghost-button {
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.channel-health {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-tertiary);
|
||||
border: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.status-pill--healthy {
|
||||
border-color: var(--color-status-success);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.status-pill--warning {
|
||||
border-color: var(--color-status-warning);
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
.status-pill--error {
|
||||
border-color: var(--color-status-error);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.channel-health__details p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.channel-health__details small {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.test-form h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.test-preview {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface-tertiary);
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: var(--space-1) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
font-family: var(--font-family-mono);
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2-5);
|
||||
}
|
||||
}
|
||||
|
||||
.deliveries-controls {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.deliveries-controls label {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.deliveries-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding-bottom: var(--space-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: var(--space-2) var(--space-1);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.status-badge--sent {
|
||||
border-color: var(--color-status-success);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.status-badge--failed {
|
||||
border-color: var(--color-status-error);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.status-badge--throttled {
|
||||
border-color: var(--color-status-warning);
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
@include screen-below-md {
|
||||
.notify-panel {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.notify-panel__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.notify-panel {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.notify-panel__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-6);
|
||||
|
||||
h1 {
|
||||
margin: var(--space-1) 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notify-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.notify-card {
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.notify-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: var(--color-brand-light);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.notify-message {
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.channel-list,
|
||||
.rule-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.channel-item,
|
||||
.rule-item {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-primary);
|
||||
color: inherit;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
transform var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&.active {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: var(--color-brand-light);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.channel-meta,
|
||||
.rule-meta {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.channel-status {
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.channel-status--enabled {
|
||||
border-color: var(--color-status-success);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
label span {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: inherit;
|
||||
padding: var(--space-2);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
input {
|
||||
width: auto;
|
||||
accent-color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.notify-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.notify-actions button {
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--space-1-5) var(--space-4);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
transition: opacity var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-brand-primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.notify-actions .ghost-button {
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.channel-health {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-tertiary);
|
||||
border: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.status-pill--healthy {
|
||||
border-color: var(--color-status-success);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.status-pill--warning {
|
||||
border-color: var(--color-status-warning);
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
.status-pill--error {
|
||||
border-color: var(--color-status-error);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.channel-health__details p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.channel-health__details small {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.test-form h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.test-preview {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface-tertiary);
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: var(--space-1) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
font-family: var(--font-family-mono);
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2-5);
|
||||
}
|
||||
}
|
||||
|
||||
.deliveries-controls {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.deliveries-controls label {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.deliveries-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding-bottom: var(--space-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: var(--space-2) var(--space-1);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.status-badge--sent {
|
||||
border-color: var(--color-status-success);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.status-badge--failed {
|
||||
border-color: var(--color-status-error);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.status-badge--throttled {
|
||||
border-color: var(--color-status-warning);
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
@include screen-below-md {
|
||||
.notify-panel {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.notify-panel__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NOTIFY_API } from '../../core/api/notify.client';
|
||||
import { MockNotifyApiService } from '../../testing/mock-notify-api.service';
|
||||
import { NotifyPanelComponent } from './notify-panel.component';
|
||||
|
||||
describe('NotifyPanelComponent', () => {
|
||||
let fixture: ComponentFixture<NotifyPanelComponent>;
|
||||
let component: NotifyPanelComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NotifyPanelComponent],
|
||||
providers: [
|
||||
MockNotifyApiService,
|
||||
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(NotifyPanelComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders channels from the mocked API', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
const items: NodeListOf<HTMLButtonElement> =
|
||||
fixture.nativeElement.querySelectorAll('[data-testid="channel-item"]');
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('persists a new rule via the mocked API', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
|
||||
component.createRuleDraft();
|
||||
component.ruleForm.patchValue({
|
||||
name: 'Notify preview rule',
|
||||
channel: component.channels()[0]?.channelId ?? '',
|
||||
eventKindsText: 'scanner.report.ready',
|
||||
labelsText: 'kev',
|
||||
});
|
||||
|
||||
await component.saveRule();
|
||||
fixture.detectChanges();
|
||||
|
||||
const ruleButtons: HTMLElement[] = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('[data-testid="rule-item"]')
|
||||
);
|
||||
expect(
|
||||
ruleButtons.some((el) => el.textContent?.includes('Notify preview rule'))
|
||||
).toBeTrue();
|
||||
});
|
||||
|
||||
it('shows a test preview after sending', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
|
||||
await component.sendTestPreview();
|
||||
fixture.detectChanges();
|
||||
|
||||
const preview = fixture.nativeElement.querySelector('[data-testid="test-preview"]');
|
||||
expect(preview).toBeTruthy();
|
||||
});
|
||||
});
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NOTIFY_API } from '../../core/api/notify.client';
|
||||
import { MockNotifyApiService } from '../../testing/mock-notify-api.service';
|
||||
import { NotifyPanelComponent } from './notify-panel.component';
|
||||
|
||||
describe('NotifyPanelComponent', () => {
|
||||
let fixture: ComponentFixture<NotifyPanelComponent>;
|
||||
let component: NotifyPanelComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NotifyPanelComponent],
|
||||
providers: [
|
||||
MockNotifyApiService,
|
||||
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(NotifyPanelComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders channels from the mocked API', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
const items: NodeListOf<HTMLButtonElement> =
|
||||
fixture.nativeElement.querySelectorAll('[data-testid="channel-item"]');
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('persists a new rule via the mocked API', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
|
||||
component.createRuleDraft();
|
||||
component.ruleForm.patchValue({
|
||||
name: 'Notify preview rule',
|
||||
channel: component.channels()[0]?.channelId ?? '',
|
||||
eventKindsText: 'scanner.report.ready',
|
||||
labelsText: 'kev',
|
||||
});
|
||||
|
||||
await component.saveRule();
|
||||
fixture.detectChanges();
|
||||
|
||||
const ruleButtons: HTMLElement[] = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('[data-testid="rule-item"]')
|
||||
);
|
||||
expect(
|
||||
ruleButtons.some((el) => el.textContent?.includes('Notify preview rule'))
|
||||
).toBeTrue();
|
||||
});
|
||||
|
||||
it('shows a test preview after sending', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
|
||||
await component.sendTestPreview();
|
||||
fixture.detectChanges();
|
||||
|
||||
const preview = fixture.nativeElement.querySelector('[data-testid="test-preview"]');
|
||||
expect(preview).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,7 +109,7 @@ import { ShadowModeConfig } from '../../core/api/policy-simulation.models';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #a5b4fc;
|
||||
color: #FFCF70;
|
||||
}
|
||||
|
||||
.shadow-indicator--enabled .shadow-indicator__icon {
|
||||
|
||||
@@ -222,7 +222,7 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
|
||||
.approvals__eyebrow {
|
||||
margin: 0;
|
||||
color: #a5b4fc;
|
||||
color: #FFCF70;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -440,7 +440,7 @@ import type { PolicyParseResult, PolicyIntent } from '../../../core/api/advisory
|
||||
}
|
||||
|
||||
.intent-badge.type-ScopeRestriction {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
|
||||
.sim__eyebrow {
|
||||
margin: 0;
|
||||
color: #a5b4fc;
|
||||
color: #FFCF70;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -109,7 +109,7 @@ import { PolicyPackStore } from '../services/policy-pack.store';
|
||||
.workspace__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
|
||||
.pack-card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; box-shadow: 0 12px 30px rgba(0,0,0,0.28); display: grid; gap: 0.6rem; }
|
||||
.pack-card__head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
|
||||
.pack-card__eyebrow { margin: 0; color: #a5b4fc; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
|
||||
.pack-card__eyebrow { margin: 0; color: #FFCF70; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
|
||||
.pack-card__desc { margin: 0.2rem 0 0; color: #cbd5e1; }
|
||||
.pack-card__meta { display: grid; justify-items: end; gap: 0.2rem; color: #94a3b8; font-size: 0.9rem; }
|
||||
.pack-card__tags { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
|
||||
@@ -563,8 +563,8 @@ type SortOrder = 'asc' | 'desc';
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: #4f46e5;
|
||||
border-bottom-color: #4f46e5;
|
||||
color: #F5A623;
|
||||
border-bottom-color: #F5A623;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,7 +582,7 @@ type SortOrder = 'asc' | 'desc';
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-top-color: #4f46e5;
|
||||
border-top-color: #F5A623;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@@ -675,7 +675,7 @@ type SortOrder = 'asc' | 'desc';
|
||||
}
|
||||
|
||||
.policy-studio__link {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -720,13 +720,13 @@ type SortOrder = 'asc' | 'desc';
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
background: #F5A623;
|
||||
border-color: #F5A623;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #4338ca;
|
||||
border-color: #4338ca;
|
||||
background: #E09115;
|
||||
border-color: #E09115;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,8 +762,8 @@ type SortOrder = 'asc' | 'desc';
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
border-color: #F5A623;
|
||||
box-shadow: 0 0 0 3px rgba(245, 166, 35, 0.1);
|
||||
}
|
||||
|
||||
&--sm {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Releases Feature Module
|
||||
* Sprint: SPRINT_20260118_004_FE_releases_feature
|
||||
*/
|
||||
|
||||
export * from './releases.routes';
|
||||
export { ReleaseFlowComponent } from './release-flow.component';
|
||||
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
||||
export { RemediationHintsComponent } from './remediation-hints.component';
|
||||
/**
|
||||
* Releases Feature Module
|
||||
* Sprint: SPRINT_20260118_004_FE_releases_feature
|
||||
*/
|
||||
|
||||
export * from './releases.routes';
|
||||
export { ReleaseFlowComponent } from './release-flow.component';
|
||||
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
||||
export { RemediationHintsComponent } from './remediation-hints.component';
|
||||
|
||||
@@ -1,328 +1,328 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PolicyGateResult,
|
||||
PolicyGateStatus,
|
||||
DeterminismFeatureFlags,
|
||||
} from '../../core/api/release.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-gate-indicator',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div
|
||||
class="gate-indicator"
|
||||
[class.gate-indicator--expanded]="expanded()"
|
||||
[class]="'gate-indicator--' + gate().status"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="gate-header"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
[attr.aria-controls]="'gate-details-' + gate().gateId"
|
||||
>
|
||||
<div class="gate-status">
|
||||
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
|
||||
@switch (gate().status) {
|
||||
@case ('passed') { <span>✓</span> }
|
||||
@case ('failed') { <span>✗</span> }
|
||||
@case ('warning') { <span>!</span> }
|
||||
@case ('pending') { <span>⌛</span> }
|
||||
@case ('skipped') { <span>-</span> }
|
||||
}
|
||||
</span>
|
||||
<span class="status-text">{{ getStatusLabel() }}</span>
|
||||
</div>
|
||||
<div class="gate-info">
|
||||
<span class="gate-name">{{ gate().name }}</span>
|
||||
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
|
||||
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
|
||||
}
|
||||
@if (gate().blockingPublish) {
|
||||
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
|
||||
}
|
||||
</div>
|
||||
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
|
||||
@if (expanded()) {
|
||||
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
|
||||
<p class="gate-message">{{ gate().message }}</p>
|
||||
<div class="gate-meta">
|
||||
<span class="meta-item">
|
||||
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
|
||||
</span>
|
||||
@if (gate().evidence?.url) {
|
||||
<a
|
||||
[href]="gate().evidence?.url"
|
||||
class="evidence-link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
View Evidence
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Determinism-specific info when feature flag shows it -->
|
||||
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
|
||||
<div class="feature-flag-info">
|
||||
@if (featureFlags()?.blockOnFailure) {
|
||||
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
|
||||
} @else if (featureFlags()?.warnOnly) {
|
||||
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.gate-indicator {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&--passed {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
border-left: 3px solid #f97316;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
border-left: 3px solid #eab308;
|
||||
}
|
||||
|
||||
&--skipped {
|
||||
border-left: 3px solid #64748b;
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
border-color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.gate-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.gate-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-icon {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.gate-indicator--failed .status-icon {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.gate-indicator--warning .status-icon {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.gate-indicator--pending .status-icon {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.gate-indicator--skipped .status-icon {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-text { color: #22c55e; }
|
||||
.gate-indicator--failed .status-text { color: #ef4444; }
|
||||
.gate-indicator--warning .status-text { color: #f97316; }
|
||||
.gate-indicator--pending .status-text { color: #eab308; }
|
||||
.gate-indicator--skipped .status-text { color: #64748b; }
|
||||
|
||||
.gate-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.gate-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gate-type-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--determinism {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
}
|
||||
|
||||
.blocking-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #64748b;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.gate-details {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-top: 1px solid #334155;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.gate-message {
|
||||
margin: 0.75rem 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gate-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
|
||||
strong {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-flag-info {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.flag-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--active {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PolicyGateIndicatorComponent {
|
||||
readonly gate = input.required<PolicyGateResult>();
|
||||
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
|
||||
|
||||
readonly expanded = signal(false);
|
||||
|
||||
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
|
||||
getStatusLabel(): string {
|
||||
const labels: Record<PolicyGateStatus, string> = {
|
||||
passed: 'Passed',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
warning: 'Warning',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return labels[this.gate().status] ?? 'Unknown';
|
||||
}
|
||||
|
||||
getStatusIconClass(): string {
|
||||
return `status-icon--${this.gate().status}`;
|
||||
}
|
||||
|
||||
formatDate(isoString: string): string {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PolicyGateResult,
|
||||
PolicyGateStatus,
|
||||
DeterminismFeatureFlags,
|
||||
} from '../../core/api/release.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-gate-indicator',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div
|
||||
class="gate-indicator"
|
||||
[class.gate-indicator--expanded]="expanded()"
|
||||
[class]="'gate-indicator--' + gate().status"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="gate-header"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
[attr.aria-controls]="'gate-details-' + gate().gateId"
|
||||
>
|
||||
<div class="gate-status">
|
||||
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
|
||||
@switch (gate().status) {
|
||||
@case ('passed') { <span>✓</span> }
|
||||
@case ('failed') { <span>✗</span> }
|
||||
@case ('warning') { <span>!</span> }
|
||||
@case ('pending') { <span>⌛</span> }
|
||||
@case ('skipped') { <span>-</span> }
|
||||
}
|
||||
</span>
|
||||
<span class="status-text">{{ getStatusLabel() }}</span>
|
||||
</div>
|
||||
<div class="gate-info">
|
||||
<span class="gate-name">{{ gate().name }}</span>
|
||||
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
|
||||
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
|
||||
}
|
||||
@if (gate().blockingPublish) {
|
||||
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
|
||||
}
|
||||
</div>
|
||||
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
|
||||
@if (expanded()) {
|
||||
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
|
||||
<p class="gate-message">{{ gate().message }}</p>
|
||||
<div class="gate-meta">
|
||||
<span class="meta-item">
|
||||
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
|
||||
</span>
|
||||
@if (gate().evidence?.url) {
|
||||
<a
|
||||
[href]="gate().evidence?.url"
|
||||
class="evidence-link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
View Evidence
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Determinism-specific info when feature flag shows it -->
|
||||
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
|
||||
<div class="feature-flag-info">
|
||||
@if (featureFlags()?.blockOnFailure) {
|
||||
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
|
||||
} @else if (featureFlags()?.warnOnly) {
|
||||
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.gate-indicator {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&--passed {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
border-left: 3px solid #f97316;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
border-left: 3px solid #eab308;
|
||||
}
|
||||
|
||||
&--skipped {
|
||||
border-left: 3px solid #64748b;
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
border-color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.gate-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.gate-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-icon {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.gate-indicator--failed .status-icon {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.gate-indicator--warning .status-icon {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.gate-indicator--pending .status-icon {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.gate-indicator--skipped .status-icon {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-text { color: #22c55e; }
|
||||
.gate-indicator--failed .status-text { color: #ef4444; }
|
||||
.gate-indicator--warning .status-text { color: #f97316; }
|
||||
.gate-indicator--pending .status-text { color: #eab308; }
|
||||
.gate-indicator--skipped .status-text { color: #64748b; }
|
||||
|
||||
.gate-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.gate-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gate-type-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--determinism {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
}
|
||||
|
||||
.blocking-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #64748b;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.gate-details {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-top: 1px solid #334155;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.gate-message {
|
||||
margin: 0.75rem 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gate-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
|
||||
strong {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-flag-info {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.flag-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--active {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PolicyGateIndicatorComponent {
|
||||
readonly gate = input.required<PolicyGateResult>();
|
||||
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
|
||||
|
||||
readonly expanded = signal(false);
|
||||
|
||||
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
|
||||
getStatusLabel(): string {
|
||||
const labels: Record<PolicyGateStatus, string> = {
|
||||
passed: 'Passed',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
warning: 'Warning',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return labels[this.gate().status] ?? 'Unknown';
|
||||
}
|
||||
|
||||
getStatusIconClass(): string {
|
||||
return `status-icon--${this.gate().status}`;
|
||||
}
|
||||
|
||||
formatDate(isoString: string): string {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user