up tests and theme
This commit is contained in:
@@ -33,6 +33,7 @@ public sealed class EvidenceLockerIntegrationTests : IDisposable
|
|||||||
public EvidenceLockerIntegrationTests(EvidenceLockerWebApplicationFactory factory)
|
public EvidenceLockerIntegrationTests(EvidenceLockerWebApplicationFactory factory)
|
||||||
{
|
{
|
||||||
_factory = factory;
|
_factory = factory;
|
||||||
|
_factory.ResetTestState();
|
||||||
_client = _factory.CreateClient();
|
_client = _factory.CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ public sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<
|
|||||||
|
|
||||||
public TestTimelinePublisher TimelinePublisher => Services.GetRequiredService<TestTimelinePublisher>();
|
public TestTimelinePublisher TimelinePublisher => Services.GetRequiredService<TestTimelinePublisher>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resets all singleton test doubles to prevent accumulated state from
|
||||||
|
/// leaking memory across test classes sharing this factory instance.
|
||||||
|
/// Call from each test class constructor.
|
||||||
|
/// </summary>
|
||||||
|
public void ResetTestState()
|
||||||
|
{
|
||||||
|
Repository.Reset();
|
||||||
|
ObjectStore.Reset();
|
||||||
|
TimelinePublisher.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
private static SigningKeyMaterialOptions GenerateKeyMaterial()
|
private static SigningKeyMaterialOptions GenerateKeyMaterial()
|
||||||
{
|
{
|
||||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
@@ -164,6 +176,12 @@ public sealed class TestTimelinePublisher : IEvidenceTimelinePublisher
|
|||||||
public List<string> PublishedEvents { get; } = new();
|
public List<string> PublishedEvents { get; } = new();
|
||||||
public List<string> IncidentEvents { get; } = new();
|
public List<string> IncidentEvents { get; } = new();
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
PublishedEvents.Clear();
|
||||||
|
IncidentEvents.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
public Task PublishBundleSealedAsync(
|
public Task PublishBundleSealedAsync(
|
||||||
EvidenceBundleSignature signature,
|
EvidenceBundleSignature signature,
|
||||||
EvidenceBundleManifest manifest,
|
EvidenceBundleManifest manifest,
|
||||||
@@ -196,6 +214,12 @@ public sealed class TestEvidenceObjectStore : IEvidenceObjectStore
|
|||||||
|
|
||||||
public void SeedExisting(string storageKey) => _preExisting.Add(storageKey);
|
public void SeedExisting(string storageKey) => _preExisting.Add(storageKey);
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_objects.Clear();
|
||||||
|
_preExisting.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
|
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var memory = new MemoryStream();
|
using var memory = new MemoryStream();
|
||||||
@@ -235,6 +259,13 @@ public sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
|
|||||||
|
|
||||||
public bool HoldConflict { get; set; }
|
public bool HoldConflict { get; set; }
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_signatures.Clear();
|
||||||
|
_bundles.Clear();
|
||||||
|
HoldConflict = false;
|
||||||
|
}
|
||||||
|
|
||||||
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
|
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_bundles[(bundle.Id.Value, bundle.TenantId.Value)] = bundle;
|
_bundles[(bundle.Id.Value, bundle.TenantId.Value)] = bundle;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
|
|||||||
public EvidenceLockerWebServiceContractTests(EvidenceLockerWebApplicationFactory factory)
|
public EvidenceLockerWebServiceContractTests(EvidenceLockerWebApplicationFactory factory)
|
||||||
{
|
{
|
||||||
_factory = factory;
|
_factory = factory;
|
||||||
|
_factory.ResetTestState();
|
||||||
_client = factory.CreateClient();
|
_client = factory.CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +323,7 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
|
|||||||
var tenantId = Guid.NewGuid().ToString("D");
|
var tenantId = Guid.NewGuid().ToString("D");
|
||||||
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceCreate);
|
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceCreate);
|
||||||
|
|
||||||
var listener = new ActivityListener
|
using var listener = new ActivityListener
|
||||||
{
|
{
|
||||||
ShouldListenTo = source => source.Name.Contains("StellaOps", StringComparison.OrdinalIgnoreCase),
|
ShouldListenTo = source => source.Name.Contains("StellaOps", StringComparison.OrdinalIgnoreCase),
|
||||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||||
@@ -359,8 +360,6 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
|
|||||||
.FirstOrDefault(e => e.Contains(bundleId!, StringComparison.Ordinal));
|
.FirstOrDefault(e => e.Contains(bundleId!, StringComparison.Ordinal));
|
||||||
timelineEvent.Should().NotBeNull($"expected a timeline event containing bundleId {bundleId}");
|
timelineEvent.Should().NotBeNull($"expected a timeline event containing bundleId {bundleId}");
|
||||||
timelineEvent.Should().Contain(bundleId!);
|
timelineEvent.Should().Contain(bundleId!);
|
||||||
|
|
||||||
listener.Dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Trait("Category", TestCategories.Integration)]
|
[Trait("Category", TestCategories.Integration)]
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public sealed class EvidenceLockerWebServiceTests : IDisposable
|
|||||||
public EvidenceLockerWebServiceTests(EvidenceLockerWebApplicationFactory factory)
|
public EvidenceLockerWebServiceTests(EvidenceLockerWebApplicationFactory factory)
|
||||||
{
|
{
|
||||||
_factory = factory;
|
_factory = factory;
|
||||||
|
_factory.ResetTestState();
|
||||||
_client = factory.CreateClient();
|
_client = factory.CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public sealed class EvidenceReindexIntegrationTests : IDisposable
|
|||||||
public EvidenceReindexIntegrationTests(EvidenceLockerWebApplicationFactory factory)
|
public EvidenceReindexIntegrationTests(EvidenceLockerWebApplicationFactory factory)
|
||||||
{
|
{
|
||||||
_factory = factory;
|
_factory = factory;
|
||||||
|
_factory.ResetTestState();
|
||||||
_client = _factory.CreateClient();
|
_client = _factory.CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,19 +20,18 @@ namespace StellaOps.EvidenceLocker.Tests;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Integration tests for export API endpoints.
|
/// Integration tests for export API endpoints.
|
||||||
/// Uses the shared EvidenceLockerWebApplicationFactory (via Collection fixture)
|
/// Uses a single derived WebApplicationFactory for the entire class (via IClassFixture)
|
||||||
/// instead of raw WebApplicationFactory<Program> to avoid loading real
|
/// to avoid creating a new TestServer per test, which previously leaked memory.
|
||||||
/// infrastructure services (database, auth, background services) which causes
|
|
||||||
/// the test process to hang and consume excessive memory.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection(EvidenceLockerTestCollection.Name)]
|
public sealed class ExportEndpointsTests : IClassFixture<ExportEndpointsTests.ExportTestFixture>, IDisposable
|
||||||
public sealed class ExportEndpointsTests
|
|
||||||
{
|
{
|
||||||
private readonly EvidenceLockerWebApplicationFactory _factory;
|
private readonly ExportTestFixture _fixture;
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
public ExportEndpointsTests(EvidenceLockerWebApplicationFactory factory)
|
public ExportEndpointsTests(ExportTestFixture fixture)
|
||||||
{
|
{
|
||||||
_factory = factory;
|
_fixture = fixture;
|
||||||
|
_client = fixture.DerivedFactory.CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -52,11 +51,11 @@ public sealed class ExportEndpointsTests
|
|||||||
EstimatedSize = 1024
|
EstimatedSize = 1024
|
||||||
});
|
});
|
||||||
|
|
||||||
using var scope = CreateClientWithMock(mockService.Object);
|
_fixture.CurrentMock = mockService.Object;
|
||||||
var request = new ExportTriggerRequest();
|
var request = new ExportTriggerRequest();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await scope.Client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
|
var response = await _client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||||
@@ -79,11 +78,11 @@ public sealed class ExportEndpointsTests
|
|||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new ExportJobResult { IsNotFound = true });
|
.ReturnsAsync(new ExportJobResult { IsNotFound = true });
|
||||||
|
|
||||||
using var scope = CreateClientWithMock(mockService.Object);
|
_fixture.CurrentMock = mockService.Object;
|
||||||
var request = new ExportTriggerRequest();
|
var request = new ExportTriggerRequest();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await scope.Client.PostAsJsonAsync("/api/v1/bundles/nonexistent/export", request);
|
var response = await _client.PostAsJsonAsync("/api/v1/bundles/nonexistent/export", request);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
@@ -104,10 +103,10 @@ public sealed class ExportEndpointsTests
|
|||||||
CompletedAt = DateTimeOffset.Parse("2026-01-07T12:00:00Z")
|
CompletedAt = DateTimeOffset.Parse("2026-01-07T12:00:00Z")
|
||||||
});
|
});
|
||||||
|
|
||||||
using var scope = CreateClientWithMock(mockService.Object);
|
_fixture.CurrentMock = mockService.Object;
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
|
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
@@ -133,10 +132,10 @@ public sealed class ExportEndpointsTests
|
|||||||
EstimatedTimeRemaining = "30s"
|
EstimatedTimeRemaining = "30s"
|
||||||
});
|
});
|
||||||
|
|
||||||
using var scope = CreateClientWithMock(mockService.Object);
|
_fixture.CurrentMock = mockService.Object;
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
|
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||||
@@ -156,10 +155,10 @@ public sealed class ExportEndpointsTests
|
|||||||
.Setup(s => s.GetExportStatusAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
|
.Setup(s => s.GetExportStatusAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((ExportJobStatus?)null);
|
.ReturnsAsync((ExportJobStatus?)null);
|
||||||
|
|
||||||
using var scope = CreateClientWithMock(mockService.Object);
|
_fixture.CurrentMock = mockService.Object;
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent");
|
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
@@ -181,10 +180,10 @@ public sealed class ExportEndpointsTests
|
|||||||
FileName = "evidence-bundle-123.tar.gz"
|
FileName = "evidence-bundle-123.tar.gz"
|
||||||
});
|
});
|
||||||
|
|
||||||
using var scope = CreateClientWithMock(mockService.Object);
|
_fixture.CurrentMock = mockService.Object;
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
|
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
@@ -203,10 +202,10 @@ public sealed class ExportEndpointsTests
|
|||||||
Status = ExportJobStatusEnum.Processing
|
Status = ExportJobStatusEnum.Processing
|
||||||
});
|
});
|
||||||
|
|
||||||
using var scope = CreateClientWithMock(mockService.Object);
|
_fixture.CurrentMock = mockService.Object;
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
|
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||||
@@ -221,10 +220,10 @@ public sealed class ExportEndpointsTests
|
|||||||
.Setup(s => s.GetExportFileAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
|
.Setup(s => s.GetExportFileAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((ExportFileResult?)null);
|
.ReturnsAsync((ExportFileResult?)null);
|
||||||
|
|
||||||
using var scope = CreateClientWithMock(mockService.Object);
|
_fixture.CurrentMock = mockService.Object;
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent/download");
|
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent/download");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
@@ -248,7 +247,7 @@ public sealed class ExportEndpointsTests
|
|||||||
Status = "pending"
|
Status = "pending"
|
||||||
});
|
});
|
||||||
|
|
||||||
using var scope = CreateClientWithMock(mockService.Object);
|
_fixture.CurrentMock = mockService.Object;
|
||||||
var request = new ExportTriggerRequest
|
var request = new ExportTriggerRequest
|
||||||
{
|
{
|
||||||
CompressionLevel = 9,
|
CompressionLevel = 9,
|
||||||
@@ -257,7 +256,7 @@ public sealed class ExportEndpointsTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await scope.Client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
|
await _client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(capturedRequest);
|
Assert.NotNull(capturedRequest);
|
||||||
@@ -266,52 +265,76 @@ public sealed class ExportEndpointsTests
|
|||||||
Assert.False(capturedRequest.IncludeRekorProofs);
|
Assert.False(capturedRequest.IncludeRekorProofs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public void Dispose()
|
||||||
/// Wraps a derived WebApplicationFactory and HttpClient so both are disposed together.
|
|
||||||
/// Previously, WithWebHostBuilder() created a new factory per test that was never disposed,
|
|
||||||
/// leaking TestServer instances and consuming gigabytes of memory.
|
|
||||||
/// </summary>
|
|
||||||
/// <summary>
|
|
||||||
/// Wraps a derived WebApplicationFactory and HttpClient so both are disposed together.
|
|
||||||
/// Previously, WithWebHostBuilder() created a new factory per test that was never disposed,
|
|
||||||
/// leaking TestServer instances and consuming gigabytes of memory.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class MockScope : IDisposable
|
|
||||||
{
|
{
|
||||||
private readonly WebApplicationFactory<EvidenceLockerProgram> _derivedFactory;
|
_client.Dispose();
|
||||||
public HttpClient Client { get; }
|
}
|
||||||
|
|
||||||
public MockScope(WebApplicationFactory<EvidenceLockerProgram> derivedFactory)
|
/// <summary>
|
||||||
|
/// Fixture that creates a single derived WebApplicationFactory with a swappable
|
||||||
|
/// IExportJobService mock. Tests set <see cref="CurrentMock"/> before each request
|
||||||
|
/// instead of creating a new factory per test. This eliminates 9 TestServer instances
|
||||||
|
/// that were previously leaking memory.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportTestFixture : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The current mock to delegate to. Set by each test before making requests.
|
||||||
|
/// </summary>
|
||||||
|
public IExportJobService CurrentMock { get; set; } = new Mock<IExportJobService>().Object;
|
||||||
|
|
||||||
|
public WebApplicationFactory<EvidenceLockerProgram> DerivedFactory { get; }
|
||||||
|
|
||||||
|
public ExportTestFixture()
|
||||||
{
|
{
|
||||||
_derivedFactory = derivedFactory;
|
// Create ONE derived factory whose IExportJobService delegates to CurrentMock.
|
||||||
Client = derivedFactory.CreateClient();
|
// This avoids creating a new TestServer per test.
|
||||||
|
var baseFactory = new EvidenceLockerWebApplicationFactory();
|
||||||
|
DerivedFactory = baseFactory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
var descriptor = services.SingleOrDefault(
|
||||||
|
d => d.ServiceType == typeof(IExportJobService));
|
||||||
|
if (descriptor != null)
|
||||||
|
{
|
||||||
|
services.Remove(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a delegating wrapper that forwards to CurrentMock,
|
||||||
|
// allowing each test to swap the mock without a new factory.
|
||||||
|
services.AddSingleton<IExportJobService>(sp =>
|
||||||
|
new DelegatingExportJobService(this));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
Client.Dispose();
|
DerivedFactory.Dispose();
|
||||||
_derivedFactory.Dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private MockScope CreateClientWithMock(IExportJobService mockService)
|
/// <summary>
|
||||||
|
/// Thin delegate that forwards all calls to the fixture's current mock,
|
||||||
|
/// allowing per-test mock swapping without creating new WebApplicationFactory instances.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class DelegatingExportJobService : IExportJobService
|
||||||
{
|
{
|
||||||
var derivedFactory = _factory.WithWebHostBuilder(builder =>
|
private readonly ExportTestFixture _fixture;
|
||||||
{
|
|
||||||
builder.ConfigureServices(services =>
|
|
||||||
{
|
|
||||||
// Remove existing registration
|
|
||||||
var descriptor = services.SingleOrDefault(
|
|
||||||
d => d.ServiceType == typeof(IExportJobService));
|
|
||||||
if (descriptor != null)
|
|
||||||
{
|
|
||||||
services.Remove(descriptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add mock
|
public DelegatingExportJobService(ExportTestFixture fixture)
|
||||||
services.AddSingleton(mockService);
|
{
|
||||||
});
|
_fixture = fixture;
|
||||||
});
|
}
|
||||||
return new MockScope(derivedFactory);
|
|
||||||
|
public Task<ExportJobResult> CreateExportJobAsync(string bundleId, ExportTriggerRequest request, CancellationToken cancellationToken)
|
||||||
|
=> _fixture.CurrentMock.CreateExportJobAsync(bundleId, request, cancellationToken);
|
||||||
|
|
||||||
|
public Task<ExportJobStatus?> GetExportStatusAsync(string bundleId, string exportId, CancellationToken cancellationToken)
|
||||||
|
=> _fixture.CurrentMock.GetExportStatusAsync(bundleId, exportId, cancellationToken);
|
||||||
|
|
||||||
|
public Task<ExportFileResult?> GetExportFileAsync(string bundleId, string exportId, CancellationToken cancellationToken)
|
||||||
|
=> _fixture.CurrentMock.GetExportFileAsync(bundleId, exportId, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,11 +56,16 @@ public sealed class PostgreSqlFixture : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
{
|
{
|
||||||
// On Windows, try to open the Docker named pipe with a short timeout.
|
// Check if the Docker daemon is actually running by looking for its process.
|
||||||
// File.Exists does not work for named pipes.
|
// NamedPipeClientStream.Connect() hangs indefinitely when Docker Desktop is
|
||||||
using var pipe = new System.IO.Pipes.NamedPipeClientStream(".", "docker_engine", System.IO.Pipes.PipeDirection.InOut, System.IO.Pipes.PipeOptions.None);
|
// installed but not running (the pipe exists but nobody reads from it).
|
||||||
pipe.Connect(2000); // 2 second timeout
|
// Testcontainers' own Docker client also hangs in this scenario.
|
||||||
return true;
|
// Checking for a running process is instant and avoids the hang entirely.
|
||||||
|
var dockerProcesses = System.Diagnostics.Process.GetProcessesByName("com.docker.backend");
|
||||||
|
if (dockerProcesses.Length == 0)
|
||||||
|
dockerProcesses = System.Diagnostics.Process.GetProcessesByName("dockerd");
|
||||||
|
foreach (var p in dockerProcesses) p.Dispose();
|
||||||
|
return dockerProcesses.Length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On Linux/macOS, check for the Docker socket
|
// On Linux/macOS, check for the Docker socket
|
||||||
|
|||||||
@@ -1,174 +1,175 @@
|
|||||||
/**
|
/**
|
||||||
* App Component Styles
|
* App Component Styles
|
||||||
* Migrated to design system tokens
|
* Migrated to design system tokens
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
font-family: var(--font-family-base);
|
font-family: var(--font-family-base);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background-color: var(--color-surface-secondary);
|
background-color: var(--color-surface-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quickstart-banner {
|
.quickstart-banner {
|
||||||
background: var(--color-status-warning-bg);
|
background: var(--color-status-warning-bg);
|
||||||
color: var(--color-status-warning-text);
|
color: var(--color-status-warning-text);
|
||||||
padding: var(--space-3) var(--space-6);
|
padding: var(--space-3) var(--space-6);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
border-bottom: 1px solid var(--color-status-warning-border);
|
border-bottom: 1px solid var(--color-status-warning-border);
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding: var(--space-3) var(--space-6);
|
padding: var(--space-3) var(--space-6);
|
||||||
background: var(--color-header-bg);
|
background: var(--color-header-bg);
|
||||||
color: var(--color-header-text);
|
color: var(--color-header-text);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
|
backdrop-filter: blur(16px) saturate(1.2);
|
||||||
// Navigation takes remaining space
|
|
||||||
app-navigation-menu {
|
// Navigation takes remaining space
|
||||||
flex: 1;
|
app-navigation-menu {
|
||||||
display: flex;
|
flex: 1;
|
||||||
justify-content: flex-start;
|
display: flex;
|
||||||
margin-left: var(--space-4);
|
justify-content: flex-start;
|
||||||
}
|
margin-left: var(--space-4);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.app-brand {
|
|
||||||
font-size: var(--font-size-lg);
|
.app-brand {
|
||||||
font-weight: var(--font-weight-semibold);
|
font-size: var(--font-size-lg);
|
||||||
letter-spacing: 0.02em;
|
font-weight: var(--font-weight-semibold);
|
||||||
color: inherit;
|
letter-spacing: 0.02em;
|
||||||
text-decoration: none;
|
color: inherit;
|
||||||
flex-shrink: 0;
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
&:hover {
|
|
||||||
opacity: 0.9;
|
&:hover {
|
||||||
}
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.app-auth {
|
|
||||||
display: flex;
|
.app-auth {
|
||||||
align-items: center;
|
display: flex;
|
||||||
gap: var(--space-3);
|
align-items: center;
|
||||||
flex-shrink: 0;
|
gap: var(--space-3);
|
||||||
|
flex-shrink: 0;
|
||||||
.app-tenant {
|
|
||||||
font-size: var(--font-size-xs);
|
.app-tenant {
|
||||||
padding: var(--space-1) var(--space-2);
|
font-size: var(--font-size-xs);
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
padding: var(--space-1) var(--space-2);
|
||||||
border-radius: var(--radius-xs);
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
color: var(--color-header-text-muted);
|
border-radius: var(--radius-xs);
|
||||||
|
color: var(--color-header-text-muted);
|
||||||
@media (max-width: 768px) {
|
|
||||||
display: none;
|
@media (max-width: 768px) {
|
||||||
}
|
display: none;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.app-fresh {
|
|
||||||
display: inline-flex;
|
.app-fresh {
|
||||||
align-items: center;
|
display: inline-flex;
|
||||||
gap: var(--space-1);
|
align-items: center;
|
||||||
padding: var(--space-0-5) var(--space-2-5);
|
gap: var(--space-1);
|
||||||
border-radius: var(--radius-full);
|
padding: var(--space-0-5) var(--space-2-5);
|
||||||
font-size: 0.7rem;
|
border-radius: var(--radius-full);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-size: 0.7rem;
|
||||||
letter-spacing: 0.03em;
|
font-weight: var(--font-weight-semibold);
|
||||||
background-color: var(--color-fresh-active-bg);
|
letter-spacing: 0.03em;
|
||||||
color: var(--color-fresh-active-text);
|
background-color: var(--color-fresh-active-bg);
|
||||||
|
color: var(--color-fresh-active-text);
|
||||||
@media (max-width: 768px) {
|
|
||||||
display: none;
|
@media (max-width: 768px) {
|
||||||
}
|
display: none;
|
||||||
|
}
|
||||||
&.app-fresh--stale {
|
|
||||||
background-color: var(--color-fresh-stale-bg);
|
&.app-fresh--stale {
|
||||||
color: var(--color-fresh-stale-text);
|
background-color: var(--color-fresh-stale-bg);
|
||||||
}
|
color: var(--color-fresh-stale-text);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
&__signin {
|
|
||||||
appearance: none;
|
&__signin {
|
||||||
border: none;
|
appearance: none;
|
||||||
border-radius: var(--radius-sm);
|
border: none;
|
||||||
padding: var(--space-2) var(--space-4);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-sm);
|
padding: var(--space-2) var(--space-4);
|
||||||
font-weight: var(--font-weight-medium);
|
font-size: var(--font-size-sm);
|
||||||
cursor: pointer;
|
font-weight: var(--font-weight-medium);
|
||||||
color: var(--color-surface-inverse);
|
cursor: pointer;
|
||||||
background-color: rgba(248, 250, 252, 0.9);
|
color: var(--color-surface-inverse);
|
||||||
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
|
background-color: rgba(248, 250, 252, 0.9);
|
||||||
background-color var(--motion-duration-fast) var(--motion-ease-default);
|
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
|
||||||
|
background-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||||
&:hover,
|
|
||||||
&:focus-visible {
|
&:hover,
|
||||||
background-color: var(--color-accent-yellow);
|
&:focus-visible {
|
||||||
transform: translateY(-1px);
|
background-color: var(--color-accent-yellow);
|
||||||
}
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-brand-primary);
|
&:focus-visible {
|
||||||
outline-offset: 2px;
|
outline: 2px solid var(--color-brand-primary);
|
||||||
}
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.app-content {
|
|
||||||
flex: 1;
|
.app-content {
|
||||||
padding: var(--space-6) var(--space-6) var(--space-8);
|
flex: 1;
|
||||||
max-width: 1200px;
|
padding: var(--space-6) var(--space-6) var(--space-8);
|
||||||
width: 100%;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
// Breadcrumb styling
|
|
||||||
app-breadcrumb {
|
// Breadcrumb styling
|
||||||
display: block;
|
app-breadcrumb {
|
||||||
margin-bottom: var(--space-3);
|
display: block;
|
||||||
}
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Page container with transition animations
|
|
||||||
.page-container {
|
// Page container with transition animations
|
||||||
animation: page-fade-in var(--motion-duration-slow) var(--motion-ease-spring);
|
.page-container {
|
||||||
}
|
animation: page-fade-in var(--motion-duration-slow) var(--motion-ease-spring);
|
||||||
|
}
|
||||||
@keyframes page-fade-in {
|
|
||||||
from {
|
@keyframes page-fade-in {
|
||||||
opacity: 0;
|
from {
|
||||||
transform: translateY(8px);
|
opacity: 0;
|
||||||
}
|
transform: translateY(8px);
|
||||||
to {
|
}
|
||||||
opacity: 1;
|
to {
|
||||||
transform: translateY(0);
|
opacity: 1;
|
||||||
}
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Respect reduced motion preference
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
// Respect reduced motion preference
|
||||||
.page-container {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
animation: none;
|
.page-container {
|
||||||
}
|
animation: none;
|
||||||
|
}
|
||||||
.app-auth__signin {
|
|
||||||
transition: none;
|
.app-auth__signin {
|
||||||
|
transition: none;
|
||||||
&:hover {
|
|
||||||
transform: none;
|
&:hover {
|
||||||
}
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,54 +1,54 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { RouterTestingModule } from '@angular/router/testing';
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { AuthorityAuthService } from './core/auth/authority-auth.service';
|
import { AuthorityAuthService } from './core/auth/authority-auth.service';
|
||||||
import { AuthSessionStore } from './core/auth/auth-session.store';
|
import { AuthSessionStore } from './core/auth/auth-session.store';
|
||||||
import { AUTH_SERVICE, AuthService } from './core/auth';
|
import { AUTH_SERVICE, AuthService } from './core/auth';
|
||||||
import { ConsoleSessionStore } from './core/console/console-session.store';
|
import { ConsoleSessionStore } from './core/console/console-session.store';
|
||||||
import { AppConfigService } from './core/config/app-config.service';
|
import { AppConfigService } from './core/config/app-config.service';
|
||||||
import { PolicyPackStore } from './features/policy-studio/services/policy-pack.store';
|
import { PolicyPackStore } from './features/policy-studio/services/policy-pack.store';
|
||||||
|
|
||||||
class AuthorityAuthServiceStub {
|
class AuthorityAuthServiceStub {
|
||||||
beginLogin = jasmine.createSpy('beginLogin');
|
beginLogin = jasmine.createSpy('beginLogin');
|
||||||
logout = jasmine.createSpy('logout');
|
logout = jasmine.createSpy('logout');
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AppComponent', () => {
|
describe('AppComponent', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [AppComponent, RouterTestingModule, HttpClientTestingModule],
|
imports: [AppComponent, RouterTestingModule, HttpClientTestingModule],
|
||||||
providers: [
|
providers: [
|
||||||
AuthSessionStore,
|
AuthSessionStore,
|
||||||
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
|
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
|
||||||
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
|
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
|
||||||
ConsoleSessionStore,
|
ConsoleSessionStore,
|
||||||
{ provide: AppConfigService, useValue: { config: { quickstartMode: false, apiBaseUrls: { authority: '', policy: '' } } } },
|
{ provide: AppConfigService, useValue: { config: { quickstartMode: false, apiBaseUrls: { authority: '', policy: '' } } } },
|
||||||
{
|
{
|
||||||
provide: PolicyPackStore,
|
provide: PolicyPackStore,
|
||||||
useValue: {
|
useValue: {
|
||||||
getPacks: () =>
|
getPacks: () =>
|
||||||
of([
|
of([
|
||||||
{ id: 'pack-1', name: 'Pack One', description: '', version: '1.0', status: 'active', createdAt: '', modifiedAt: '', createdBy: '', modifiedBy: '', tags: [] },
|
{ id: 'pack-1', name: 'Pack One', description: '', version: '1.0', status: 'active', createdAt: '', modifiedAt: '', createdBy: '', modifiedBy: '', tags: [] },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates the root component', () => {
|
it('creates the root component', () => {
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
expect(app).toBeTruthy();
|
expect(app).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a router outlet for child routes', () => {
|
it('renders a router outlet for child routes', () => {
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
expect(compiled.querySelector('router-outlet')).not.toBeNull();
|
expect(compiled.querySelector('router-outlet')).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,382 +1,382 @@
|
|||||||
/**
|
/**
|
||||||
* AOC (Authorization of Containers) models for dashboard metrics.
|
* AOC (Authorization of Containers) models for dashboard metrics.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface AocMetrics {
|
export interface AocMetrics {
|
||||||
/** Pass/fail counts for the time window */
|
/** Pass/fail counts for the time window */
|
||||||
passCount: number;
|
passCount: number;
|
||||||
failCount: number;
|
failCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
passRate: number;
|
passRate: number;
|
||||||
|
|
||||||
/** Recent violations grouped by code */
|
/** Recent violations grouped by code */
|
||||||
recentViolations: AocViolationSummary[];
|
recentViolations: AocViolationSummary[];
|
||||||
|
|
||||||
/** Ingest throughput metrics */
|
/** Ingest throughput metrics */
|
||||||
ingestThroughput: AocIngestThroughput;
|
ingestThroughput: AocIngestThroughput;
|
||||||
|
|
||||||
/** Time window for these metrics */
|
/** Time window for these metrics */
|
||||||
timeWindow: {
|
timeWindow: {
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AocViolationSummary {
|
export interface AocViolationSummary {
|
||||||
code: string;
|
code: string;
|
||||||
description: string;
|
description: string;
|
||||||
count: number;
|
count: number;
|
||||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AocIngestThroughput {
|
export interface AocIngestThroughput {
|
||||||
/** Documents processed per minute */
|
/** Documents processed per minute */
|
||||||
docsPerMinute: number;
|
docsPerMinute: number;
|
||||||
/** Average processing latency in milliseconds */
|
/** Average processing latency in milliseconds */
|
||||||
avgLatencyMs: number;
|
avgLatencyMs: number;
|
||||||
/** P95 latency in milliseconds */
|
/** P95 latency in milliseconds */
|
||||||
p95LatencyMs: number;
|
p95LatencyMs: number;
|
||||||
/** Current queue depth */
|
/** Current queue depth */
|
||||||
queueDepth: number;
|
queueDepth: number;
|
||||||
/** Error rate percentage */
|
/** Error rate percentage */
|
||||||
errorRate: number;
|
errorRate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AocVerificationRequest {
|
export interface AocVerificationRequest {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
since?: string;
|
since?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AocVerificationResult {
|
export interface AocVerificationResult {
|
||||||
verificationId: string;
|
verificationId: string;
|
||||||
status: 'passed' | 'failed' | 'partial';
|
status: 'passed' | 'failed' | 'partial';
|
||||||
checkedCount: number;
|
checkedCount: number;
|
||||||
passedCount: number;
|
passedCount: number;
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
violations: AocViolationDetail[];
|
violations: AocViolationDetail[];
|
||||||
completedAt: string;
|
completedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AocViolationDetail {
|
export interface AocViolationDetail {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
violationCode: string;
|
violationCode: string;
|
||||||
field?: string;
|
field?: string;
|
||||||
expected?: string;
|
expected?: string;
|
||||||
actual?: string;
|
actual?: string;
|
||||||
provenance?: AocProvenance;
|
provenance?: AocProvenance;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AocProvenance {
|
export interface AocProvenance {
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
ingestedAt: string;
|
ingestedAt: string;
|
||||||
digest: string;
|
digest: string;
|
||||||
sourceType?: 'registry' | 'git' | 'upload' | 'api';
|
sourceType?: 'registry' | 'git' | 'upload' | 'api';
|
||||||
sourceUrl?: string;
|
sourceUrl?: string;
|
||||||
submitter?: string;
|
submitter?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AocViolationGroup {
|
export interface AocViolationGroup {
|
||||||
code: string;
|
code: string;
|
||||||
description: string;
|
description: string;
|
||||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
violations: AocViolationDetail[];
|
violations: AocViolationDetail[];
|
||||||
affectedDocuments: number;
|
affectedDocuments: number;
|
||||||
remediation?: string;
|
remediation?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AocDocumentView {
|
export interface AocDocumentView {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
documentType: string;
|
documentType: string;
|
||||||
violations: AocViolationDetail[];
|
violations: AocViolationDetail[];
|
||||||
provenance: AocProvenance;
|
provenance: AocProvenance;
|
||||||
rawContent?: Record<string, unknown>;
|
rawContent?: Record<string, unknown>;
|
||||||
highlightedFields: string[];
|
highlightedFields: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Violation severity levels.
|
* Violation severity levels.
|
||||||
*/
|
*/
|
||||||
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low';
|
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AOC source configuration.
|
* AOC source configuration.
|
||||||
*/
|
*/
|
||||||
export interface AocSource {
|
export interface AocSource {
|
||||||
id: string;
|
id: string;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'registry' | 'git' | 'upload' | 'api';
|
type: 'registry' | 'git' | 'upload' | 'api';
|
||||||
url?: string;
|
url?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
lastSync?: string;
|
lastSync?: string;
|
||||||
status: 'healthy' | 'degraded' | 'offline';
|
status: 'healthy' | 'degraded' | 'offline';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Violation code definition.
|
* Violation code definition.
|
||||||
*/
|
*/
|
||||||
export interface AocViolationCode {
|
export interface AocViolationCode {
|
||||||
code: string;
|
code: string;
|
||||||
description: string;
|
description: string;
|
||||||
severity: ViolationSeverity;
|
severity: ViolationSeverity;
|
||||||
category: string;
|
category: string;
|
||||||
remediation?: string;
|
remediation?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard summary data.
|
* Dashboard summary data.
|
||||||
*/
|
*/
|
||||||
export interface AocDashboardSummary {
|
export interface AocDashboardSummary {
|
||||||
/** Pass/fail metrics */
|
/** Pass/fail metrics */
|
||||||
passFail: {
|
passFail: {
|
||||||
passCount: number;
|
passCount: number;
|
||||||
failCount: number;
|
failCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
passRate: number;
|
passRate: number;
|
||||||
trend?: 'improving' | 'degrading' | 'stable';
|
trend?: 'improving' | 'degrading' | 'stable';
|
||||||
history?: { timestamp: string; value: number }[];
|
history?: { timestamp: string; value: number }[];
|
||||||
};
|
};
|
||||||
/** Recent violations */
|
/** Recent violations */
|
||||||
recentViolations: AocViolationSummary[];
|
recentViolations: AocViolationSummary[];
|
||||||
/** Ingest throughput */
|
/** Ingest throughput */
|
||||||
throughput: AocIngestThroughput;
|
throughput: AocIngestThroughput;
|
||||||
/** Throughput by tenant */
|
/** Throughput by tenant */
|
||||||
throughputByTenant: TenantThroughput[];
|
throughputByTenant: TenantThroughput[];
|
||||||
/** Configured sources */
|
/** Configured sources */
|
||||||
sources: AocSource[];
|
sources: AocSource[];
|
||||||
/** Time window */
|
/** Time window */
|
||||||
timeWindow: {
|
timeWindow: {
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tenant-level throughput metrics.
|
* Tenant-level throughput metrics.
|
||||||
*/
|
*/
|
||||||
export interface TenantThroughput {
|
export interface TenantThroughput {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
tenantName?: string;
|
tenantName?: string;
|
||||||
documentsIngested: number;
|
documentsIngested: number;
|
||||||
bytesIngested: number;
|
bytesIngested: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Field that caused a violation.
|
* Field that caused a violation.
|
||||||
*/
|
*/
|
||||||
export interface OffendingField {
|
export interface OffendingField {
|
||||||
path: string;
|
path: string;
|
||||||
expected?: string;
|
expected?: string;
|
||||||
actual?: string;
|
actual?: string;
|
||||||
expectedValue?: string;
|
expectedValue?: string;
|
||||||
actualValue?: string;
|
actualValue?: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
suggestion?: string;
|
suggestion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detailed violation record for display.
|
* Detailed violation record for display.
|
||||||
*/
|
*/
|
||||||
export interface ViolationDetail {
|
export interface ViolationDetail {
|
||||||
violationId: string;
|
violationId: string;
|
||||||
documentType: string;
|
documentType: string;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
severity: ViolationSeverity;
|
severity: ViolationSeverity;
|
||||||
detectedAt: string;
|
detectedAt: string;
|
||||||
offendingFields: OffendingField[];
|
offendingFields: OffendingField[];
|
||||||
provenance: ViolationProvenance;
|
provenance: ViolationProvenance;
|
||||||
suggestion?: string;
|
suggestion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provenance metadata for a violation.
|
* Provenance metadata for a violation.
|
||||||
*/
|
*/
|
||||||
export interface ViolationProvenance {
|
export interface ViolationProvenance {
|
||||||
sourceType: string;
|
sourceType: string;
|
||||||
sourceUri: string;
|
sourceUri: string;
|
||||||
ingestedAt: string;
|
ingestedAt: string;
|
||||||
ingestedBy: string;
|
ingestedBy: string;
|
||||||
buildId?: string;
|
buildId?: string;
|
||||||
commitSha?: string;
|
commitSha?: string;
|
||||||
pipelineUrl?: string;
|
pipelineUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type aliases for backwards compatibility
|
// Type aliases for backwards compatibility
|
||||||
export type IngestThroughput = AocIngestThroughput;
|
export type IngestThroughput = AocIngestThroughput;
|
||||||
export type VerificationRequest = AocVerificationRequest;
|
export type VerificationRequest = AocVerificationRequest;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Sprint 027: AOC Compliance Dashboard Extensions
|
// Sprint 027: AOC Compliance Dashboard Extensions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Guard violation types for AOC ingestion
|
// Guard violation types for AOC ingestion
|
||||||
export type GuardViolationReason =
|
export type GuardViolationReason =
|
||||||
| 'schema_invalid'
|
| 'schema_invalid'
|
||||||
| 'untrusted_source'
|
| 'untrusted_source'
|
||||||
| 'duplicate'
|
| 'duplicate'
|
||||||
| 'malformed_timestamp'
|
| 'malformed_timestamp'
|
||||||
| 'missing_required_fields'
|
| 'missing_required_fields'
|
||||||
| 'hash_mismatch'
|
| 'hash_mismatch'
|
||||||
| 'unknown';
|
| 'unknown';
|
||||||
|
|
||||||
export interface GuardViolation {
|
export interface GuardViolation {
|
||||||
id: string;
|
id: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
source: string;
|
source: string;
|
||||||
reason: GuardViolationReason;
|
reason: GuardViolationReason;
|
||||||
message: string;
|
message: string;
|
||||||
payloadSample?: string;
|
payloadSample?: string;
|
||||||
module: 'concelier' | 'excititor';
|
module: 'concelier' | 'excititor';
|
||||||
canRetry: boolean;
|
canRetry: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ingestion flow metrics
|
// Ingestion flow metrics
|
||||||
export interface IngestionSourceMetrics {
|
export interface IngestionSourceMetrics {
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
sourceName: string;
|
sourceName: string;
|
||||||
module: 'concelier' | 'excititor';
|
module: 'concelier' | 'excititor';
|
||||||
throughputPerMinute: number;
|
throughputPerMinute: number;
|
||||||
latencyP50Ms: number;
|
latencyP50Ms: number;
|
||||||
latencyP95Ms: number;
|
latencyP95Ms: number;
|
||||||
latencyP99Ms: number;
|
latencyP99Ms: number;
|
||||||
errorRate: number;
|
errorRate: number;
|
||||||
backlogDepth: number;
|
backlogDepth: number;
|
||||||
lastIngestionAt: string;
|
lastIngestionAt: string;
|
||||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IngestionFlowSummary {
|
export interface IngestionFlowSummary {
|
||||||
sources: IngestionSourceMetrics[];
|
sources: IngestionSourceMetrics[];
|
||||||
totalThroughput: number;
|
totalThroughput: number;
|
||||||
avgLatencyP95Ms: number;
|
avgLatencyP95Ms: number;
|
||||||
overallErrorRate: number;
|
overallErrorRate: number;
|
||||||
lastUpdatedAt: string;
|
lastUpdatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provenance chain types
|
// Provenance chain types
|
||||||
export type ProvenanceStepType =
|
export type ProvenanceStepType =
|
||||||
| 'source'
|
| 'source'
|
||||||
| 'advisory_raw'
|
| 'advisory_raw'
|
||||||
| 'normalized'
|
| 'normalized'
|
||||||
| 'vex_decision'
|
| 'vex_decision'
|
||||||
| 'finding'
|
| 'finding'
|
||||||
| 'policy_verdict'
|
| 'policy_verdict'
|
||||||
| 'attestation';
|
| 'attestation';
|
||||||
|
|
||||||
export interface ProvenanceStep {
|
export interface ProvenanceStep {
|
||||||
stepType: ProvenanceStepType;
|
stepType: ProvenanceStepType;
|
||||||
label: string;
|
label: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
hash?: string;
|
hash?: string;
|
||||||
linkedFromHash?: string;
|
linkedFromHash?: string;
|
||||||
status: 'valid' | 'warning' | 'error' | 'pending';
|
status: 'valid' | 'warning' | 'error' | 'pending';
|
||||||
details: Record<string, unknown>;
|
details: Record<string, unknown>;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProvenanceChain {
|
export interface ProvenanceChain {
|
||||||
inputType: 'advisory_id' | 'finding_id' | 'cve_id';
|
inputType: 'advisory_id' | 'finding_id' | 'cve_id';
|
||||||
inputValue: string;
|
inputValue: string;
|
||||||
steps: ProvenanceStep[];
|
steps: ProvenanceStep[];
|
||||||
isComplete: boolean;
|
isComplete: boolean;
|
||||||
validationErrors: string[];
|
validationErrors: string[];
|
||||||
validatedAt: string;
|
validatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AOC compliance metrics
|
// AOC compliance metrics
|
||||||
export interface AocComplianceMetrics {
|
export interface AocComplianceMetrics {
|
||||||
guardViolations: {
|
guardViolations: {
|
||||||
count: number;
|
count: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
byReason: Record<string, number>;
|
byReason: Record<string, number>;
|
||||||
trend: 'up' | 'down' | 'stable';
|
trend: 'up' | 'down' | 'stable';
|
||||||
};
|
};
|
||||||
provenanceCompleteness: {
|
provenanceCompleteness: {
|
||||||
percentage: number;
|
percentage: number;
|
||||||
recordsWithValidHash: number;
|
recordsWithValidHash: number;
|
||||||
totalRecords: number;
|
totalRecords: number;
|
||||||
trend: 'up' | 'down' | 'stable';
|
trend: 'up' | 'down' | 'stable';
|
||||||
};
|
};
|
||||||
deduplicationRate: {
|
deduplicationRate: {
|
||||||
percentage: number;
|
percentage: number;
|
||||||
duplicatesDetected: number;
|
duplicatesDetected: number;
|
||||||
totalIngested: number;
|
totalIngested: number;
|
||||||
trend: 'up' | 'down' | 'stable';
|
trend: 'up' | 'down' | 'stable';
|
||||||
};
|
};
|
||||||
ingestionLatency: {
|
ingestionLatency: {
|
||||||
p50Ms: number;
|
p50Ms: number;
|
||||||
p95Ms: number;
|
p95Ms: number;
|
||||||
p99Ms: number;
|
p99Ms: number;
|
||||||
meetsSla: boolean;
|
meetsSla: boolean;
|
||||||
slaTargetP95Ms: number;
|
slaTargetP95Ms: number;
|
||||||
};
|
};
|
||||||
supersedesDepth: {
|
supersedesDepth: {
|
||||||
maxDepth: number;
|
maxDepth: number;
|
||||||
avgDepth: number;
|
avgDepth: number;
|
||||||
distribution: { depth: number; count: number }[];
|
distribution: { depth: number; count: number }[];
|
||||||
};
|
};
|
||||||
periodStart: string;
|
periodStart: string;
|
||||||
periodEnd: string;
|
periodEnd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compliance report
|
// Compliance report
|
||||||
export type ComplianceReportFormat = 'csv' | 'json';
|
export type ComplianceReportFormat = 'csv' | 'json';
|
||||||
|
|
||||||
export interface ComplianceReportRequest {
|
export interface ComplianceReportRequest {
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
sources?: string[];
|
sources?: string[];
|
||||||
format: ComplianceReportFormat;
|
format: ComplianceReportFormat;
|
||||||
includeViolationDetails: boolean;
|
includeViolationDetails: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComplianceReportSummary {
|
export interface ComplianceReportSummary {
|
||||||
reportId: string;
|
reportId: string;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
period: { start: string; end: string };
|
period: { start: string; end: string };
|
||||||
guardViolationSummary: {
|
guardViolationSummary: {
|
||||||
total: number;
|
total: number;
|
||||||
bySource: Record<string, number>;
|
bySource: Record<string, number>;
|
||||||
byReason: Record<string, number>;
|
byReason: Record<string, number>;
|
||||||
};
|
};
|
||||||
provenanceCompliance: {
|
provenanceCompliance: {
|
||||||
percentage: number;
|
percentage: number;
|
||||||
bySource: Record<string, number>;
|
bySource: Record<string, number>;
|
||||||
};
|
};
|
||||||
deduplicationMetrics: {
|
deduplicationMetrics: {
|
||||||
rate: number;
|
rate: number;
|
||||||
bySource: Record<string, number>;
|
bySource: Record<string, number>;
|
||||||
};
|
};
|
||||||
latencyMetrics: {
|
latencyMetrics: {
|
||||||
p50Ms: number;
|
p50Ms: number;
|
||||||
p95Ms: number;
|
p95Ms: number;
|
||||||
p99Ms: number;
|
p99Ms: number;
|
||||||
bySource: Record<string, { p50: number; p95: number; p99: number }>;
|
bySource: Record<string, { p50: number; p95: number; p99: number }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// API response wrappers
|
// API response wrappers
|
||||||
export interface AocComplianceDashboardData {
|
export interface AocComplianceDashboardData {
|
||||||
metrics: AocComplianceMetrics;
|
metrics: AocComplianceMetrics;
|
||||||
recentViolations: GuardViolation[];
|
recentViolations: GuardViolation[];
|
||||||
ingestionFlow: IngestionFlowSummary;
|
ingestionFlow: IngestionFlowSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuardViolationsPagedResponse {
|
export interface GuardViolationsPagedResponse {
|
||||||
items: GuardViolation[];
|
items: GuardViolation[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter options
|
// Filter options
|
||||||
export interface AocDashboardFilters {
|
export interface AocDashboardFilters {
|
||||||
dateRange: { start: string; end: string };
|
dateRange: { start: string; end: string };
|
||||||
sources?: string[];
|
sources?: string[];
|
||||||
modules?: ('concelier' | 'excititor')[];
|
modules?: ('concelier' | 'excititor')[];
|
||||||
violationReasons?: GuardViolationReason[];
|
violationReasons?: GuardViolationReason[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +1,113 @@
|
|||||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||||
|
|
||||||
export interface AuthorityTenantViewDto {
|
export interface AuthorityTenantViewDto {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
readonly status: string;
|
readonly status: string;
|
||||||
readonly isolationMode: string;
|
readonly isolationMode: string;
|
||||||
readonly defaultRoles: readonly string[];
|
readonly defaultRoles: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TenantCatalogResponseDto {
|
export interface TenantCatalogResponseDto {
|
||||||
readonly tenants: readonly AuthorityTenantViewDto[];
|
readonly tenants: readonly AuthorityTenantViewDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConsoleProfileDto {
|
export interface ConsoleProfileDto {
|
||||||
readonly subjectId: string | null;
|
readonly subjectId: string | null;
|
||||||
readonly username: string | null;
|
readonly username: string | null;
|
||||||
readonly displayName: string | null;
|
readonly displayName: string | null;
|
||||||
readonly tenant: string;
|
readonly tenant: string;
|
||||||
readonly sessionId: string | null;
|
readonly sessionId: string | null;
|
||||||
readonly roles: readonly string[];
|
readonly roles: readonly string[];
|
||||||
readonly scopes: readonly string[];
|
readonly scopes: readonly string[];
|
||||||
readonly audiences: readonly string[];
|
readonly audiences: readonly string[];
|
||||||
readonly authenticationMethods: readonly string[];
|
readonly authenticationMethods: readonly string[];
|
||||||
readonly issuedAt: string | null;
|
readonly issuedAt: string | null;
|
||||||
readonly authenticationTime: string | null;
|
readonly authenticationTime: string | null;
|
||||||
readonly expiresAt: string | null;
|
readonly expiresAt: string | null;
|
||||||
readonly freshAuth: boolean;
|
readonly freshAuth: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConsoleTokenIntrospectionDto {
|
export interface ConsoleTokenIntrospectionDto {
|
||||||
readonly active: boolean;
|
readonly active: boolean;
|
||||||
readonly tenant: string;
|
readonly tenant: string;
|
||||||
readonly subject: string | null;
|
readonly subject: string | null;
|
||||||
readonly clientId: string | null;
|
readonly clientId: string | null;
|
||||||
readonly tokenId: string | null;
|
readonly tokenId: string | null;
|
||||||
readonly scopes: readonly string[];
|
readonly scopes: readonly string[];
|
||||||
readonly audiences: readonly string[];
|
readonly audiences: readonly string[];
|
||||||
readonly issuedAt: string | null;
|
readonly issuedAt: string | null;
|
||||||
readonly authenticationTime: string | null;
|
readonly authenticationTime: string | null;
|
||||||
readonly expiresAt: string | null;
|
readonly expiresAt: string | null;
|
||||||
readonly freshAuth: boolean;
|
readonly freshAuth: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthorityConsoleApi {
|
export interface AuthorityConsoleApi {
|
||||||
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto>;
|
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto>;
|
||||||
getProfile(tenantId?: string): Observable<ConsoleProfileDto>;
|
getProfile(tenantId?: string): Observable<ConsoleProfileDto>;
|
||||||
introspectToken(
|
introspectToken(
|
||||||
tenantId?: string
|
tenantId?: string
|
||||||
): Observable<ConsoleTokenIntrospectionDto>;
|
): Observable<ConsoleTokenIntrospectionDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AUTHORITY_CONSOLE_API = new InjectionToken<AuthorityConsoleApi>(
|
export const AUTHORITY_CONSOLE_API = new InjectionToken<AuthorityConsoleApi>(
|
||||||
'AUTHORITY_CONSOLE_API'
|
'AUTHORITY_CONSOLE_API'
|
||||||
);
|
);
|
||||||
|
|
||||||
export const AUTHORITY_CONSOLE_API_BASE_URL = new InjectionToken<string>(
|
export const AUTHORITY_CONSOLE_API_BASE_URL = new InjectionToken<string>(
|
||||||
'AUTHORITY_CONSOLE_API_BASE_URL'
|
'AUTHORITY_CONSOLE_API_BASE_URL'
|
||||||
);
|
);
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class AuthorityConsoleApiHttpClient implements AuthorityConsoleApi {
|
export class AuthorityConsoleApiHttpClient implements AuthorityConsoleApi {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly http: HttpClient,
|
private readonly http: HttpClient,
|
||||||
@Inject(AUTHORITY_CONSOLE_API_BASE_URL) private readonly baseUrl: string,
|
@Inject(AUTHORITY_CONSOLE_API_BASE_URL) private readonly baseUrl: string,
|
||||||
private readonly authSession: AuthSessionStore
|
private readonly authSession: AuthSessionStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto> {
|
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto> {
|
||||||
return this.http.get<TenantCatalogResponseDto>(`${this.baseUrl}/tenants`, {
|
return this.http.get<TenantCatalogResponseDto>(`${this.baseUrl}/tenants`, {
|
||||||
headers: this.buildHeaders(tenantId),
|
headers: this.buildHeaders(tenantId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getProfile(tenantId?: string): Observable<ConsoleProfileDto> {
|
getProfile(tenantId?: string): Observable<ConsoleProfileDto> {
|
||||||
return this.http.get<ConsoleProfileDto>(`${this.baseUrl}/profile`, {
|
return this.http.get<ConsoleProfileDto>(`${this.baseUrl}/profile`, {
|
||||||
headers: this.buildHeaders(tenantId),
|
headers: this.buildHeaders(tenantId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
introspectToken(
|
introspectToken(
|
||||||
tenantId?: string
|
tenantId?: string
|
||||||
): Observable<ConsoleTokenIntrospectionDto> {
|
): Observable<ConsoleTokenIntrospectionDto> {
|
||||||
return this.http.post<ConsoleTokenIntrospectionDto>(
|
return this.http.post<ConsoleTokenIntrospectionDto>(
|
||||||
`${this.baseUrl}/token/introspect`,
|
`${this.baseUrl}/token/introspect`,
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
headers: this.buildHeaders(tenantId),
|
headers: this.buildHeaders(tenantId),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildHeaders(tenantOverride?: string): HttpHeaders {
|
private buildHeaders(tenantOverride?: string): HttpHeaders {
|
||||||
const tenantId =
|
const tenantId =
|
||||||
(tenantOverride && tenantOverride.trim()) ||
|
(tenantOverride && tenantOverride.trim()) ||
|
||||||
this.authSession.getActiveTenantId();
|
this.authSession.getActiveTenantId();
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AuthorityConsoleApiHttpClient requires an active tenant identifier.'
|
'AuthorityConsoleApiHttpClient requires an active tenant identifier.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new HttpHeaders({
|
return new HttpHeaders({
|
||||||
'X-StellaOps-Tenant': tenantId,
|
'X-StellaOps-Tenant': tenantId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,51 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
InjectionToken,
|
InjectionToken,
|
||||||
inject,
|
inject,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
export interface TrivyDbSettingsDto {
|
export interface TrivyDbSettingsDto {
|
||||||
publishFull: boolean;
|
publishFull: boolean;
|
||||||
publishDelta: boolean;
|
publishDelta: boolean;
|
||||||
includeFull: boolean;
|
includeFull: boolean;
|
||||||
includeDelta: boolean;
|
includeDelta: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrivyDbRunResponseDto {
|
export interface TrivyDbRunResponseDto {
|
||||||
exportId: string;
|
exportId: string;
|
||||||
triggeredAt: string;
|
triggeredAt: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONCELIER_EXPORTER_API_BASE_URL = new InjectionToken<string>(
|
export const CONCELIER_EXPORTER_API_BASE_URL = new InjectionToken<string>(
|
||||||
'CONCELIER_EXPORTER_API_BASE_URL'
|
'CONCELIER_EXPORTER_API_BASE_URL'
|
||||||
);
|
);
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ConcelierExporterClient {
|
export class ConcelierExporterClient {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly baseUrl = inject(CONCELIER_EXPORTER_API_BASE_URL);
|
private readonly baseUrl = inject(CONCELIER_EXPORTER_API_BASE_URL);
|
||||||
|
|
||||||
getTrivyDbSettings(): Observable<TrivyDbSettingsDto> {
|
getTrivyDbSettings(): Observable<TrivyDbSettingsDto> {
|
||||||
return this.http.get<TrivyDbSettingsDto>(`${this.baseUrl}/settings`);
|
return this.http.get<TrivyDbSettingsDto>(`${this.baseUrl}/settings`);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTrivyDbSettings(
|
updateTrivyDbSettings(
|
||||||
settings: TrivyDbSettingsDto
|
settings: TrivyDbSettingsDto
|
||||||
): Observable<TrivyDbSettingsDto> {
|
): Observable<TrivyDbSettingsDto> {
|
||||||
return this.http.put<TrivyDbSettingsDto>(`${this.baseUrl}/settings`, settings);
|
return this.http.put<TrivyDbSettingsDto>(`${this.baseUrl}/settings`, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
runTrivyDbExport(
|
runTrivyDbExport(
|
||||||
settings: TrivyDbSettingsDto
|
settings: TrivyDbSettingsDto
|
||||||
): Observable<TrivyDbRunResponseDto> {
|
): Observable<TrivyDbRunResponseDto> {
|
||||||
return this.http.post<TrivyDbRunResponseDto>(`${this.baseUrl}/run`, {
|
return this.http.post<TrivyDbRunResponseDto>(`${this.baseUrl}/run`, {
|
||||||
trigger: 'ui',
|
trigger: 'ui',
|
||||||
parameters: settings,
|
parameters: settings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,77 @@
|
|||||||
/**
|
/**
|
||||||
* Determinism verification models for SBOM scan details.
|
* Determinism verification models for SBOM scan details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface DeterminismStatus {
|
export interface DeterminismStatus {
|
||||||
/** Overall determinism status */
|
/** Overall determinism status */
|
||||||
status: 'verified' | 'warning' | 'failed' | 'unknown';
|
status: 'verified' | 'warning' | 'failed' | 'unknown';
|
||||||
|
|
||||||
/** Merkle root from _composition.json */
|
/** Merkle root from _composition.json */
|
||||||
merkleRoot: string | null;
|
merkleRoot: string | null;
|
||||||
|
|
||||||
/** Whether Merkle root matches computed hash */
|
/** Whether Merkle root matches computed hash */
|
||||||
merkleConsistent: boolean;
|
merkleConsistent: boolean;
|
||||||
|
|
||||||
/** Fragment hashes with verification status */
|
/** Fragment hashes with verification status */
|
||||||
fragments: DeterminismFragment[];
|
fragments: DeterminismFragment[];
|
||||||
|
|
||||||
/** Composition metadata */
|
/** Composition metadata */
|
||||||
composition: CompositionMeta | null;
|
composition: CompositionMeta | null;
|
||||||
|
|
||||||
/** Timestamp of verification */
|
/** Timestamp of verification */
|
||||||
verifiedAt: string;
|
verifiedAt: string;
|
||||||
|
|
||||||
/** Any issues found */
|
/** Any issues found */
|
||||||
issues: DeterminismIssue[];
|
issues: DeterminismIssue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeterminismFragment {
|
export interface DeterminismFragment {
|
||||||
/** Fragment identifier (e.g., layer digest) */
|
/** Fragment identifier (e.g., layer digest) */
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
/** Fragment type */
|
/** Fragment type */
|
||||||
type: 'layer' | 'metadata' | 'attestation' | 'sbom';
|
type: 'layer' | 'metadata' | 'attestation' | 'sbom';
|
||||||
|
|
||||||
/** Expected hash from composition */
|
/** Expected hash from composition */
|
||||||
expectedHash: string;
|
expectedHash: string;
|
||||||
|
|
||||||
/** Computed hash */
|
/** Computed hash */
|
||||||
computedHash: string;
|
computedHash: string;
|
||||||
|
|
||||||
/** Whether hashes match */
|
/** Whether hashes match */
|
||||||
matches: boolean;
|
matches: boolean;
|
||||||
|
|
||||||
/** Size in bytes */
|
/** Size in bytes */
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompositionMeta {
|
export interface CompositionMeta {
|
||||||
/** Composition schema version */
|
/** Composition schema version */
|
||||||
schemaVersion: string;
|
schemaVersion: string;
|
||||||
|
|
||||||
/** Scanner version that produced this */
|
/** Scanner version that produced this */
|
||||||
scannerVersion: string;
|
scannerVersion: string;
|
||||||
|
|
||||||
/** Build timestamp */
|
/** Build timestamp */
|
||||||
buildTimestamp: string;
|
buildTimestamp: string;
|
||||||
|
|
||||||
/** Total fragments */
|
/** Total fragments */
|
||||||
fragmentCount: number;
|
fragmentCount: number;
|
||||||
|
|
||||||
/** Composition file hash */
|
/** Composition file hash */
|
||||||
compositionHash: string;
|
compositionHash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeterminismIssue {
|
export interface DeterminismIssue {
|
||||||
/** Issue severity */
|
/** Issue severity */
|
||||||
severity: 'error' | 'warning' | 'info';
|
severity: 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
/** Issue code */
|
/** Issue code */
|
||||||
code: string;
|
code: string;
|
||||||
|
|
||||||
/** Human-readable message */
|
/** Human-readable message */
|
||||||
message: string;
|
message: string;
|
||||||
|
|
||||||
/** Affected fragment ID if applicable */
|
/** Affected fragment ID if applicable */
|
||||||
fragmentId?: string;
|
fragmentId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +1,95 @@
|
|||||||
/**
|
/**
|
||||||
* Entropy analysis models for image security visualization.
|
* Entropy analysis models for image security visualization.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface EntropyAnalysis {
|
export interface EntropyAnalysis {
|
||||||
/** Image digest */
|
/** Image digest */
|
||||||
imageDigest: string;
|
imageDigest: string;
|
||||||
|
|
||||||
/** Overall entropy score (0-10, higher = more suspicious) */
|
/** Overall entropy score (0-10, higher = more suspicious) */
|
||||||
overallScore: number;
|
overallScore: number;
|
||||||
|
|
||||||
/** Risk level classification */
|
/** Risk level classification */
|
||||||
riskLevel: 'low' | 'medium' | 'high' | 'critical';
|
riskLevel: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
/** Per-layer entropy breakdown */
|
/** Per-layer entropy breakdown */
|
||||||
layers: LayerEntropy[];
|
layers: LayerEntropy[];
|
||||||
|
|
||||||
/** Files with high entropy (potential secrets/malware) */
|
/** Files with high entropy (potential secrets/malware) */
|
||||||
highEntropyFiles: HighEntropyFile[];
|
highEntropyFiles: HighEntropyFile[];
|
||||||
|
|
||||||
/** Detector hints for suspicious patterns */
|
/** Detector hints for suspicious patterns */
|
||||||
detectorHints: DetectorHint[];
|
detectorHints: DetectorHint[];
|
||||||
|
|
||||||
/** Analysis timestamp */
|
/** Analysis timestamp */
|
||||||
analyzedAt: string;
|
analyzedAt: string;
|
||||||
|
|
||||||
/** Link to raw entropy report */
|
/** Link to raw entropy report */
|
||||||
reportUrl: string;
|
reportUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LayerEntropy {
|
export interface LayerEntropy {
|
||||||
/** Layer digest */
|
/** Layer digest */
|
||||||
digest: string;
|
digest: string;
|
||||||
|
|
||||||
/** Layer command (e.g., COPY, RUN) */
|
/** Layer command (e.g., COPY, RUN) */
|
||||||
command: string;
|
command: string;
|
||||||
|
|
||||||
/** Layer size in bytes */
|
/** Layer size in bytes */
|
||||||
size: number;
|
size: number;
|
||||||
|
|
||||||
/** Average entropy for this layer (0-8 bits) */
|
/** Average entropy for this layer (0-8 bits) */
|
||||||
avgEntropy: number;
|
avgEntropy: number;
|
||||||
|
|
||||||
/** Percentage of opaque bytes (high entropy) */
|
/** Percentage of opaque bytes (high entropy) */
|
||||||
opaqueByteRatio: number;
|
opaqueByteRatio: number;
|
||||||
|
|
||||||
/** Number of high-entropy files */
|
/** Number of high-entropy files */
|
||||||
highEntropyFileCount: number;
|
highEntropyFileCount: number;
|
||||||
|
|
||||||
/** Risk contribution to overall score */
|
/** Risk contribution to overall score */
|
||||||
riskContribution: number;
|
riskContribution: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HighEntropyFile {
|
export interface HighEntropyFile {
|
||||||
/** File path in container */
|
/** File path in container */
|
||||||
path: string;
|
path: string;
|
||||||
|
|
||||||
/** Layer where file was added */
|
/** Layer where file was added */
|
||||||
layerDigest: string;
|
layerDigest: string;
|
||||||
|
|
||||||
/** File size in bytes */
|
/** File size in bytes */
|
||||||
size: number;
|
size: number;
|
||||||
|
|
||||||
/** File entropy (0-8 bits) */
|
/** File entropy (0-8 bits) */
|
||||||
entropy: number;
|
entropy: number;
|
||||||
|
|
||||||
/** Classification */
|
/** Classification */
|
||||||
classification: 'encrypted' | 'compressed' | 'binary' | 'suspicious' | 'unknown';
|
classification: 'encrypted' | 'compressed' | 'binary' | 'suspicious' | 'unknown';
|
||||||
|
|
||||||
/** Why this file is flagged */
|
/** Why this file is flagged */
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DetectorHint {
|
export interface DetectorHint {
|
||||||
/** Hint ID */
|
/** Hint ID */
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
/** Severity */
|
/** Severity */
|
||||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
/** Pattern type */
|
/** Pattern type */
|
||||||
type: 'credential' | 'key' | 'token' | 'obfuscated' | 'packed' | 'crypto';
|
type: 'credential' | 'key' | 'token' | 'obfuscated' | 'packed' | 'crypto';
|
||||||
|
|
||||||
/** Human-readable description */
|
/** Human-readable description */
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
/** Affected file paths */
|
/** Affected file paths */
|
||||||
affectedPaths: string[];
|
affectedPaths: string[];
|
||||||
|
|
||||||
/** Confidence (0-100) */
|
/** Confidence (0-100) */
|
||||||
confidence: number;
|
confidence: number;
|
||||||
|
|
||||||
/** Remediation suggestion */
|
/** Remediation suggestion */
|
||||||
remediation: string;
|
remediation: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,352 +1,352 @@
|
|||||||
import { Injectable, InjectionToken } from '@angular/core';
|
import { Injectable, InjectionToken } from '@angular/core';
|
||||||
import { Observable, of, delay } from 'rxjs';
|
import { Observable, of, delay } from 'rxjs';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EvidenceData,
|
EvidenceData,
|
||||||
Linkset,
|
Linkset,
|
||||||
Observation,
|
Observation,
|
||||||
PolicyEvidence,
|
PolicyEvidence,
|
||||||
} from './evidence.models';
|
} from './evidence.models';
|
||||||
|
|
||||||
export interface EvidenceApi {
|
export interface EvidenceApi {
|
||||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
|
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
|
||||||
getObservation(observationId: string): Observable<Observation>;
|
getObservation(observationId: string): Observable<Observation>;
|
||||||
getLinkset(linksetId: string): Observable<Linkset>;
|
getLinkset(linksetId: string): Observable<Linkset>;
|
||||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
|
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
|
||||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
|
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
|
||||||
/**
|
/**
|
||||||
* Export full evidence bundle as tar.gz or zip
|
* Export full evidence bundle as tar.gz or zip
|
||||||
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
||||||
*/
|
*/
|
||||||
exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob>;
|
exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
|
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
|
||||||
|
|
||||||
// Mock data for development
|
// Mock data for development
|
||||||
const MOCK_OBSERVATIONS: Observation[] = [
|
const MOCK_OBSERVATIONS: Observation[] = [
|
||||||
{
|
{
|
||||||
observationId: 'obs-ghsa-001',
|
observationId: 'obs-ghsa-001',
|
||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
source: 'ghsa',
|
source: 'ghsa',
|
||||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||||
title: 'Log4j Remote Code Execution (Log4Shell)',
|
title: 'Log4j Remote Code Execution (Log4Shell)',
|
||||||
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||||
severities: [
|
severities: [
|
||||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||||
],
|
],
|
||||||
affected: [
|
affected: [
|
||||||
{
|
{
|
||||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||||
package: 'log4j-core',
|
package: 'log4j-core',
|
||||||
ecosystem: 'maven',
|
ecosystem: 'maven',
|
||||||
ranges: [
|
ranges: [
|
||||||
{
|
{
|
||||||
type: 'ECOSYSTEM',
|
type: 'ECOSYSTEM',
|
||||||
events: [
|
events: [
|
||||||
{ introduced: '2.0-beta9' },
|
{ introduced: '2.0-beta9' },
|
||||||
{ fixed: '2.15.0' },
|
{ fixed: '2.15.0' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
references: [
|
references: [
|
||||||
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
|
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
|
||||||
'https://logging.apache.org/log4j/2.x/security.html',
|
'https://logging.apache.org/log4j/2.x/security.html',
|
||||||
],
|
],
|
||||||
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
|
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
|
||||||
published: '2021-12-10T00:00:00Z',
|
published: '2021-12-10T00:00:00Z',
|
||||||
modified: '2024-01-15T10:30:00Z',
|
modified: '2024-01-15T10:30:00Z',
|
||||||
provenance: {
|
provenance: {
|
||||||
sourceArtifactSha: 'sha256:abc123def456...',
|
sourceArtifactSha: 'sha256:abc123def456...',
|
||||||
fetchedAt: '2024-11-20T08:00:00Z',
|
fetchedAt: '2024-11-20T08:00:00Z',
|
||||||
ingestJobId: 'job-ghsa-2024-1120',
|
ingestJobId: 'job-ghsa-2024-1120',
|
||||||
},
|
},
|
||||||
ingestedAt: '2024-11-20T08:05:00Z',
|
ingestedAt: '2024-11-20T08:05:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
observationId: 'obs-nvd-001',
|
observationId: 'obs-nvd-001',
|
||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
source: 'nvd',
|
source: 'nvd',
|
||||||
advisoryId: 'CVE-2021-44228',
|
advisoryId: 'CVE-2021-44228',
|
||||||
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
|
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
|
||||||
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||||
severities: [
|
severities: [
|
||||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||||
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
|
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
|
||||||
],
|
],
|
||||||
affected: [
|
affected: [
|
||||||
{
|
{
|
||||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||||
package: 'log4j-core',
|
package: 'log4j-core',
|
||||||
ecosystem: 'maven',
|
ecosystem: 'maven',
|
||||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||||
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
|
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
references: [
|
references: [
|
||||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||||
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
|
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
|
||||||
],
|
],
|
||||||
relationships: [
|
relationships: [
|
||||||
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
|
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
|
||||||
],
|
],
|
||||||
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
|
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
|
||||||
published: '2021-12-10T10:15:00Z',
|
published: '2021-12-10T10:15:00Z',
|
||||||
modified: '2024-02-20T15:45:00Z',
|
modified: '2024-02-20T15:45:00Z',
|
||||||
provenance: {
|
provenance: {
|
||||||
sourceArtifactSha: 'sha256:def789ghi012...',
|
sourceArtifactSha: 'sha256:def789ghi012...',
|
||||||
fetchedAt: '2024-11-20T08:10:00Z',
|
fetchedAt: '2024-11-20T08:10:00Z',
|
||||||
ingestJobId: 'job-nvd-2024-1120',
|
ingestJobId: 'job-nvd-2024-1120',
|
||||||
},
|
},
|
||||||
ingestedAt: '2024-11-20T08:15:00Z',
|
ingestedAt: '2024-11-20T08:15:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
observationId: 'obs-osv-001',
|
observationId: 'obs-osv-001',
|
||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
source: 'osv',
|
source: 'osv',
|
||||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||||
title: 'Remote code injection in Log4j',
|
title: 'Remote code injection in Log4j',
|
||||||
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
|
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
|
||||||
severities: [
|
severities: [
|
||||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||||
],
|
],
|
||||||
affected: [
|
affected: [
|
||||||
{
|
{
|
||||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||||
package: 'log4j-core',
|
package: 'log4j-core',
|
||||||
ecosystem: 'Maven',
|
ecosystem: 'Maven',
|
||||||
ranges: [
|
ranges: [
|
||||||
{
|
{
|
||||||
type: 'ECOSYSTEM',
|
type: 'ECOSYSTEM',
|
||||||
events: [
|
events: [
|
||||||
{ introduced: '2.0-beta9' },
|
{ introduced: '2.0-beta9' },
|
||||||
{ fixed: '2.3.1' },
|
{ fixed: '2.3.1' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'ECOSYSTEM',
|
type: 'ECOSYSTEM',
|
||||||
events: [
|
events: [
|
||||||
{ introduced: '2.4' },
|
{ introduced: '2.4' },
|
||||||
{ fixed: '2.12.2' },
|
{ fixed: '2.12.2' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'ECOSYSTEM',
|
type: 'ECOSYSTEM',
|
||||||
events: [
|
events: [
|
||||||
{ introduced: '2.13.0' },
|
{ introduced: '2.13.0' },
|
||||||
{ fixed: '2.15.0' },
|
{ fixed: '2.15.0' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
references: [
|
references: [
|
||||||
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
|
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
|
||||||
],
|
],
|
||||||
published: '2021-12-10T00:00:00Z',
|
published: '2021-12-10T00:00:00Z',
|
||||||
modified: '2023-06-15T09:00:00Z',
|
modified: '2023-06-15T09:00:00Z',
|
||||||
provenance: {
|
provenance: {
|
||||||
sourceArtifactSha: 'sha256:ghi345jkl678...',
|
sourceArtifactSha: 'sha256:ghi345jkl678...',
|
||||||
fetchedAt: '2024-11-20T08:20:00Z',
|
fetchedAt: '2024-11-20T08:20:00Z',
|
||||||
ingestJobId: 'job-osv-2024-1120',
|
ingestJobId: 'job-osv-2024-1120',
|
||||||
},
|
},
|
||||||
ingestedAt: '2024-11-20T08:25:00Z',
|
ingestedAt: '2024-11-20T08:25:00Z',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const MOCK_LINKSET: Linkset = {
|
const MOCK_LINKSET: Linkset = {
|
||||||
linksetId: 'ls-log4shell-001',
|
linksetId: 'ls-log4shell-001',
|
||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
advisoryId: 'CVE-2021-44228',
|
advisoryId: 'CVE-2021-44228',
|
||||||
source: 'aggregated',
|
source: 'aggregated',
|
||||||
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
|
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
|
||||||
normalized: {
|
normalized: {
|
||||||
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
|
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
|
||||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||||
severities: [
|
severities: [
|
||||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
confidence: 0.95,
|
confidence: 0.95,
|
||||||
conflicts: [
|
conflicts: [
|
||||||
{
|
{
|
||||||
field: 'affected.ranges',
|
field: 'affected.ranges',
|
||||||
reason: 'Different fixed version ranges reported by sources',
|
reason: 'Different fixed version ranges reported by sources',
|
||||||
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
|
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
|
||||||
sourceIds: ['ghsa', 'osv'],
|
sourceIds: ['ghsa', 'osv'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'weaknesses',
|
field: 'weaknesses',
|
||||||
reason: 'Different CWE identifiers reported',
|
reason: 'Different CWE identifiers reported',
|
||||||
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
|
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
|
||||||
sourceIds: ['ghsa', 'nvd'],
|
sourceIds: ['ghsa', 'nvd'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
createdAt: '2024-11-20T08:30:00Z',
|
createdAt: '2024-11-20T08:30:00Z',
|
||||||
builtByJobId: 'linkset-build-2024-1120',
|
builtByJobId: 'linkset-build-2024-1120',
|
||||||
provenance: {
|
provenance: {
|
||||||
observationHashes: [
|
observationHashes: [
|
||||||
'sha256:abc123...',
|
'sha256:abc123...',
|
||||||
'sha256:def789...',
|
'sha256:def789...',
|
||||||
'sha256:ghi345...',
|
'sha256:ghi345...',
|
||||||
],
|
],
|
||||||
toolVersion: 'concelier-lnm-1.2.0',
|
toolVersion: 'concelier-lnm-1.2.0',
|
||||||
policyHash: 'sha256:policy-hash-001',
|
policyHash: 'sha256:policy-hash-001',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
|
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
|
||||||
policyId: 'pol-critical-vuln-001',
|
policyId: 'pol-critical-vuln-001',
|
||||||
policyName: 'Critical Vulnerability Policy',
|
policyName: 'Critical Vulnerability Policy',
|
||||||
decision: 'block',
|
decision: 'block',
|
||||||
decidedAt: '2024-11-20T08:35:00Z',
|
decidedAt: '2024-11-20T08:35:00Z',
|
||||||
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
|
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
ruleId: 'rule-cvss-critical',
|
ruleId: 'rule-cvss-critical',
|
||||||
ruleName: 'Block Critical CVSS',
|
ruleName: 'Block Critical CVSS',
|
||||||
passed: false,
|
passed: false,
|
||||||
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
|
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
|
||||||
matchedItems: ['CVE-2021-44228'],
|
matchedItems: ['CVE-2021-44228'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ruleId: 'rule-known-exploit',
|
ruleId: 'rule-known-exploit',
|
||||||
ruleName: 'Known Exploit Check',
|
ruleName: 'Known Exploit Check',
|
||||||
passed: false,
|
passed: false,
|
||||||
reason: 'Active exploitation reported by CISA',
|
reason: 'Active exploitation reported by CISA',
|
||||||
matchedItems: ['KEV-2021-44228'],
|
matchedItems: ['KEV-2021-44228'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ruleId: 'rule-fix-available',
|
ruleId: 'rule-fix-available',
|
||||||
ruleName: 'Fix Available',
|
ruleName: 'Fix Available',
|
||||||
passed: true,
|
passed: true,
|
||||||
reason: 'Fixed version 2.15.0+ available',
|
reason: 'Fixed version 2.15.0+ available',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
linksetIds: ['ls-log4shell-001'],
|
linksetIds: ['ls-log4shell-001'],
|
||||||
aocChain: [
|
aocChain: [
|
||||||
{
|
{
|
||||||
attestationId: 'aoc-obs-ghsa-001',
|
attestationId: 'aoc-obs-ghsa-001',
|
||||||
type: 'observation',
|
type: 'observation',
|
||||||
hash: 'sha256:abc123def456...',
|
hash: 'sha256:abc123def456...',
|
||||||
timestamp: '2024-11-20T08:05:00Z',
|
timestamp: '2024-11-20T08:05:00Z',
|
||||||
parentHash: undefined,
|
parentHash: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attestationId: 'aoc-obs-nvd-001',
|
attestationId: 'aoc-obs-nvd-001',
|
||||||
type: 'observation',
|
type: 'observation',
|
||||||
hash: 'sha256:def789ghi012...',
|
hash: 'sha256:def789ghi012...',
|
||||||
timestamp: '2024-11-20T08:15:00Z',
|
timestamp: '2024-11-20T08:15:00Z',
|
||||||
parentHash: 'sha256:abc123def456...',
|
parentHash: 'sha256:abc123def456...',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attestationId: 'aoc-obs-osv-001',
|
attestationId: 'aoc-obs-osv-001',
|
||||||
type: 'observation',
|
type: 'observation',
|
||||||
hash: 'sha256:ghi345jkl678...',
|
hash: 'sha256:ghi345jkl678...',
|
||||||
timestamp: '2024-11-20T08:25:00Z',
|
timestamp: '2024-11-20T08:25:00Z',
|
||||||
parentHash: 'sha256:def789ghi012...',
|
parentHash: 'sha256:def789ghi012...',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attestationId: 'aoc-ls-001',
|
attestationId: 'aoc-ls-001',
|
||||||
type: 'linkset',
|
type: 'linkset',
|
||||||
hash: 'sha256:linkset-hash-001...',
|
hash: 'sha256:linkset-hash-001...',
|
||||||
timestamp: '2024-11-20T08:30:00Z',
|
timestamp: '2024-11-20T08:30:00Z',
|
||||||
parentHash: 'sha256:ghi345jkl678...',
|
parentHash: 'sha256:ghi345jkl678...',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attestationId: 'aoc-policy-001',
|
attestationId: 'aoc-policy-001',
|
||||||
type: 'policy',
|
type: 'policy',
|
||||||
hash: 'sha256:policy-decision-hash...',
|
hash: 'sha256:policy-decision-hash...',
|
||||||
timestamp: '2024-11-20T08:35:00Z',
|
timestamp: '2024-11-20T08:35:00Z',
|
||||||
signer: 'policy-engine-v1',
|
signer: 'policy-engine-v1',
|
||||||
parentHash: 'sha256:linkset-hash-001...',
|
parentHash: 'sha256:linkset-hash-001...',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MockEvidenceApiService implements EvidenceApi {
|
export class MockEvidenceApiService implements EvidenceApi {
|
||||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
|
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
|
||||||
// Filter observations related to the advisory
|
// Filter observations related to the advisory
|
||||||
const observations = MOCK_OBSERVATIONS.filter(
|
const observations = MOCK_OBSERVATIONS.filter(
|
||||||
(o) =>
|
(o) =>
|
||||||
o.advisoryId === advisoryId ||
|
o.advisoryId === advisoryId ||
|
||||||
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
|
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
|
||||||
);
|
);
|
||||||
|
|
||||||
const linkset = MOCK_LINKSET;
|
const linkset = MOCK_LINKSET;
|
||||||
const policyEvidence = MOCK_POLICY_EVIDENCE;
|
const policyEvidence = MOCK_POLICY_EVIDENCE;
|
||||||
|
|
||||||
const data: EvidenceData = {
|
const data: EvidenceData = {
|
||||||
advisoryId,
|
advisoryId,
|
||||||
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
|
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
|
||||||
observations,
|
observations,
|
||||||
linkset,
|
linkset,
|
||||||
policyEvidence,
|
policyEvidence,
|
||||||
hasConflicts: linkset.conflicts.length > 0,
|
hasConflicts: linkset.conflicts.length > 0,
|
||||||
conflictCount: linkset.conflicts.length,
|
conflictCount: linkset.conflicts.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
return of(data).pipe(delay(300));
|
return of(data).pipe(delay(300));
|
||||||
}
|
}
|
||||||
|
|
||||||
getObservation(observationId: string): Observable<Observation> {
|
getObservation(observationId: string): Observable<Observation> {
|
||||||
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
|
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
|
||||||
if (!observation) {
|
if (!observation) {
|
||||||
throw new Error(`Observation not found: ${observationId}`);
|
throw new Error(`Observation not found: ${observationId}`);
|
||||||
}
|
}
|
||||||
return of(observation).pipe(delay(100));
|
return of(observation).pipe(delay(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
getLinkset(linksetId: string): Observable<Linkset> {
|
getLinkset(linksetId: string): Observable<Linkset> {
|
||||||
if (linksetId === MOCK_LINKSET.linksetId) {
|
if (linksetId === MOCK_LINKSET.linksetId) {
|
||||||
return of(MOCK_LINKSET).pipe(delay(100));
|
return of(MOCK_LINKSET).pipe(delay(100));
|
||||||
}
|
}
|
||||||
throw new Error(`Linkset not found: ${linksetId}`);
|
throw new Error(`Linkset not found: ${linksetId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
|
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
|
||||||
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
|
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
|
||||||
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
|
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
|
||||||
}
|
}
|
||||||
return of(null).pipe(delay(100));
|
return of(null).pipe(delay(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
|
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
|
||||||
let data: object;
|
let data: object;
|
||||||
if (type === 'observation') {
|
if (type === 'observation') {
|
||||||
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
|
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
|
||||||
} else {
|
} else {
|
||||||
data = MOCK_LINKSET;
|
data = MOCK_LINKSET;
|
||||||
}
|
}
|
||||||
const json = JSON.stringify(data, null, 2);
|
const json = JSON.stringify(data, null, 2);
|
||||||
const blob = new Blob([json], { type: 'application/json' });
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
return of(blob).pipe(delay(100));
|
return of(blob).pipe(delay(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export full evidence bundle as tar.gz or zip
|
* Export full evidence bundle as tar.gz or zip
|
||||||
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
||||||
*/
|
*/
|
||||||
async exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob> {
|
async exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob> {
|
||||||
// In mock implementation, return a JSON blob with all evidence data
|
// In mock implementation, return a JSON blob with all evidence data
|
||||||
const data = {
|
const data = {
|
||||||
advisoryId,
|
advisoryId,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
format,
|
format,
|
||||||
observations: MOCK_OBSERVATIONS,
|
observations: MOCK_OBSERVATIONS,
|
||||||
linkset: MOCK_LINKSET,
|
linkset: MOCK_LINKSET,
|
||||||
policyEvidence: MOCK_POLICY_EVIDENCE,
|
policyEvidence: MOCK_POLICY_EVIDENCE,
|
||||||
};
|
};
|
||||||
|
|
||||||
const json = JSON.stringify(data, null, 2);
|
const json = JSON.stringify(data, null, 2);
|
||||||
const mimeType = format === 'tar.gz' ? 'application/gzip' : 'application/zip';
|
const mimeType = format === 'tar.gz' ? 'application/gzip' : 'application/zip';
|
||||||
|
|
||||||
// Simulate network delay
|
// Simulate network delay
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
return new Blob([json], { type: mimeType });
|
return new Blob([json], { type: mimeType });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,355 +1,355 @@
|
|||||||
/**
|
/**
|
||||||
* Link-Not-Merge Evidence Models
|
* Link-Not-Merge Evidence Models
|
||||||
* Based on docs/modules/concelier/link-not-merge-schema.md
|
* Based on docs/modules/concelier/link-not-merge-schema.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Severity from advisory sources
|
// Severity from advisory sources
|
||||||
export interface AdvisorySeverity {
|
export interface AdvisorySeverity {
|
||||||
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
|
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
|
||||||
readonly score: number;
|
readonly score: number;
|
||||||
readonly vector?: string;
|
readonly vector?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Affected package information
|
// Affected package information
|
||||||
export interface AffectedPackage {
|
export interface AffectedPackage {
|
||||||
readonly purl: string;
|
readonly purl: string;
|
||||||
readonly package?: string;
|
readonly package?: string;
|
||||||
readonly versions?: readonly string[];
|
readonly versions?: readonly string[];
|
||||||
readonly ranges?: readonly VersionRange[];
|
readonly ranges?: readonly VersionRange[];
|
||||||
readonly ecosystem?: string;
|
readonly ecosystem?: string;
|
||||||
readonly cpe?: readonly string[];
|
readonly cpe?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VersionRange {
|
export interface VersionRange {
|
||||||
readonly type: string;
|
readonly type: string;
|
||||||
readonly events: readonly VersionEvent[];
|
readonly events: readonly VersionEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VersionEvent {
|
export interface VersionEvent {
|
||||||
readonly introduced?: string;
|
readonly introduced?: string;
|
||||||
readonly fixed?: string;
|
readonly fixed?: string;
|
||||||
readonly last_affected?: string;
|
readonly last_affected?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relationship between advisories
|
// Relationship between advisories
|
||||||
export interface AdvisoryRelationship {
|
export interface AdvisoryRelationship {
|
||||||
readonly type: string;
|
readonly type: string;
|
||||||
readonly source: string;
|
readonly source: string;
|
||||||
readonly target: string;
|
readonly target: string;
|
||||||
readonly provenance?: string;
|
readonly provenance?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provenance tracking
|
// Provenance tracking
|
||||||
export interface ObservationProvenance {
|
export interface ObservationProvenance {
|
||||||
readonly sourceArtifactSha: string;
|
readonly sourceArtifactSha: string;
|
||||||
readonly fetchedAt: string;
|
readonly fetchedAt: string;
|
||||||
readonly ingestJobId?: string;
|
readonly ingestJobId?: string;
|
||||||
readonly signature?: Record<string, unknown>;
|
readonly signature?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw observation from a single source
|
// Raw observation from a single source
|
||||||
export interface Observation {
|
export interface Observation {
|
||||||
readonly observationId: string;
|
readonly observationId: string;
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
|
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
|
||||||
readonly advisoryId: string;
|
readonly advisoryId: string;
|
||||||
readonly title?: string;
|
readonly title?: string;
|
||||||
readonly summary?: string;
|
readonly summary?: string;
|
||||||
readonly severities: readonly AdvisorySeverity[];
|
readonly severities: readonly AdvisorySeverity[];
|
||||||
readonly affected: readonly AffectedPackage[];
|
readonly affected: readonly AffectedPackage[];
|
||||||
readonly references?: readonly string[];
|
readonly references?: readonly string[];
|
||||||
readonly scopes?: readonly string[];
|
readonly scopes?: readonly string[];
|
||||||
readonly relationships?: readonly AdvisoryRelationship[];
|
readonly relationships?: readonly AdvisoryRelationship[];
|
||||||
readonly weaknesses?: readonly string[];
|
readonly weaknesses?: readonly string[];
|
||||||
readonly published?: string;
|
readonly published?: string;
|
||||||
readonly modified?: string;
|
readonly modified?: string;
|
||||||
readonly provenance: ObservationProvenance;
|
readonly provenance: ObservationProvenance;
|
||||||
readonly ingestedAt: string;
|
readonly ingestedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conflict when sources disagree
|
// Conflict when sources disagree
|
||||||
export interface LinksetConflict {
|
export interface LinksetConflict {
|
||||||
readonly field: string;
|
readonly field: string;
|
||||||
readonly reason: string;
|
readonly reason: string;
|
||||||
readonly values?: readonly string[];
|
readonly values?: readonly string[];
|
||||||
readonly sourceIds?: readonly string[];
|
readonly sourceIds?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linkset provenance
|
// Linkset provenance
|
||||||
export interface LinksetProvenance {
|
export interface LinksetProvenance {
|
||||||
readonly observationHashes: readonly string[];
|
readonly observationHashes: readonly string[];
|
||||||
readonly toolVersion?: string;
|
readonly toolVersion?: string;
|
||||||
readonly policyHash?: string;
|
readonly policyHash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalized linkset aggregating multiple observations
|
// Normalized linkset aggregating multiple observations
|
||||||
export interface Linkset {
|
export interface Linkset {
|
||||||
readonly linksetId: string;
|
readonly linksetId: string;
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly advisoryId: string;
|
readonly advisoryId: string;
|
||||||
readonly source: string;
|
readonly source: string;
|
||||||
readonly observations: readonly string[]; // observation IDs
|
readonly observations: readonly string[]; // observation IDs
|
||||||
readonly normalized?: {
|
readonly normalized?: {
|
||||||
readonly purls?: readonly string[];
|
readonly purls?: readonly string[];
|
||||||
readonly versions?: readonly string[];
|
readonly versions?: readonly string[];
|
||||||
readonly ranges?: readonly VersionRange[];
|
readonly ranges?: readonly VersionRange[];
|
||||||
readonly severities?: readonly AdvisorySeverity[];
|
readonly severities?: readonly AdvisorySeverity[];
|
||||||
};
|
};
|
||||||
readonly confidence?: number; // 0-1
|
readonly confidence?: number; // 0-1
|
||||||
readonly conflicts: readonly LinksetConflict[];
|
readonly conflicts: readonly LinksetConflict[];
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly builtByJobId?: string;
|
readonly builtByJobId?: string;
|
||||||
readonly provenance?: LinksetProvenance;
|
readonly provenance?: LinksetProvenance;
|
||||||
// Artifact and verification fields (SPRINT_0341_0001_0001)
|
// Artifact and verification fields (SPRINT_0341_0001_0001)
|
||||||
readonly artifactRef?: string; // e.g., registry.example.com/image:tag
|
readonly artifactRef?: string; // e.g., registry.example.com/image:tag
|
||||||
readonly artifactDigest?: string; // e.g., sha256:abc123...
|
readonly artifactDigest?: string; // e.g., sha256:abc123...
|
||||||
readonly sbomDigest?: string; // SBOM attestation digest
|
readonly sbomDigest?: string; // SBOM attestation digest
|
||||||
readonly rekorLogIndex?: number; // Rekor transparency log index
|
readonly rekorLogIndex?: number; // Rekor transparency log index
|
||||||
}
|
}
|
||||||
|
|
||||||
// Policy decision result
|
// Policy decision result
|
||||||
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
|
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
|
||||||
|
|
||||||
// Policy decision with evidence
|
// Policy decision with evidence
|
||||||
export interface PolicyEvidence {
|
export interface PolicyEvidence {
|
||||||
readonly policyId: string;
|
readonly policyId: string;
|
||||||
readonly policyName: string;
|
readonly policyName: string;
|
||||||
readonly decision: PolicyDecision;
|
readonly decision: PolicyDecision;
|
||||||
readonly decidedAt: string;
|
readonly decidedAt: string;
|
||||||
readonly reason?: string;
|
readonly reason?: string;
|
||||||
readonly rules: readonly PolicyRuleResult[];
|
readonly rules: readonly PolicyRuleResult[];
|
||||||
readonly linksetIds: readonly string[];
|
readonly linksetIds: readonly string[];
|
||||||
readonly aocChain?: AocChainEntry[];
|
readonly aocChain?: AocChainEntry[];
|
||||||
// Decision verification fields (SPRINT_0341_0001_0001)
|
// Decision verification fields (SPRINT_0341_0001_0001)
|
||||||
readonly decisionDigest?: string; // Hash of the decision for verification
|
readonly decisionDigest?: string; // Hash of the decision for verification
|
||||||
readonly rekorLogIndex?: number; // Rekor log index if logged
|
readonly rekorLogIndex?: number; // Rekor log index if logged
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyRuleResult {
|
export interface PolicyRuleResult {
|
||||||
readonly ruleId: string;
|
readonly ruleId: string;
|
||||||
readonly ruleName: string;
|
readonly ruleName: string;
|
||||||
readonly passed: boolean;
|
readonly passed: boolean;
|
||||||
readonly reason?: string;
|
readonly reason?: string;
|
||||||
readonly matchedItems?: readonly string[];
|
readonly matchedItems?: readonly string[];
|
||||||
// Confidence metadata (UI-POLICY-13-007)
|
// Confidence metadata (UI-POLICY-13-007)
|
||||||
readonly unknownConfidence?: number | null;
|
readonly unknownConfidence?: number | null;
|
||||||
readonly confidenceBand?: string | null;
|
readonly confidenceBand?: string | null;
|
||||||
readonly unknownAgeDays?: number | null;
|
readonly unknownAgeDays?: number | null;
|
||||||
readonly sourceTrust?: string | null;
|
readonly sourceTrust?: string | null;
|
||||||
readonly reachability?: string | null;
|
readonly reachability?: string | null;
|
||||||
readonly quietedBy?: string | null;
|
readonly quietedBy?: string | null;
|
||||||
readonly quiet?: boolean | null;
|
readonly quiet?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AOC (Attestation of Compliance) chain entry
|
// AOC (Attestation of Compliance) chain entry
|
||||||
export interface AocChainEntry {
|
export interface AocChainEntry {
|
||||||
readonly attestationId: string;
|
readonly attestationId: string;
|
||||||
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
|
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
|
||||||
readonly hash: string;
|
readonly hash: string;
|
||||||
readonly timestamp: string;
|
readonly timestamp: string;
|
||||||
readonly signer?: string;
|
readonly signer?: string;
|
||||||
readonly parentHash?: string;
|
readonly parentHash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
|
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
|
||||||
export type VexStatus =
|
export type VexStatus =
|
||||||
| 'NOT_AFFECTED'
|
| 'NOT_AFFECTED'
|
||||||
| 'UNDER_INVESTIGATION'
|
| 'UNDER_INVESTIGATION'
|
||||||
| 'AFFECTED_MITIGATED'
|
| 'AFFECTED_MITIGATED'
|
||||||
| 'AFFECTED_UNMITIGATED'
|
| 'AFFECTED_UNMITIGATED'
|
||||||
| 'FIXED';
|
| 'FIXED';
|
||||||
|
|
||||||
export type VexJustificationType =
|
export type VexJustificationType =
|
||||||
| 'CODE_NOT_PRESENT'
|
| 'CODE_NOT_PRESENT'
|
||||||
| 'CODE_NOT_REACHABLE'
|
| 'CODE_NOT_REACHABLE'
|
||||||
| 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH'
|
| 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH'
|
||||||
| 'CONFIGURATION_NOT_AFFECTED'
|
| 'CONFIGURATION_NOT_AFFECTED'
|
||||||
| 'OS_NOT_AFFECTED'
|
| 'OS_NOT_AFFECTED'
|
||||||
| 'RUNTIME_MITIGATION_PRESENT'
|
| 'RUNTIME_MITIGATION_PRESENT'
|
||||||
| 'COMPENSATING_CONTROLS'
|
| 'COMPENSATING_CONTROLS'
|
||||||
| 'ACCEPTED_BUSINESS_RISK'
|
| 'ACCEPTED_BUSINESS_RISK'
|
||||||
| 'OTHER';
|
| 'OTHER';
|
||||||
|
|
||||||
export interface VexSubjectRef {
|
export interface VexSubjectRef {
|
||||||
readonly type: 'IMAGE' | 'REPO' | 'SBOM_COMPONENT' | 'OTHER';
|
readonly type: 'IMAGE' | 'REPO' | 'SBOM_COMPONENT' | 'OTHER';
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly digest: Record<string, string>;
|
readonly digest: Record<string, string>;
|
||||||
readonly sbomNodeId?: string;
|
readonly sbomNodeId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VexEvidenceRef {
|
export interface VexEvidenceRef {
|
||||||
readonly type: 'PR' | 'TICKET' | 'DOC' | 'COMMIT' | 'OTHER';
|
readonly type: 'PR' | 'TICKET' | 'DOC' | 'COMMIT' | 'OTHER';
|
||||||
readonly title?: string;
|
readonly title?: string;
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VexScope {
|
export interface VexScope {
|
||||||
readonly environments?: readonly string[];
|
readonly environments?: readonly string[];
|
||||||
readonly projects?: readonly string[];
|
readonly projects?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VexValidFor {
|
export interface VexValidFor {
|
||||||
readonly notBefore?: string;
|
readonly notBefore?: string;
|
||||||
readonly notAfter?: string;
|
readonly notAfter?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VexActorRef {
|
export interface VexActorRef {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signature metadata for signed VEX decisions.
|
* Signature metadata for signed VEX decisions.
|
||||||
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui (FE-RISK-005)
|
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui (FE-RISK-005)
|
||||||
*/
|
*/
|
||||||
export interface VexDecisionSignatureInfo {
|
export interface VexDecisionSignatureInfo {
|
||||||
/** Whether the decision is cryptographically signed */
|
/** Whether the decision is cryptographically signed */
|
||||||
readonly isSigned: boolean;
|
readonly isSigned: boolean;
|
||||||
/** DSSE envelope digest (base64-encoded) */
|
/** DSSE envelope digest (base64-encoded) */
|
||||||
readonly dsseDigest?: string;
|
readonly dsseDigest?: string;
|
||||||
/** Signature algorithm used (e.g., 'ecdsa-p256', 'rsa-sha256') */
|
/** Signature algorithm used (e.g., 'ecdsa-p256', 'rsa-sha256') */
|
||||||
readonly signatureAlgorithm?: string;
|
readonly signatureAlgorithm?: string;
|
||||||
/** Key ID used for signing */
|
/** Key ID used for signing */
|
||||||
readonly signingKeyId?: string;
|
readonly signingKeyId?: string;
|
||||||
/** Signer identity (e.g., email, OIDC subject) */
|
/** Signer identity (e.g., email, OIDC subject) */
|
||||||
readonly signerIdentity?: string;
|
readonly signerIdentity?: string;
|
||||||
/** Timestamp when signed (ISO-8601) */
|
/** Timestamp when signed (ISO-8601) */
|
||||||
readonly signedAt?: string;
|
readonly signedAt?: string;
|
||||||
/** Signature verification status */
|
/** Signature verification status */
|
||||||
readonly verificationStatus?: 'verified' | 'failed' | 'pending' | 'unknown';
|
readonly verificationStatus?: 'verified' | 'failed' | 'pending' | 'unknown';
|
||||||
/** Rekor transparency log entry if logged */
|
/** Rekor transparency log entry if logged */
|
||||||
readonly rekorEntry?: VexRekorEntry;
|
readonly rekorEntry?: VexRekorEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rekor transparency log entry for VEX decisions.
|
* Rekor transparency log entry for VEX decisions.
|
||||||
*/
|
*/
|
||||||
export interface VexRekorEntry {
|
export interface VexRekorEntry {
|
||||||
/** Rekor log index */
|
/** Rekor log index */
|
||||||
readonly logIndex: number;
|
readonly logIndex: number;
|
||||||
/** Rekor log ID (tree hash) */
|
/** Rekor log ID (tree hash) */
|
||||||
readonly logId?: string;
|
readonly logId?: string;
|
||||||
/** Entry UUID in Rekor */
|
/** Entry UUID in Rekor */
|
||||||
readonly entryUuid?: string;
|
readonly entryUuid?: string;
|
||||||
/** Time integrated into the log (ISO-8601) */
|
/** Time integrated into the log (ISO-8601) */
|
||||||
readonly integratedTime?: string;
|
readonly integratedTime?: string;
|
||||||
/** URL to view/verify the entry */
|
/** URL to view/verify the entry */
|
||||||
readonly verifyUrl?: string;
|
readonly verifyUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VexDecision {
|
export interface VexDecision {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly vulnerabilityId: string;
|
readonly vulnerabilityId: string;
|
||||||
readonly subject: VexSubjectRef;
|
readonly subject: VexSubjectRef;
|
||||||
readonly status: VexStatus;
|
readonly status: VexStatus;
|
||||||
readonly justificationType: VexJustificationType;
|
readonly justificationType: VexJustificationType;
|
||||||
readonly justificationText?: string;
|
readonly justificationText?: string;
|
||||||
readonly evidenceRefs?: readonly VexEvidenceRef[];
|
readonly evidenceRefs?: readonly VexEvidenceRef[];
|
||||||
readonly scope?: VexScope;
|
readonly scope?: VexScope;
|
||||||
readonly validFor?: VexValidFor;
|
readonly validFor?: VexValidFor;
|
||||||
readonly supersedesDecisionId?: string;
|
readonly supersedesDecisionId?: string;
|
||||||
readonly createdBy: VexActorRef;
|
readonly createdBy: VexActorRef;
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly updatedAt?: string;
|
readonly updatedAt?: string;
|
||||||
/** Signature metadata for signed decisions (FE-RISK-005) */
|
/** Signature metadata for signed decisions (FE-RISK-005) */
|
||||||
readonly signatureInfo?: VexDecisionSignatureInfo;
|
readonly signatureInfo?: VexDecisionSignatureInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// VEX status summary for UI display
|
// VEX status summary for UI display
|
||||||
export interface VexStatusSummary {
|
export interface VexStatusSummary {
|
||||||
readonly notAffected: number;
|
readonly notAffected: number;
|
||||||
readonly underInvestigation: number;
|
readonly underInvestigation: number;
|
||||||
readonly affectedMitigated: number;
|
readonly affectedMitigated: number;
|
||||||
readonly affectedUnmitigated: number;
|
readonly affectedUnmitigated: number;
|
||||||
readonly fixed: number;
|
readonly fixed: number;
|
||||||
readonly total: number;
|
readonly total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// VEX conflict indicator
|
// VEX conflict indicator
|
||||||
export interface VexConflict {
|
export interface VexConflict {
|
||||||
readonly vulnerabilityId: string;
|
readonly vulnerabilityId: string;
|
||||||
readonly conflictingStatuses: readonly VexStatus[];
|
readonly conflictingStatuses: readonly VexStatus[];
|
||||||
readonly decisionIds: readonly string[];
|
readonly decisionIds: readonly string[];
|
||||||
readonly reason: string;
|
readonly reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evidence panel data combining all elements
|
// Evidence panel data combining all elements
|
||||||
export interface EvidenceData {
|
export interface EvidenceData {
|
||||||
readonly advisoryId: string;
|
readonly advisoryId: string;
|
||||||
readonly title?: string;
|
readonly title?: string;
|
||||||
readonly observations: readonly Observation[];
|
readonly observations: readonly Observation[];
|
||||||
readonly linkset?: Linkset;
|
readonly linkset?: Linkset;
|
||||||
readonly policyEvidence?: PolicyEvidence;
|
readonly policyEvidence?: PolicyEvidence;
|
||||||
readonly vexDecisions?: readonly VexDecision[];
|
readonly vexDecisions?: readonly VexDecision[];
|
||||||
readonly vexConflicts?: readonly VexConflict[];
|
readonly vexConflicts?: readonly VexConflict[];
|
||||||
readonly hasConflicts: boolean;
|
readonly hasConflicts: boolean;
|
||||||
readonly conflictCount: number;
|
readonly conflictCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source metadata for display
|
// Source metadata for display
|
||||||
export interface SourceInfo {
|
export interface SourceInfo {
|
||||||
readonly sourceId: string;
|
readonly sourceId: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly icon?: string;
|
readonly icon?: string;
|
||||||
readonly url?: string;
|
readonly url?: string;
|
||||||
readonly lastUpdated?: string;
|
readonly lastUpdated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter configuration for observations/linksets
|
// Filter configuration for observations/linksets
|
||||||
export type SeverityBucket = 'critical' | 'high' | 'medium' | 'low' | 'all';
|
export type SeverityBucket = 'critical' | 'high' | 'medium' | 'low' | 'all';
|
||||||
|
|
||||||
export interface ObservationFilters {
|
export interface ObservationFilters {
|
||||||
readonly sources: readonly string[]; // Filter by source IDs
|
readonly sources: readonly string[]; // Filter by source IDs
|
||||||
readonly severityBucket: SeverityBucket; // Filter by severity level
|
readonly severityBucket: SeverityBucket; // Filter by severity level
|
||||||
readonly conflictOnly: boolean; // Show only observations with conflicts
|
readonly conflictOnly: boolean; // Show only observations with conflicts
|
||||||
readonly hasCvssVector: boolean | null; // null = all, true = has vector, false = no vector
|
readonly hasCvssVector: boolean | null; // null = all, true = has vector, false = no vector
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_OBSERVATION_FILTERS: ObservationFilters = {
|
export const DEFAULT_OBSERVATION_FILTERS: ObservationFilters = {
|
||||||
sources: [],
|
sources: [],
|
||||||
severityBucket: 'all',
|
severityBucket: 'all',
|
||||||
conflictOnly: false,
|
conflictOnly: false,
|
||||||
hasCvssVector: null,
|
hasCvssVector: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pagination configuration
|
// Pagination configuration
|
||||||
export interface PaginationState {
|
export interface PaginationState {
|
||||||
readonly pageSize: number;
|
readonly pageSize: number;
|
||||||
readonly currentPage: number;
|
readonly currentPage: number;
|
||||||
readonly totalItems: number;
|
readonly totalItems: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_PAGE_SIZE = 10;
|
export const DEFAULT_PAGE_SIZE = 10;
|
||||||
|
|
||||||
export const SOURCE_INFO: Record<string, SourceInfo> = {
|
export const SOURCE_INFO: Record<string, SourceInfo> = {
|
||||||
ghsa: {
|
ghsa: {
|
||||||
sourceId: 'ghsa',
|
sourceId: 'ghsa',
|
||||||
name: 'GitHub Security Advisories',
|
name: 'GitHub Security Advisories',
|
||||||
icon: 'github',
|
icon: 'github',
|
||||||
url: 'https://github.com/advisories',
|
url: 'https://github.com/advisories',
|
||||||
},
|
},
|
||||||
nvd: {
|
nvd: {
|
||||||
sourceId: 'nvd',
|
sourceId: 'nvd',
|
||||||
name: 'National Vulnerability Database',
|
name: 'National Vulnerability Database',
|
||||||
icon: 'database',
|
icon: 'database',
|
||||||
url: 'https://nvd.nist.gov',
|
url: 'https://nvd.nist.gov',
|
||||||
},
|
},
|
||||||
'cert-bund': {
|
'cert-bund': {
|
||||||
sourceId: 'cert-bund',
|
sourceId: 'cert-bund',
|
||||||
name: 'CERT-Bund',
|
name: 'CERT-Bund',
|
||||||
icon: 'shield',
|
icon: 'shield',
|
||||||
url: 'https://www.cert-bund.de',
|
url: 'https://www.cert-bund.de',
|
||||||
},
|
},
|
||||||
osv: {
|
osv: {
|
||||||
sourceId: 'osv',
|
sourceId: 'osv',
|
||||||
name: 'Open Source Vulnerabilities',
|
name: 'Open Source Vulnerabilities',
|
||||||
icon: 'box',
|
icon: 'box',
|
||||||
url: 'https://osv.dev',
|
url: 'https://osv.dev',
|
||||||
},
|
},
|
||||||
cve: {
|
cve: {
|
||||||
sourceId: 'cve',
|
sourceId: 'cve',
|
||||||
name: 'CVE Program',
|
name: 'CVE Program',
|
||||||
icon: 'alert-triangle',
|
icon: 'alert-triangle',
|
||||||
url: 'https://cve.mitre.org',
|
url: 'https://cve.mitre.org',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,18 +56,18 @@ export class ExceptionApiHttpClient implements ExceptionApi {
|
|||||||
if (options?.severity) {
|
if (options?.severity) {
|
||||||
params = params.set('severity', options.severity);
|
params = params.set('severity', options.severity);
|
||||||
}
|
}
|
||||||
if (options?.search) {
|
if (options?.search) {
|
||||||
params = params.set('search', options.search);
|
params = params.set('search', options.search);
|
||||||
}
|
}
|
||||||
if (options?.sortBy) {
|
if (options?.sortBy) {
|
||||||
params = params.set('sortBy', options.sortBy);
|
params = params.set('sortBy', options.sortBy);
|
||||||
}
|
}
|
||||||
if (options?.sortOrder) {
|
if (options?.sortOrder) {
|
||||||
params = params.set('sortOrder', options.sortOrder);
|
params = params.set('sortOrder', options.sortOrder);
|
||||||
}
|
}
|
||||||
if (options?.limit) {
|
if (options?.limit) {
|
||||||
params = params.set('limit', options.limit.toString());
|
params = params.set('limit', options.limit.toString());
|
||||||
}
|
}
|
||||||
if (options?.continuationToken) {
|
if (options?.continuationToken) {
|
||||||
params = params.set('continuationToken', options.continuationToken);
|
params = params.set('continuationToken', options.continuationToken);
|
||||||
}
|
}
|
||||||
@@ -190,198 +190,198 @@ export class ExceptionApiHttpClient implements ExceptionApi {
|
|||||||
return new HttpHeaders(headers);
|
return new HttpHeaders(headers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock implementation for development and testing.
|
* Mock implementation for development and testing.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MockExceptionApiService implements ExceptionApi {
|
export class MockExceptionApiService implements ExceptionApi {
|
||||||
private readonly mockExceptions: Exception[] = [
|
private readonly mockExceptions: Exception[] = [
|
||||||
{
|
{
|
||||||
schemaVersion: '1.0',
|
schemaVersion: '1.0',
|
||||||
exceptionId: 'exc-001',
|
exceptionId: 'exc-001',
|
||||||
tenantId: 'tenant-dev',
|
tenantId: 'tenant-dev',
|
||||||
name: 'log4j-temporary-exception',
|
name: 'log4j-temporary-exception',
|
||||||
displayName: 'Log4j Temporary Exception',
|
displayName: 'Log4j Temporary Exception',
|
||||||
description: 'Temporary exception for legacy Log4j usage in internal tooling',
|
description: 'Temporary exception for legacy Log4j usage in internal tooling',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
scope: {
|
scope: {
|
||||||
type: 'component',
|
type: 'component',
|
||||||
componentPurls: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'],
|
componentPurls: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'],
|
||||||
vulnIds: ['CVE-2021-44228'],
|
vulnIds: ['CVE-2021-44228'],
|
||||||
},
|
},
|
||||||
justification: {
|
justification: {
|
||||||
template: 'internal-only',
|
template: 'internal-only',
|
||||||
text: 'Internal-only service with no external exposure. Migration planned for Q1.',
|
text: 'Internal-only service with no external exposure. Migration planned for Q1.',
|
||||||
},
|
},
|
||||||
timebox: {
|
timebox: {
|
||||||
startDate: '2025-01-01T00:00:00Z',
|
startDate: '2025-01-01T00:00:00Z',
|
||||||
endDate: '2025-03-31T23:59:59Z',
|
endDate: '2025-03-31T23:59:59Z',
|
||||||
autoRenew: false,
|
autoRenew: false,
|
||||||
},
|
},
|
||||||
approvals: [
|
approvals: [
|
||||||
{
|
{
|
||||||
approvalId: 'apr-001',
|
approvalId: 'apr-001',
|
||||||
approvedBy: 'security-lead@example.com',
|
approvedBy: 'security-lead@example.com',
|
||||||
approvedAt: '2024-12-15T10:30:00Z',
|
approvedAt: '2024-12-15T10:30:00Z',
|
||||||
comment: 'Approved with condition: migrate before Q2',
|
comment: 'Approved with condition: migrate before Q2',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
labels: { team: 'platform', priority: 'P2' },
|
labels: { team: 'platform', priority: 'P2' },
|
||||||
createdBy: 'dev@example.com',
|
createdBy: 'dev@example.com',
|
||||||
createdAt: '2024-12-10T09:00:00Z',
|
createdAt: '2024-12-10T09:00:00Z',
|
||||||
updatedBy: 'security-lead@example.com',
|
updatedBy: 'security-lead@example.com',
|
||||||
updatedAt: '2024-12-15T10:30:00Z',
|
updatedAt: '2024-12-15T10:30:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
schemaVersion: '1.0',
|
schemaVersion: '1.0',
|
||||||
exceptionId: 'exc-002',
|
exceptionId: 'exc-002',
|
||||||
tenantId: 'tenant-dev',
|
tenantId: 'tenant-dev',
|
||||||
name: 'openssl-vuln-exception',
|
name: 'openssl-vuln-exception',
|
||||||
displayName: 'OpenSSL Vulnerability Exception',
|
displayName: 'OpenSSL Vulnerability Exception',
|
||||||
status: 'pending_review',
|
status: 'pending_review',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
scope: {
|
scope: {
|
||||||
type: 'asset',
|
type: 'asset',
|
||||||
assetIds: ['asset-nginx-prod'],
|
assetIds: ['asset-nginx-prod'],
|
||||||
vulnIds: ['CVE-2024-0001'],
|
vulnIds: ['CVE-2024-0001'],
|
||||||
},
|
},
|
||||||
justification: {
|
justification: {
|
||||||
template: 'compensating-control',
|
template: 'compensating-control',
|
||||||
text: 'Compensating controls in place: WAF rules, network segmentation, monitoring.',
|
text: 'Compensating controls in place: WAF rules, network segmentation, monitoring.',
|
||||||
},
|
},
|
||||||
timebox: {
|
timebox: {
|
||||||
startDate: '2025-01-15T00:00:00Z',
|
startDate: '2025-01-15T00:00:00Z',
|
||||||
endDate: '2025-02-15T23:59:59Z',
|
endDate: '2025-02-15T23:59:59Z',
|
||||||
},
|
},
|
||||||
labels: { team: 'infrastructure' },
|
labels: { team: 'infrastructure' },
|
||||||
createdBy: 'ops@example.com',
|
createdBy: 'ops@example.com',
|
||||||
createdAt: '2025-01-10T14:00:00Z',
|
createdAt: '2025-01-10T14:00:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
schemaVersion: '1.0',
|
schemaVersion: '1.0',
|
||||||
exceptionId: 'exc-003',
|
exceptionId: 'exc-003',
|
||||||
tenantId: 'tenant-dev',
|
tenantId: 'tenant-dev',
|
||||||
name: 'legacy-crypto-exception',
|
name: 'legacy-crypto-exception',
|
||||||
displayName: 'Legacy Crypto Library',
|
displayName: 'Legacy Crypto Library',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
severity: 'medium',
|
severity: 'medium',
|
||||||
scope: {
|
scope: {
|
||||||
type: 'tenant',
|
type: 'tenant',
|
||||||
tenantId: 'tenant-dev',
|
tenantId: 'tenant-dev',
|
||||||
},
|
},
|
||||||
justification: {
|
justification: {
|
||||||
text: 'Migration in progress. ETA: 2 weeks.',
|
text: 'Migration in progress. ETA: 2 weeks.',
|
||||||
},
|
},
|
||||||
timebox: {
|
timebox: {
|
||||||
startDate: '2025-01-20T00:00:00Z',
|
startDate: '2025-01-20T00:00:00Z',
|
||||||
endDate: '2025-02-20T23:59:59Z',
|
endDate: '2025-02-20T23:59:59Z',
|
||||||
},
|
},
|
||||||
createdBy: 'dev@example.com',
|
createdBy: 'dev@example.com',
|
||||||
createdAt: '2025-01-18T11:00:00Z',
|
createdAt: '2025-01-18T11:00:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
schemaVersion: '1.0',
|
schemaVersion: '1.0',
|
||||||
exceptionId: 'exc-004',
|
exceptionId: 'exc-004',
|
||||||
tenantId: 'tenant-dev',
|
tenantId: 'tenant-dev',
|
||||||
name: 'expired-cert-exception',
|
name: 'expired-cert-exception',
|
||||||
displayName: 'Expired Certificate Exception',
|
displayName: 'Expired Certificate Exception',
|
||||||
status: 'expired',
|
status: 'expired',
|
||||||
severity: 'low',
|
severity: 'low',
|
||||||
scope: {
|
scope: {
|
||||||
type: 'asset',
|
type: 'asset',
|
||||||
assetIds: ['asset-test-env'],
|
assetIds: ['asset-test-env'],
|
||||||
},
|
},
|
||||||
justification: {
|
justification: {
|
||||||
text: 'Test environment only, not production facing.',
|
text: 'Test environment only, not production facing.',
|
||||||
},
|
},
|
||||||
timebox: {
|
timebox: {
|
||||||
startDate: '2024-10-01T00:00:00Z',
|
startDate: '2024-10-01T00:00:00Z',
|
||||||
endDate: '2024-12-31T23:59:59Z',
|
endDate: '2024-12-31T23:59:59Z',
|
||||||
},
|
},
|
||||||
createdBy: 'qa@example.com',
|
createdBy: 'qa@example.com',
|
||||||
createdAt: '2024-09-25T08:00:00Z',
|
createdAt: '2024-09-25T08:00:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
schemaVersion: '1.0',
|
schemaVersion: '1.0',
|
||||||
exceptionId: 'exc-005',
|
exceptionId: 'exc-005',
|
||||||
tenantId: 'tenant-dev',
|
tenantId: 'tenant-dev',
|
||||||
name: 'rejected-exception',
|
name: 'rejected-exception',
|
||||||
displayName: 'Rejected Risk Exception',
|
displayName: 'Rejected Risk Exception',
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
scope: {
|
scope: {
|
||||||
type: 'global',
|
type: 'global',
|
||||||
},
|
},
|
||||||
justification: {
|
justification: {
|
||||||
text: 'Blanket exception for all critical vulns.',
|
text: 'Blanket exception for all critical vulns.',
|
||||||
},
|
},
|
||||||
timebox: {
|
timebox: {
|
||||||
startDate: '2025-01-01T00:00:00Z',
|
startDate: '2025-01-01T00:00:00Z',
|
||||||
endDate: '2025-12-31T23:59:59Z',
|
endDate: '2025-12-31T23:59:59Z',
|
||||||
},
|
},
|
||||||
createdBy: 'dev@example.com',
|
createdBy: 'dev@example.com',
|
||||||
createdAt: '2024-12-20T16:00:00Z',
|
createdAt: '2024-12-20T16:00:00Z',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
|
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
|
||||||
let filtered = [...this.mockExceptions];
|
let filtered = [...this.mockExceptions];
|
||||||
|
|
||||||
if (options?.status) {
|
if (options?.status) {
|
||||||
filtered = filtered.filter((e) => e.status === options.status);
|
filtered = filtered.filter((e) => e.status === options.status);
|
||||||
}
|
}
|
||||||
if (options?.severity) {
|
if (options?.severity) {
|
||||||
filtered = filtered.filter((e) => e.severity === options.severity);
|
filtered = filtered.filter((e) => e.severity === options.severity);
|
||||||
}
|
}
|
||||||
if (options?.search) {
|
if (options?.search) {
|
||||||
const searchLower = options.search.toLowerCase();
|
const searchLower = options.search.toLowerCase();
|
||||||
filtered = filtered.filter(
|
filtered = filtered.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
e.name.toLowerCase().includes(searchLower) ||
|
e.name.toLowerCase().includes(searchLower) ||
|
||||||
e.displayName?.toLowerCase().includes(searchLower) ||
|
e.displayName?.toLowerCase().includes(searchLower) ||
|
||||||
e.description?.toLowerCase().includes(searchLower)
|
e.description?.toLowerCase().includes(searchLower)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortBy = options?.sortBy ?? 'createdAt';
|
const sortBy = options?.sortBy ?? 'createdAt';
|
||||||
const sortOrder = options?.sortOrder ?? 'desc';
|
const sortOrder = options?.sortOrder ?? 'desc';
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
let comparison = 0;
|
let comparison = 0;
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'name':
|
case 'name':
|
||||||
comparison = a.name.localeCompare(b.name);
|
comparison = a.name.localeCompare(b.name);
|
||||||
break;
|
break;
|
||||||
case 'severity':
|
case 'severity':
|
||||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||||
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
||||||
break;
|
break;
|
||||||
case 'status':
|
case 'status':
|
||||||
comparison = a.status.localeCompare(b.status);
|
comparison = a.status.localeCompare(b.status);
|
||||||
break;
|
break;
|
||||||
case 'updatedAt':
|
case 'updatedAt':
|
||||||
comparison = (a.updatedAt ?? a.createdAt).localeCompare(b.updatedAt ?? b.createdAt);
|
comparison = (a.updatedAt ?? a.createdAt).localeCompare(b.updatedAt ?? b.createdAt);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
comparison = a.createdAt.localeCompare(b.createdAt);
|
comparison = a.createdAt.localeCompare(b.createdAt);
|
||||||
}
|
}
|
||||||
return sortOrder === 'asc' ? comparison : -comparison;
|
return sortOrder === 'asc' ? comparison : -comparison;
|
||||||
});
|
});
|
||||||
|
|
||||||
const limit = options?.limit ?? 20;
|
const limit = options?.limit ?? 20;
|
||||||
const items = filtered.slice(0, limit);
|
const items = filtered.slice(0, limit);
|
||||||
|
|
||||||
return new Observable((subscriber) => {
|
return new Observable((subscriber) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
subscriber.next({
|
subscriber.next({
|
||||||
items,
|
items,
|
||||||
count: filtered.length,
|
count: filtered.length,
|
||||||
continuationToken: filtered.length > limit ? 'next-page-token' : null,
|
continuationToken: filtered.length > limit ? 'next-page-token' : null,
|
||||||
});
|
});
|
||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getException(exceptionId: string): Observable<Exception> {
|
getException(exceptionId: string): Observable<Exception> {
|
||||||
@@ -390,11 +390,11 @@ export class MockExceptionApiService implements ExceptionApi {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (exception) {
|
if (exception) {
|
||||||
subscriber.next(exception);
|
subscriber.next(exception);
|
||||||
} else {
|
} else {
|
||||||
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
||||||
}
|
}
|
||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,26 +404,26 @@ export class MockExceptionApiService implements ExceptionApi {
|
|||||||
schemaVersion: '1.0',
|
schemaVersion: '1.0',
|
||||||
exceptionId: `exc-${Math.random().toString(36).slice(2, 10)}`,
|
exceptionId: `exc-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
tenantId: 'tenant-dev',
|
tenantId: 'tenant-dev',
|
||||||
name: exception.name ?? 'new-exception',
|
name: exception.name ?? 'new-exception',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
severity: exception.severity ?? 'medium',
|
severity: exception.severity ?? 'medium',
|
||||||
scope: exception.scope ?? { type: 'tenant' },
|
scope: exception.scope ?? { type: 'tenant' },
|
||||||
justification: exception.justification ?? { text: '' },
|
justification: exception.justification ?? { text: '' },
|
||||||
timebox: exception.timebox ?? {
|
timebox: exception.timebox ?? {
|
||||||
startDate: new Date().toISOString(),
|
startDate: new Date().toISOString(),
|
||||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
},
|
},
|
||||||
createdBy: 'ui@stella-ops.local',
|
createdBy: 'ui@stella-ops.local',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
...exception,
|
...exception,
|
||||||
} as Exception;
|
} as Exception;
|
||||||
|
|
||||||
this.mockExceptions.push(newException);
|
this.mockExceptions.push(newException);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
subscriber.next(newException);
|
subscriber.next(newException);
|
||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
}, 200);
|
}, 200);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,20 +433,20 @@ export class MockExceptionApiService implements ExceptionApi {
|
|||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = {
|
const updated = {
|
||||||
...this.mockExceptions[index],
|
...this.mockExceptions[index],
|
||||||
...updates,
|
...updates,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
updatedBy: 'ui@stella-ops.local',
|
updatedBy: 'ui@stella-ops.local',
|
||||||
};
|
};
|
||||||
this.mockExceptions[index] = updated;
|
this.mockExceptions[index] = updated;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
subscriber.next(updated);
|
subscriber.next(updated);
|
||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
}, 200);
|
}, 200);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,51 +456,51 @@ export class MockExceptionApiService implements ExceptionApi {
|
|||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
this.mockExceptions.splice(index, 1);
|
this.mockExceptions.splice(index, 1);
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
subscriber.next();
|
subscriber.next();
|
||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
}, 200);
|
}, 200);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
|
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
|
||||||
return this.updateException(transition.exceptionId, {
|
return this.updateException(transition.exceptionId, {
|
||||||
status: transition.newStatus,
|
status: transition.newStatus,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getStats(): Observable<ExceptionStats> {
|
getStats(): Observable<ExceptionStats> {
|
||||||
return new Observable((subscriber) => {
|
return new Observable((subscriber) => {
|
||||||
const byStatus: Record<string, number> = {
|
const byStatus: Record<string, number> = {
|
||||||
draft: 0,
|
draft: 0,
|
||||||
pending_review: 0,
|
pending_review: 0,
|
||||||
approved: 0,
|
approved: 0,
|
||||||
rejected: 0,
|
rejected: 0,
|
||||||
expired: 0,
|
expired: 0,
|
||||||
revoked: 0,
|
revoked: 0,
|
||||||
};
|
};
|
||||||
const bySeverity: Record<string, number> = {
|
const bySeverity: Record<string, number> = {
|
||||||
critical: 0,
|
critical: 0,
|
||||||
high: 0,
|
high: 0,
|
||||||
medium: 0,
|
medium: 0,
|
||||||
low: 0,
|
low: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.mockExceptions.forEach((e) => {
|
this.mockExceptions.forEach((e) => {
|
||||||
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
|
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
|
||||||
bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1;
|
bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
subscriber.next({
|
subscriber.next({
|
||||||
total: this.mockExceptions.length,
|
total: this.mockExceptions.length,
|
||||||
byStatus: byStatus as Record<any, number>,
|
byStatus: byStatus as Record<any, number>,
|
||||||
bySeverity: bySeverity as Record<any, number>,
|
bySeverity: bySeverity as Record<any, number>,
|
||||||
expiringWithin7Days: 1,
|
expiringWithin7Days: 1,
|
||||||
pendingApproval: byStatus['pending_review'],
|
pendingApproval: byStatus['pending_review'],
|
||||||
});
|
});
|
||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,252 +1,252 @@
|
|||||||
/**
|
/**
|
||||||
* Exception management models for the Exception Center.
|
* Exception management models for the Exception Center.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ExceptionStatus =
|
export type ExceptionStatus =
|
||||||
| 'draft'
|
| 'draft'
|
||||||
| 'pending_review'
|
| 'pending_review'
|
||||||
| 'approved'
|
| 'approved'
|
||||||
| 'rejected'
|
| 'rejected'
|
||||||
| 'expired'
|
| 'expired'
|
||||||
| 'revoked';
|
| 'revoked';
|
||||||
|
|
||||||
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
|
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
|
||||||
|
|
||||||
export interface Exception {
|
export interface Exception {
|
||||||
/** Unique exception ID */
|
/** Unique exception ID */
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
/** Short title */
|
/** Short title */
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
/** Detailed justification */
|
/** Detailed justification */
|
||||||
justification: string;
|
justification: string;
|
||||||
|
|
||||||
/** Exception type */
|
/** Exception type */
|
||||||
type: ExceptionType;
|
type: ExceptionType;
|
||||||
|
|
||||||
/** Current status */
|
/** Current status */
|
||||||
status: ExceptionStatus;
|
status: ExceptionStatus;
|
||||||
|
|
||||||
/** Severity being excepted */
|
/** Severity being excepted */
|
||||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
/** Scope definition */
|
/** Scope definition */
|
||||||
scope: ExceptionScope;
|
scope: ExceptionScope;
|
||||||
|
|
||||||
/** Time constraints */
|
/** Time constraints */
|
||||||
timebox: ExceptionTimebox;
|
timebox: ExceptionTimebox;
|
||||||
|
|
||||||
/** Workflow history */
|
/** Workflow history */
|
||||||
workflow: ExceptionWorkflow;
|
workflow: ExceptionWorkflow;
|
||||||
|
|
||||||
/** Audit trail */
|
/** Audit trail */
|
||||||
auditLog: ExceptionAuditEntry[];
|
auditLog: ExceptionAuditEntry[];
|
||||||
|
|
||||||
/** Associated findings/violations */
|
/** Associated findings/violations */
|
||||||
findings: string[];
|
findings: string[];
|
||||||
|
|
||||||
/** Tags for filtering */
|
/** Tags for filtering */
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
|
||||||
/** Created timestamp */
|
/** Created timestamp */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
||||||
/** Last updated timestamp */
|
/** Last updated timestamp */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExceptionScope {
|
export interface ExceptionScope {
|
||||||
/** Affected images (glob patterns allowed) */
|
/** Affected images (glob patterns allowed) */
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
|
||||||
/** Affected CVEs */
|
/** Affected CVEs */
|
||||||
cves?: string[];
|
cves?: string[];
|
||||||
|
|
||||||
/** Affected packages */
|
/** Affected packages */
|
||||||
packages?: string[];
|
packages?: string[];
|
||||||
|
|
||||||
/** Affected licenses */
|
/** Affected licenses */
|
||||||
licenses?: string[];
|
licenses?: string[];
|
||||||
|
|
||||||
/** Affected policy rules */
|
/** Affected policy rules */
|
||||||
policyRules?: string[];
|
policyRules?: string[];
|
||||||
|
|
||||||
/** Tenant scope */
|
/** Tenant scope */
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
|
|
||||||
/** Environment scope */
|
/** Environment scope */
|
||||||
environments?: string[];
|
environments?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExceptionTimebox {
|
export interface ExceptionTimebox {
|
||||||
/** Start date */
|
/** Start date */
|
||||||
startsAt: string;
|
startsAt: string;
|
||||||
|
|
||||||
/** Expiration date */
|
/** Expiration date */
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
|
||||||
/** Remaining days */
|
/** Remaining days */
|
||||||
remainingDays: number;
|
remainingDays: number;
|
||||||
|
|
||||||
/** Is expired */
|
/** Is expired */
|
||||||
isExpired: boolean;
|
isExpired: boolean;
|
||||||
|
|
||||||
/** Warning threshold (days before expiry) */
|
/** Warning threshold (days before expiry) */
|
||||||
warnDays: number;
|
warnDays: number;
|
||||||
|
|
||||||
/** Is in warning period */
|
/** Is in warning period */
|
||||||
isWarning: boolean;
|
isWarning: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExceptionWorkflow {
|
export interface ExceptionWorkflow {
|
||||||
/** Current workflow state */
|
/** Current workflow state */
|
||||||
state: ExceptionStatus;
|
state: ExceptionStatus;
|
||||||
|
|
||||||
/** Requested by */
|
/** Requested by */
|
||||||
requestedBy: string;
|
requestedBy: string;
|
||||||
|
|
||||||
/** Requested at */
|
/** Requested at */
|
||||||
requestedAt: string;
|
requestedAt: string;
|
||||||
|
|
||||||
/** Approved by */
|
/** Approved by */
|
||||||
approvedBy?: string;
|
approvedBy?: string;
|
||||||
|
|
||||||
/** Approved at */
|
/** Approved at */
|
||||||
approvedAt?: string;
|
approvedAt?: string;
|
||||||
|
|
||||||
/** Revoked by */
|
/** Revoked by */
|
||||||
revokedBy?: string;
|
revokedBy?: string;
|
||||||
|
|
||||||
/** Revoked at */
|
/** Revoked at */
|
||||||
revokedAt?: string;
|
revokedAt?: string;
|
||||||
|
|
||||||
/** Revocation reason */
|
/** Revocation reason */
|
||||||
revocationReason?: string;
|
revocationReason?: string;
|
||||||
|
|
||||||
/** Required approvers */
|
/** Required approvers */
|
||||||
requiredApprovers: string[];
|
requiredApprovers: string[];
|
||||||
|
|
||||||
/** Current approvals */
|
/** Current approvals */
|
||||||
approvals: ExceptionApproval[];
|
approvals: ExceptionApproval[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExceptionApproval {
|
export interface ExceptionApproval {
|
||||||
/** Approver identity */
|
/** Approver identity */
|
||||||
approver: string;
|
approver: string;
|
||||||
|
|
||||||
/** Decision */
|
/** Decision */
|
||||||
decision: 'approved' | 'rejected';
|
decision: 'approved' | 'rejected';
|
||||||
|
|
||||||
/** Timestamp */
|
/** Timestamp */
|
||||||
at: string;
|
at: string;
|
||||||
|
|
||||||
/** Optional comment */
|
/** Optional comment */
|
||||||
comment?: string;
|
comment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExceptionAuditEntry {
|
export interface ExceptionAuditEntry {
|
||||||
/** Entry ID */
|
/** Entry ID */
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
/** Action performed */
|
/** Action performed */
|
||||||
action: 'created' | 'submitted' | 'approved' | 'rejected' | 'activated' | 'expired' | 'revoked' | 'edited';
|
action: 'created' | 'submitted' | 'approved' | 'rejected' | 'activated' | 'expired' | 'revoked' | 'edited';
|
||||||
|
|
||||||
/** Actor */
|
/** Actor */
|
||||||
actor: string;
|
actor: string;
|
||||||
|
|
||||||
/** Timestamp */
|
/** Timestamp */
|
||||||
at: string;
|
at: string;
|
||||||
|
|
||||||
/** Details */
|
/** Details */
|
||||||
details?: string;
|
details?: string;
|
||||||
|
|
||||||
/** Previous values (for edits) */
|
/** Previous values (for edits) */
|
||||||
previousValues?: Record<string, unknown>;
|
previousValues?: Record<string, unknown>;
|
||||||
|
|
||||||
/** New values (for edits) */
|
/** New values (for edits) */
|
||||||
newValues?: Record<string, unknown>;
|
newValues?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExceptionFilter {
|
export interface ExceptionFilter {
|
||||||
status?: ExceptionStatus[];
|
status?: ExceptionStatus[];
|
||||||
type?: ExceptionType[];
|
type?: ExceptionType[];
|
||||||
severity?: string[];
|
severity?: string[];
|
||||||
search?: string;
|
search?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
expiringSoon?: boolean;
|
expiringSoon?: boolean;
|
||||||
createdAfter?: string;
|
createdAfter?: string;
|
||||||
createdBefore?: string;
|
createdBefore?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExceptionSortOption {
|
export interface ExceptionSortOption {
|
||||||
field: 'createdAt' | 'updatedAt' | 'expiresAt' | 'severity' | 'title';
|
field: 'createdAt' | 'updatedAt' | 'expiresAt' | 'severity' | 'title';
|
||||||
direction: 'asc' | 'desc';
|
direction: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExceptionTransition {
|
export interface ExceptionTransition {
|
||||||
from: ExceptionStatus;
|
from: ExceptionStatus;
|
||||||
to: ExceptionStatus;
|
to: ExceptionStatus;
|
||||||
action: string;
|
action: string;
|
||||||
requiresApproval: boolean;
|
requiresApproval: boolean;
|
||||||
allowedRoles: string[];
|
allowedRoles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
|
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
|
||||||
{ from: 'draft', to: 'pending_review', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
|
{ from: 'draft', to: 'pending_review', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
|
||||||
{ from: 'pending_review', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
|
{ from: 'pending_review', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
|
||||||
{ from: 'pending_review', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
{ from: 'pending_review', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
||||||
{ from: 'pending_review', to: 'rejected', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
{ from: 'pending_review', to: 'rejected', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
||||||
{ from: 'approved', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
|
{ from: 'approved', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
|
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
|
||||||
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
|
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
|
||||||
{ status: 'pending_review', label: 'Pending Review', color: '#f59e0b' },
|
{ status: 'pending_review', label: 'Pending Review', color: '#f59e0b' },
|
||||||
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
|
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
|
||||||
{ status: 'rejected', label: 'Rejected', color: '#f472b6' },
|
{ status: 'rejected', label: 'Rejected', color: '#f472b6' },
|
||||||
{ status: 'expired', label: 'Expired', color: '#6b7280' },
|
{ status: 'expired', label: 'Expired', color: '#6b7280' },
|
||||||
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
|
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exception ledger entry for timeline display.
|
* Exception ledger entry for timeline display.
|
||||||
* @sprint SPRINT_20251226_004_FE_risk_dashboard
|
* @sprint SPRINT_20251226_004_FE_risk_dashboard
|
||||||
* @task DASH-12
|
* @task DASH-12
|
||||||
*/
|
*/
|
||||||
export interface ExceptionLedgerEntry {
|
export interface ExceptionLedgerEntry {
|
||||||
/** Entry ID. */
|
/** Entry ID. */
|
||||||
id: string;
|
id: string;
|
||||||
/** Exception ID. */
|
/** Exception ID. */
|
||||||
exceptionId: string;
|
exceptionId: string;
|
||||||
/** Event type. */
|
/** Event type. */
|
||||||
eventType: 'created' | 'approved' | 'rejected' | 'expired' | 'revoked' | 'extended' | 'modified';
|
eventType: 'created' | 'approved' | 'rejected' | 'expired' | 'revoked' | 'extended' | 'modified';
|
||||||
/** Event timestamp. */
|
/** Event timestamp. */
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
/** Actor user ID. */
|
/** Actor user ID. */
|
||||||
actorId: string;
|
actorId: string;
|
||||||
/** Actor display name. */
|
/** Actor display name. */
|
||||||
actorName?: string;
|
actorName?: string;
|
||||||
/** Event details. */
|
/** Event details. */
|
||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
/** Comment. */
|
/** Comment. */
|
||||||
comment?: string;
|
comment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exception summary for risk budget dashboard.
|
* Exception summary for risk budget dashboard.
|
||||||
* @sprint SPRINT_20251226_004_FE_risk_dashboard
|
* @sprint SPRINT_20251226_004_FE_risk_dashboard
|
||||||
* @task DASH-04
|
* @task DASH-04
|
||||||
*/
|
*/
|
||||||
export interface ExceptionSummary {
|
export interface ExceptionSummary {
|
||||||
/** Total active exceptions. */
|
/** Total active exceptions. */
|
||||||
active: number;
|
active: number;
|
||||||
/** Pending approval. */
|
/** Pending approval. */
|
||||||
pending: number;
|
pending: number;
|
||||||
/** Expiring within 7 days. */
|
/** Expiring within 7 days. */
|
||||||
expiringSoon: number;
|
expiringSoon: number;
|
||||||
/** Total risk points covered. */
|
/** Total risk points covered. */
|
||||||
riskPointsCovered: number;
|
riskPointsCovered: number;
|
||||||
/** Trace ID. */
|
/** Trace ID. */
|
||||||
traceId: string;
|
traceId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,419 +1,419 @@
|
|||||||
export type NotifyChannelType =
|
export type NotifyChannelType =
|
||||||
| 'Slack'
|
| 'Slack'
|
||||||
| 'Teams'
|
| 'Teams'
|
||||||
| 'Email'
|
| 'Email'
|
||||||
| 'Webhook'
|
| 'Webhook'
|
||||||
| 'Custom';
|
| 'Custom';
|
||||||
|
|
||||||
export type ChannelHealthStatus = 'Healthy' | 'Degraded' | 'Unhealthy';
|
export type ChannelHealthStatus = 'Healthy' | 'Degraded' | 'Unhealthy';
|
||||||
|
|
||||||
export type NotifyDeliveryStatus =
|
export type NotifyDeliveryStatus =
|
||||||
| 'Pending'
|
| 'Pending'
|
||||||
| 'Sent'
|
| 'Sent'
|
||||||
| 'Failed'
|
| 'Failed'
|
||||||
| 'Throttled'
|
| 'Throttled'
|
||||||
| 'Digested'
|
| 'Digested'
|
||||||
| 'Dropped';
|
| 'Dropped';
|
||||||
|
|
||||||
export type NotifyDeliveryAttemptStatus =
|
export type NotifyDeliveryAttemptStatus =
|
||||||
| 'Enqueued'
|
| 'Enqueued'
|
||||||
| 'Sending'
|
| 'Sending'
|
||||||
| 'Succeeded'
|
| 'Succeeded'
|
||||||
| 'Failed'
|
| 'Failed'
|
||||||
| 'Throttled'
|
| 'Throttled'
|
||||||
| 'Skipped';
|
| 'Skipped';
|
||||||
|
|
||||||
export type NotifyDeliveryFormat =
|
export type NotifyDeliveryFormat =
|
||||||
| 'Slack'
|
| 'Slack'
|
||||||
| 'Teams'
|
| 'Teams'
|
||||||
| 'Email'
|
| 'Email'
|
||||||
| 'Webhook'
|
| 'Webhook'
|
||||||
| 'Json';
|
| 'Json';
|
||||||
|
|
||||||
export interface NotifyChannelLimits {
|
export interface NotifyChannelLimits {
|
||||||
readonly concurrency?: number | null;
|
readonly concurrency?: number | null;
|
||||||
readonly requestsPerMinute?: number | null;
|
readonly requestsPerMinute?: number | null;
|
||||||
readonly timeout?: string | null;
|
readonly timeout?: string | null;
|
||||||
readonly maxBatchSize?: number | null;
|
readonly maxBatchSize?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyChannelConfig {
|
export interface NotifyChannelConfig {
|
||||||
readonly secretRef: string;
|
readonly secretRef: string;
|
||||||
readonly target?: string;
|
readonly target?: string;
|
||||||
readonly endpoint?: string;
|
readonly endpoint?: string;
|
||||||
readonly properties?: Record<string, string>;
|
readonly properties?: Record<string, string>;
|
||||||
readonly limits?: NotifyChannelLimits | null;
|
readonly limits?: NotifyChannelLimits | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyChannel {
|
export interface NotifyChannel {
|
||||||
readonly schemaVersion?: string;
|
readonly schemaVersion?: string;
|
||||||
readonly channelId: string;
|
readonly channelId: string;
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly displayName?: string;
|
readonly displayName?: string;
|
||||||
readonly description?: string;
|
readonly description?: string;
|
||||||
readonly type: NotifyChannelType;
|
readonly type: NotifyChannelType;
|
||||||
readonly enabled: boolean;
|
readonly enabled: boolean;
|
||||||
readonly config: NotifyChannelConfig;
|
readonly config: NotifyChannelConfig;
|
||||||
readonly labels?: Record<string, string>;
|
readonly labels?: Record<string, string>;
|
||||||
readonly metadata?: Record<string, string>;
|
readonly metadata?: Record<string, string>;
|
||||||
readonly createdBy?: string;
|
readonly createdBy?: string;
|
||||||
readonly createdAt?: string;
|
readonly createdAt?: string;
|
||||||
readonly updatedBy?: string;
|
readonly updatedBy?: string;
|
||||||
readonly updatedAt?: string;
|
readonly updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyRuleMatchVex {
|
export interface NotifyRuleMatchVex {
|
||||||
readonly includeAcceptedJustifications?: boolean;
|
readonly includeAcceptedJustifications?: boolean;
|
||||||
readonly includeRejectedJustifications?: boolean;
|
readonly includeRejectedJustifications?: boolean;
|
||||||
readonly includeUnknownJustifications?: boolean;
|
readonly includeUnknownJustifications?: boolean;
|
||||||
readonly justificationKinds?: readonly string[];
|
readonly justificationKinds?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyRuleMatch {
|
export interface NotifyRuleMatch {
|
||||||
readonly eventKinds?: readonly string[];
|
readonly eventKinds?: readonly string[];
|
||||||
readonly namespaces?: readonly string[];
|
readonly namespaces?: readonly string[];
|
||||||
readonly repositories?: readonly string[];
|
readonly repositories?: readonly string[];
|
||||||
readonly digests?: readonly string[];
|
readonly digests?: readonly string[];
|
||||||
readonly labels?: readonly string[];
|
readonly labels?: readonly string[];
|
||||||
readonly componentPurls?: readonly string[];
|
readonly componentPurls?: readonly string[];
|
||||||
readonly minSeverity?: string | null;
|
readonly minSeverity?: string | null;
|
||||||
readonly verdicts?: readonly string[];
|
readonly verdicts?: readonly string[];
|
||||||
readonly kevOnly?: boolean | null;
|
readonly kevOnly?: boolean | null;
|
||||||
readonly vex?: NotifyRuleMatchVex | null;
|
readonly vex?: NotifyRuleMatchVex | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyRuleAction {
|
export interface NotifyRuleAction {
|
||||||
readonly actionId: string;
|
readonly actionId: string;
|
||||||
readonly channel: string;
|
readonly channel: string;
|
||||||
readonly template?: string;
|
readonly template?: string;
|
||||||
readonly digest?: string;
|
readonly digest?: string;
|
||||||
readonly throttle?: string | null;
|
readonly throttle?: string | null;
|
||||||
readonly locale?: string;
|
readonly locale?: string;
|
||||||
readonly enabled: boolean;
|
readonly enabled: boolean;
|
||||||
readonly metadata?: Record<string, string>;
|
readonly metadata?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyRule {
|
export interface NotifyRule {
|
||||||
readonly schemaVersion?: string;
|
readonly schemaVersion?: string;
|
||||||
readonly ruleId: string;
|
readonly ruleId: string;
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly description?: string;
|
readonly description?: string;
|
||||||
readonly enabled: boolean;
|
readonly enabled: boolean;
|
||||||
readonly match: NotifyRuleMatch;
|
readonly match: NotifyRuleMatch;
|
||||||
readonly actions: readonly NotifyRuleAction[];
|
readonly actions: readonly NotifyRuleAction[];
|
||||||
readonly labels?: Record<string, string>;
|
readonly labels?: Record<string, string>;
|
||||||
readonly metadata?: Record<string, string>;
|
readonly metadata?: Record<string, string>;
|
||||||
readonly createdBy?: string;
|
readonly createdBy?: string;
|
||||||
readonly createdAt?: string;
|
readonly createdAt?: string;
|
||||||
readonly updatedBy?: string;
|
readonly updatedBy?: string;
|
||||||
readonly updatedAt?: string;
|
readonly updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyDeliveryAttempt {
|
export interface NotifyDeliveryAttempt {
|
||||||
readonly timestamp: string;
|
readonly timestamp: string;
|
||||||
readonly status: NotifyDeliveryAttemptStatus;
|
readonly status: NotifyDeliveryAttemptStatus;
|
||||||
readonly statusCode?: number;
|
readonly statusCode?: number;
|
||||||
readonly reason?: string;
|
readonly reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyDeliveryRendered {
|
export interface NotifyDeliveryRendered {
|
||||||
readonly channelType: NotifyChannelType;
|
readonly channelType: NotifyChannelType;
|
||||||
readonly format: NotifyDeliveryFormat;
|
readonly format: NotifyDeliveryFormat;
|
||||||
readonly target: string;
|
readonly target: string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly body: string;
|
readonly body: string;
|
||||||
readonly summary?: string;
|
readonly summary?: string;
|
||||||
readonly textBody?: string;
|
readonly textBody?: string;
|
||||||
readonly locale?: string;
|
readonly locale?: string;
|
||||||
readonly bodyHash?: string;
|
readonly bodyHash?: string;
|
||||||
readonly attachments?: readonly string[];
|
readonly attachments?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyDelivery {
|
export interface NotifyDelivery {
|
||||||
readonly deliveryId: string;
|
readonly deliveryId: string;
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly ruleId: string;
|
readonly ruleId: string;
|
||||||
readonly actionId: string;
|
readonly actionId: string;
|
||||||
readonly eventId: string;
|
readonly eventId: string;
|
||||||
readonly kind: string;
|
readonly kind: string;
|
||||||
readonly status: NotifyDeliveryStatus;
|
readonly status: NotifyDeliveryStatus;
|
||||||
readonly statusReason?: string;
|
readonly statusReason?: string;
|
||||||
readonly rendered?: NotifyDeliveryRendered;
|
readonly rendered?: NotifyDeliveryRendered;
|
||||||
readonly attempts?: readonly NotifyDeliveryAttempt[];
|
readonly attempts?: readonly NotifyDeliveryAttempt[];
|
||||||
readonly metadata?: Record<string, string>;
|
readonly metadata?: Record<string, string>;
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly sentAt?: string;
|
readonly sentAt?: string;
|
||||||
readonly completedAt?: string;
|
readonly completedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyDeliveriesQueryOptions {
|
export interface NotifyDeliveriesQueryOptions {
|
||||||
readonly status?: NotifyDeliveryStatus;
|
readonly status?: NotifyDeliveryStatus;
|
||||||
readonly since?: string;
|
readonly since?: string;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly continuationToken?: string;
|
readonly continuationToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyDeliveriesResponse {
|
export interface NotifyDeliveriesResponse {
|
||||||
readonly items: readonly NotifyDelivery[];
|
readonly items: readonly NotifyDelivery[];
|
||||||
readonly continuationToken?: string | null;
|
readonly continuationToken?: string | null;
|
||||||
readonly count: number;
|
readonly count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelHealthResponse {
|
export interface ChannelHealthResponse {
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly channelId: string;
|
readonly channelId: string;
|
||||||
readonly status: ChannelHealthStatus;
|
readonly status: ChannelHealthStatus;
|
||||||
readonly message?: string | null;
|
readonly message?: string | null;
|
||||||
readonly checkedAt: string;
|
readonly checkedAt: string;
|
||||||
readonly traceId: string;
|
readonly traceId: string;
|
||||||
readonly metadata?: Record<string, string>;
|
readonly metadata?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelTestSendRequest {
|
export interface ChannelTestSendRequest {
|
||||||
readonly target?: string;
|
readonly target?: string;
|
||||||
readonly templateId?: string;
|
readonly templateId?: string;
|
||||||
readonly title?: string;
|
readonly title?: string;
|
||||||
readonly summary?: string;
|
readonly summary?: string;
|
||||||
readonly body?: string;
|
readonly body?: string;
|
||||||
readonly textBody?: string;
|
readonly textBody?: string;
|
||||||
readonly locale?: string;
|
readonly locale?: string;
|
||||||
readonly metadata?: Record<string, string>;
|
readonly metadata?: Record<string, string>;
|
||||||
readonly attachments?: readonly string[];
|
readonly attachments?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelTestSendResponse {
|
export interface ChannelTestSendResponse {
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly channelId: string;
|
readonly channelId: string;
|
||||||
readonly preview: NotifyDeliveryRendered;
|
readonly preview: NotifyDeliveryRendered;
|
||||||
readonly queuedAt: string;
|
readonly queuedAt: string;
|
||||||
readonly traceId: string;
|
readonly traceId: string;
|
||||||
readonly metadata?: Record<string, string>;
|
readonly metadata?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WEB-NOTIFY-39-001: Digest scheduling, quiet-hours, throttle management.
|
* WEB-NOTIFY-39-001: Digest scheduling, quiet-hours, throttle management.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Digest frequency. */
|
/** Digest frequency. */
|
||||||
export type DigestFrequency = 'instant' | 'hourly' | 'daily' | 'weekly';
|
export type DigestFrequency = 'instant' | 'hourly' | 'daily' | 'weekly';
|
||||||
|
|
||||||
/** Digest schedule. */
|
/** Digest schedule. */
|
||||||
export interface DigestSchedule {
|
export interface DigestSchedule {
|
||||||
readonly scheduleId: string;
|
readonly scheduleId: string;
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly description?: string;
|
readonly description?: string;
|
||||||
readonly frequency: DigestFrequency;
|
readonly frequency: DigestFrequency;
|
||||||
readonly timezone: string;
|
readonly timezone: string;
|
||||||
readonly hour?: number;
|
readonly hour?: number;
|
||||||
readonly dayOfWeek?: number;
|
readonly dayOfWeek?: number;
|
||||||
readonly enabled: boolean;
|
readonly enabled: boolean;
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly updatedAt?: string;
|
readonly updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Digest schedules response. */
|
/** Digest schedules response. */
|
||||||
export interface DigestSchedulesResponse {
|
export interface DigestSchedulesResponse {
|
||||||
readonly items: readonly DigestSchedule[];
|
readonly items: readonly DigestSchedule[];
|
||||||
readonly nextPageToken?: string | null;
|
readonly nextPageToken?: string | null;
|
||||||
readonly total?: number;
|
readonly total?: number;
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Quiet hour window. */
|
/** Quiet hour window. */
|
||||||
export interface QuietHourWindow {
|
export interface QuietHourWindow {
|
||||||
readonly timezone: string;
|
readonly timezone: string;
|
||||||
readonly days: readonly string[];
|
readonly days: readonly string[];
|
||||||
readonly start: string;
|
readonly start: string;
|
||||||
readonly end: string;
|
readonly end: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Quiet hour exemption. */
|
/** Quiet hour exemption. */
|
||||||
export interface QuietHourExemption {
|
export interface QuietHourExemption {
|
||||||
readonly eventKinds: readonly string[];
|
readonly eventKinds: readonly string[];
|
||||||
readonly reason: string;
|
readonly reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Quiet hours configuration. */
|
/** Quiet hours configuration. */
|
||||||
export interface QuietHours {
|
export interface QuietHours {
|
||||||
readonly quietHoursId: string;
|
readonly quietHoursId: string;
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly description?: string;
|
readonly description?: string;
|
||||||
readonly windows: readonly QuietHourWindow[];
|
readonly windows: readonly QuietHourWindow[];
|
||||||
readonly exemptions?: readonly QuietHourExemption[];
|
readonly exemptions?: readonly QuietHourExemption[];
|
||||||
readonly enabled: boolean;
|
readonly enabled: boolean;
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly updatedAt?: string;
|
readonly updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Quiet hours response. */
|
/** Quiet hours response. */
|
||||||
export interface QuietHoursResponse {
|
export interface QuietHoursResponse {
|
||||||
readonly items: readonly QuietHours[];
|
readonly items: readonly QuietHours[];
|
||||||
readonly nextPageToken?: string | null;
|
readonly nextPageToken?: string | null;
|
||||||
readonly total?: number;
|
readonly total?: number;
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Throttle configuration. */
|
/** Throttle configuration. */
|
||||||
export interface ThrottleConfig {
|
export interface ThrottleConfig {
|
||||||
readonly throttleId: string;
|
readonly throttleId: string;
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly description?: string;
|
readonly description?: string;
|
||||||
readonly windowSeconds: number;
|
readonly windowSeconds: number;
|
||||||
readonly maxEvents: number;
|
readonly maxEvents: number;
|
||||||
readonly burstLimit?: number;
|
readonly burstLimit?: number;
|
||||||
readonly enabled: boolean;
|
readonly enabled: boolean;
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly updatedAt?: string;
|
readonly updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Throttle configs response. */
|
/** Throttle configs response. */
|
||||||
export interface ThrottleConfigsResponse {
|
export interface ThrottleConfigsResponse {
|
||||||
readonly items: readonly ThrottleConfig[];
|
readonly items: readonly ThrottleConfig[];
|
||||||
readonly nextPageToken?: string | null;
|
readonly nextPageToken?: string | null;
|
||||||
readonly total?: number;
|
readonly total?: number;
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Simulation request. */
|
/** Simulation request. */
|
||||||
export interface NotifySimulationRequest {
|
export interface NotifySimulationRequest {
|
||||||
readonly eventKind: string;
|
readonly eventKind: string;
|
||||||
readonly payload: Record<string, unknown>;
|
readonly payload: Record<string, unknown>;
|
||||||
readonly targetChannels?: readonly string[];
|
readonly targetChannels?: readonly string[];
|
||||||
readonly dryRun: boolean;
|
readonly dryRun: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Simulation result. */
|
/** Simulation result. */
|
||||||
export interface NotifySimulationResult {
|
export interface NotifySimulationResult {
|
||||||
readonly simulationId: string;
|
readonly simulationId: string;
|
||||||
readonly matchedRules: readonly string[];
|
readonly matchedRules: readonly string[];
|
||||||
readonly wouldNotify: readonly {
|
readonly wouldNotify: readonly {
|
||||||
readonly channelId: string;
|
readonly channelId: string;
|
||||||
readonly actionId: string;
|
readonly actionId: string;
|
||||||
readonly template: string;
|
readonly template: string;
|
||||||
readonly digest: DigestFrequency;
|
readonly digest: DigestFrequency;
|
||||||
}[];
|
}[];
|
||||||
readonly throttled: boolean;
|
readonly throttled: boolean;
|
||||||
readonly quietHoursActive: boolean;
|
readonly quietHoursActive: boolean;
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WEB-NOTIFY-40-001: Escalation, localization, channel health, ack verification.
|
* WEB-NOTIFY-40-001: Escalation, localization, channel health, ack verification.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Escalation policy. */
|
/** Escalation policy. */
|
||||||
export interface EscalationPolicy {
|
export interface EscalationPolicy {
|
||||||
readonly policyId: string;
|
readonly policyId: string;
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly description?: string;
|
readonly description?: string;
|
||||||
readonly levels: readonly EscalationLevel[];
|
readonly levels: readonly EscalationLevel[];
|
||||||
readonly enabled: boolean;
|
readonly enabled: boolean;
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly updatedAt?: string;
|
readonly updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Escalation level. */
|
/** Escalation level. */
|
||||||
export interface EscalationLevel {
|
export interface EscalationLevel {
|
||||||
readonly level: number;
|
readonly level: number;
|
||||||
readonly delayMinutes: number;
|
readonly delayMinutes: number;
|
||||||
readonly channels: readonly string[];
|
readonly channels: readonly string[];
|
||||||
readonly notifyOnAck: boolean;
|
readonly notifyOnAck: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Escalation policies response. */
|
/** Escalation policies response. */
|
||||||
export interface EscalationPoliciesResponse {
|
export interface EscalationPoliciesResponse {
|
||||||
readonly items: readonly EscalationPolicy[];
|
readonly items: readonly EscalationPolicy[];
|
||||||
readonly nextPageToken?: string | null;
|
readonly nextPageToken?: string | null;
|
||||||
readonly total?: number;
|
readonly total?: number;
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Localization config. */
|
/** Localization config. */
|
||||||
export interface LocalizationConfig {
|
export interface LocalizationConfig {
|
||||||
readonly localeId: string;
|
readonly localeId: string;
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly locale: string;
|
readonly locale: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly templates: Record<string, string>;
|
readonly templates: Record<string, string>;
|
||||||
readonly dateFormat?: string;
|
readonly dateFormat?: string;
|
||||||
readonly timeFormat?: string;
|
readonly timeFormat?: string;
|
||||||
readonly timezone?: string;
|
readonly timezone?: string;
|
||||||
readonly enabled: boolean;
|
readonly enabled: boolean;
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly updatedAt?: string;
|
readonly updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Localization configs response. */
|
/** Localization configs response. */
|
||||||
export interface LocalizationConfigsResponse {
|
export interface LocalizationConfigsResponse {
|
||||||
readonly items: readonly LocalizationConfig[];
|
readonly items: readonly LocalizationConfig[];
|
||||||
readonly nextPageToken?: string | null;
|
readonly nextPageToken?: string | null;
|
||||||
readonly total?: number;
|
readonly total?: number;
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Incident for acknowledgment. */
|
/** Incident for acknowledgment. */
|
||||||
export interface NotifyIncident {
|
export interface NotifyIncident {
|
||||||
readonly incidentId: string;
|
readonly incidentId: string;
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
|
readonly severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
|
||||||
readonly status: 'open' | 'acknowledged' | 'resolved' | 'closed';
|
readonly status: 'open' | 'acknowledged' | 'resolved' | 'closed';
|
||||||
readonly eventIds: readonly string[];
|
readonly eventIds: readonly string[];
|
||||||
readonly escalationLevel?: number;
|
readonly escalationLevel?: number;
|
||||||
readonly escalationPolicyId?: string;
|
readonly escalationPolicyId?: string;
|
||||||
readonly assignee?: string;
|
readonly assignee?: string;
|
||||||
readonly acknowledgedAt?: string;
|
readonly acknowledgedAt?: string;
|
||||||
readonly acknowledgedBy?: string;
|
readonly acknowledgedBy?: string;
|
||||||
readonly resolvedAt?: string;
|
readonly resolvedAt?: string;
|
||||||
readonly resolvedBy?: string;
|
readonly resolvedBy?: string;
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly updatedAt?: string;
|
readonly updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Incidents response. */
|
/** Incidents response. */
|
||||||
export interface NotifyIncidentsResponse {
|
export interface NotifyIncidentsResponse {
|
||||||
readonly items: readonly NotifyIncident[];
|
readonly items: readonly NotifyIncident[];
|
||||||
readonly nextPageToken?: string | null;
|
readonly nextPageToken?: string | null;
|
||||||
readonly total?: number;
|
readonly total?: number;
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Acknowledgment request. */
|
/** Acknowledgment request. */
|
||||||
export interface AckRequest {
|
export interface AckRequest {
|
||||||
readonly ackToken: string;
|
readonly ackToken: string;
|
||||||
readonly note?: string;
|
readonly note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Acknowledgment response. */
|
/** Acknowledgment response. */
|
||||||
export interface AckResponse {
|
export interface AckResponse {
|
||||||
readonly incidentId: string;
|
readonly incidentId: string;
|
||||||
readonly acknowledged: boolean;
|
readonly acknowledged: boolean;
|
||||||
readonly acknowledgedAt: string;
|
readonly acknowledgedAt: string;
|
||||||
readonly acknowledgedBy: string;
|
readonly acknowledgedBy: string;
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Notify query options. */
|
/** Notify query options. */
|
||||||
export interface NotifyQueryOptions {
|
export interface NotifyQueryOptions {
|
||||||
readonly tenantId?: string;
|
readonly tenantId?: string;
|
||||||
readonly projectId?: string;
|
readonly projectId?: string;
|
||||||
readonly pageToken?: string;
|
readonly pageToken?: string;
|
||||||
readonly pageSize?: number;
|
readonly pageSize?: number;
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Notify error codes. */
|
/** Notify error codes. */
|
||||||
export type NotifyErrorCode =
|
export type NotifyErrorCode =
|
||||||
| 'ERR_NOTIFY_CHANNEL_NOT_FOUND'
|
| 'ERR_NOTIFY_CHANNEL_NOT_FOUND'
|
||||||
| 'ERR_NOTIFY_RULE_NOT_FOUND'
|
| 'ERR_NOTIFY_RULE_NOT_FOUND'
|
||||||
| 'ERR_NOTIFY_INVALID_CONFIG'
|
| 'ERR_NOTIFY_INVALID_CONFIG'
|
||||||
| 'ERR_NOTIFY_RATE_LIMIT'
|
| 'ERR_NOTIFY_RATE_LIMIT'
|
||||||
| 'ERR_NOTIFY_ACK_INVALID'
|
| 'ERR_NOTIFY_ACK_INVALID'
|
||||||
| 'ERR_NOTIFY_ACK_EXPIRED';
|
| 'ERR_NOTIFY_ACK_EXPIRED';
|
||||||
|
|
||||||
|
|||||||
@@ -1,128 +1,128 @@
|
|||||||
export interface PolicyPreviewRequestDto {
|
export interface PolicyPreviewRequestDto {
|
||||||
imageDigest: string;
|
imageDigest: string;
|
||||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||||
policy?: PolicyPreviewPolicyDto;
|
policy?: PolicyPreviewPolicyDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyPreviewPolicyDto {
|
export interface PolicyPreviewPolicyDto {
|
||||||
content?: string;
|
content?: string;
|
||||||
format?: string;
|
format?: string;
|
||||||
actor?: string;
|
actor?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyPreviewFindingDto {
|
export interface PolicyPreviewFindingDto {
|
||||||
id: string;
|
id: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
environment?: string;
|
environment?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
vendor?: string;
|
vendor?: string;
|
||||||
license?: string;
|
license?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
repository?: string;
|
repository?: string;
|
||||||
package?: string;
|
package?: string;
|
||||||
purl?: string;
|
purl?: string;
|
||||||
cve?: string;
|
cve?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
layerDigest?: string;
|
layerDigest?: string;
|
||||||
tags?: ReadonlyArray<string>;
|
tags?: ReadonlyArray<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyPreviewVerdictDto {
|
export interface PolicyPreviewVerdictDto {
|
||||||
findingId: string;
|
findingId: string;
|
||||||
status: string;
|
status: string;
|
||||||
ruleName?: string | null;
|
ruleName?: string | null;
|
||||||
ruleAction?: string | null;
|
ruleAction?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
score?: number | null;
|
score?: number | null;
|
||||||
configVersion?: string | null;
|
configVersion?: string | null;
|
||||||
inputs?: Readonly<Record<string, number>>;
|
inputs?: Readonly<Record<string, number>>;
|
||||||
quietedBy?: string | null;
|
quietedBy?: string | null;
|
||||||
quiet?: boolean | null;
|
quiet?: boolean | null;
|
||||||
unknownConfidence?: number | null;
|
unknownConfidence?: number | null;
|
||||||
confidenceBand?: string | null;
|
confidenceBand?: string | null;
|
||||||
unknownAgeDays?: number | null;
|
unknownAgeDays?: number | null;
|
||||||
sourceTrust?: string | null;
|
sourceTrust?: string | null;
|
||||||
reachability?: string | null;
|
reachability?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyPreviewDiffDto {
|
export interface PolicyPreviewDiffDto {
|
||||||
findingId: string;
|
findingId: string;
|
||||||
baseline: PolicyPreviewVerdictDto;
|
baseline: PolicyPreviewVerdictDto;
|
||||||
projected: PolicyPreviewVerdictDto;
|
projected: PolicyPreviewVerdictDto;
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyPreviewIssueDto {
|
export interface PolicyPreviewIssueDto {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyPreviewResponseDto {
|
export interface PolicyPreviewResponseDto {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
policyDigest: string;
|
policyDigest: string;
|
||||||
revisionId?: string | null;
|
revisionId?: string | null;
|
||||||
changed: number;
|
changed: number;
|
||||||
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
|
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
|
||||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyPreviewSample {
|
export interface PolicyPreviewSample {
|
||||||
previewRequest: PolicyPreviewRequestDto;
|
previewRequest: PolicyPreviewRequestDto;
|
||||||
previewResponse: PolicyPreviewResponseDto;
|
previewResponse: PolicyPreviewResponseDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyReportRequestDto {
|
export interface PolicyReportRequestDto {
|
||||||
imageDigest: string;
|
imageDigest: string;
|
||||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyReportResponseDto {
|
export interface PolicyReportResponseDto {
|
||||||
report: PolicyReportDocumentDto;
|
report: PolicyReportDocumentDto;
|
||||||
dsse?: DsseEnvelopeDto | null;
|
dsse?: DsseEnvelopeDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyReportDocumentDto {
|
export interface PolicyReportDocumentDto {
|
||||||
reportId: string;
|
reportId: string;
|
||||||
imageDigest: string;
|
imageDigest: string;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
verdict: string;
|
verdict: string;
|
||||||
policy: PolicyReportPolicyDto;
|
policy: PolicyReportPolicyDto;
|
||||||
summary: PolicyReportSummaryDto;
|
summary: PolicyReportSummaryDto;
|
||||||
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
|
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyReportPolicyDto {
|
export interface PolicyReportPolicyDto {
|
||||||
revisionId?: string | null;
|
revisionId?: string | null;
|
||||||
digest?: string | null;
|
digest?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyReportSummaryDto {
|
export interface PolicyReportSummaryDto {
|
||||||
total: number;
|
total: number;
|
||||||
blocked: number;
|
blocked: number;
|
||||||
warned: number;
|
warned: number;
|
||||||
ignored: number;
|
ignored: number;
|
||||||
quieted: number;
|
quieted: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DsseEnvelopeDto {
|
export interface DsseEnvelopeDto {
|
||||||
payloadType: string;
|
payloadType: string;
|
||||||
payload: string;
|
payload: string;
|
||||||
signatures: ReadonlyArray<DsseSignatureDto>;
|
signatures: ReadonlyArray<DsseSignatureDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DsseSignatureDto {
|
export interface DsseSignatureDto {
|
||||||
keyId: string;
|
keyId: string;
|
||||||
algorithm: string;
|
algorithm: string;
|
||||||
signature: string;
|
signature: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyReportSample {
|
export interface PolicyReportSample {
|
||||||
reportRequest: PolicyReportRequestDto;
|
reportRequest: PolicyReportRequestDto;
|
||||||
reportResponse: PolicyReportResponseDto;
|
reportResponse: PolicyReportResponseDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,163 +1,163 @@
|
|||||||
/**
|
/**
|
||||||
* Policy gate models for release flow indicators.
|
* Policy gate models for release flow indicators.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface PolicyGateStatus {
|
export interface PolicyGateStatus {
|
||||||
/** Overall gate status */
|
/** Overall gate status */
|
||||||
status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
|
status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
|
||||||
|
|
||||||
/** Policy evaluation ID */
|
/** Policy evaluation ID */
|
||||||
evaluationId: string;
|
evaluationId: string;
|
||||||
|
|
||||||
/** Target artifact (image, SBOM, etc.) */
|
/** Target artifact (image, SBOM, etc.) */
|
||||||
targetRef: string;
|
targetRef: string;
|
||||||
|
|
||||||
/** Policy set that was evaluated */
|
/** Policy set that was evaluated */
|
||||||
policySetId: string;
|
policySetId: string;
|
||||||
|
|
||||||
/** Individual gate results */
|
/** Individual gate results */
|
||||||
gates: PolicyGate[];
|
gates: PolicyGate[];
|
||||||
|
|
||||||
/** Blocking issues preventing publish */
|
/** Blocking issues preventing publish */
|
||||||
blockingIssues: PolicyBlockingIssue[];
|
blockingIssues: PolicyBlockingIssue[];
|
||||||
|
|
||||||
/** Warning-level issues */
|
/** Warning-level issues */
|
||||||
warnings: PolicyWarning[];
|
warnings: PolicyWarning[];
|
||||||
|
|
||||||
/** Remediation hints for failures */
|
/** Remediation hints for failures */
|
||||||
remediationHints: PolicyRemediationHint[];
|
remediationHints: PolicyRemediationHint[];
|
||||||
|
|
||||||
/** Evaluation timestamp */
|
/** Evaluation timestamp */
|
||||||
evaluatedAt: string;
|
evaluatedAt: string;
|
||||||
|
|
||||||
/** Can the artifact be published? */
|
/** Can the artifact be published? */
|
||||||
canPublish: boolean;
|
canPublish: boolean;
|
||||||
|
|
||||||
/** Reason if publish is blocked */
|
/** Reason if publish is blocked */
|
||||||
blockReason?: string;
|
blockReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyGate {
|
export interface PolicyGate {
|
||||||
/** Gate identifier */
|
/** Gate identifier */
|
||||||
gateId: string;
|
gateId: string;
|
||||||
|
|
||||||
/** Human-readable name */
|
/** Human-readable name */
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
/** Gate type */
|
/** Gate type */
|
||||||
type: 'determinism' | 'vulnerability' | 'license' | 'signature' | 'entropy' | 'custom';
|
type: 'determinism' | 'vulnerability' | 'license' | 'signature' | 'entropy' | 'custom';
|
||||||
|
|
||||||
/** Gate result */
|
/** Gate result */
|
||||||
result: 'passed' | 'failed' | 'warning' | 'skipped';
|
result: 'passed' | 'failed' | 'warning' | 'skipped';
|
||||||
|
|
||||||
/** Is this gate required for publish? */
|
/** Is this gate required for publish? */
|
||||||
required: boolean;
|
required: boolean;
|
||||||
|
|
||||||
/** Gate-specific details */
|
/** Gate-specific details */
|
||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
|
|
||||||
/** Evidence references */
|
/** Evidence references */
|
||||||
evidenceRefs?: string[];
|
evidenceRefs?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyBlockingIssue {
|
export interface PolicyBlockingIssue {
|
||||||
/** Issue code */
|
/** Issue code */
|
||||||
code: string;
|
code: string;
|
||||||
|
|
||||||
/** Gate that produced this issue */
|
/** Gate that produced this issue */
|
||||||
gateId: string;
|
gateId: string;
|
||||||
|
|
||||||
/** Issue severity */
|
/** Issue severity */
|
||||||
severity: 'critical' | 'high';
|
severity: 'critical' | 'high';
|
||||||
|
|
||||||
/** Issue description */
|
/** Issue description */
|
||||||
message: string;
|
message: string;
|
||||||
|
|
||||||
/** Affected resource */
|
/** Affected resource */
|
||||||
resource?: string;
|
resource?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyWarning {
|
export interface PolicyWarning {
|
||||||
/** Warning code */
|
/** Warning code */
|
||||||
code: string;
|
code: string;
|
||||||
|
|
||||||
/** Gate that produced this warning */
|
/** Gate that produced this warning */
|
||||||
gateId: string;
|
gateId: string;
|
||||||
|
|
||||||
/** Warning message */
|
/** Warning message */
|
||||||
message: string;
|
message: string;
|
||||||
|
|
||||||
/** Affected resource */
|
/** Affected resource */
|
||||||
resource?: string;
|
resource?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyRemediationHint {
|
export interface PolicyRemediationHint {
|
||||||
/** Which gate/issue this remediates */
|
/** Which gate/issue this remediates */
|
||||||
forGate: string;
|
forGate: string;
|
||||||
|
|
||||||
/** Which issue code */
|
/** Which issue code */
|
||||||
forCode?: string;
|
forCode?: string;
|
||||||
|
|
||||||
/** Hint title */
|
/** Hint title */
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
/** Step-by-step instructions */
|
/** Step-by-step instructions */
|
||||||
steps: string[];
|
steps: string[];
|
||||||
|
|
||||||
/** Documentation link */
|
/** Documentation link */
|
||||||
docsUrl?: string;
|
docsUrl?: string;
|
||||||
|
|
||||||
/** CLI command to run */
|
/** CLI command to run */
|
||||||
cliCommand?: string;
|
cliCommand?: string;
|
||||||
|
|
||||||
/** Estimated effort */
|
/** Estimated effort */
|
||||||
effort?: 'trivial' | 'easy' | 'moderate' | 'complex';
|
effort?: 'trivial' | 'easy' | 'moderate' | 'complex';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeterminismGateDetails {
|
export interface DeterminismGateDetails {
|
||||||
/** Merkle root consistency */
|
/** Merkle root consistency */
|
||||||
merkleRootConsistent: boolean;
|
merkleRootConsistent: boolean;
|
||||||
|
|
||||||
/** Expected Merkle root */
|
/** Expected Merkle root */
|
||||||
expectedMerkleRoot?: string;
|
expectedMerkleRoot?: string;
|
||||||
|
|
||||||
/** Computed Merkle root */
|
/** Computed Merkle root */
|
||||||
computedMerkleRoot?: string;
|
computedMerkleRoot?: string;
|
||||||
|
|
||||||
/** Fragment verification results */
|
/** Fragment verification results */
|
||||||
fragmentResults: {
|
fragmentResults: {
|
||||||
fragmentId: string;
|
fragmentId: string;
|
||||||
expected: string;
|
expected: string;
|
||||||
computed: string;
|
computed: string;
|
||||||
match: boolean;
|
match: boolean;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
/** Composition file present */
|
/** Composition file present */
|
||||||
compositionPresent: boolean;
|
compositionPresent: boolean;
|
||||||
|
|
||||||
/** Total fragments */
|
/** Total fragments */
|
||||||
totalFragments: number;
|
totalFragments: number;
|
||||||
|
|
||||||
/** Matching fragments */
|
/** Matching fragments */
|
||||||
matchingFragments: number;
|
matchingFragments: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntropyGateDetails {
|
export interface EntropyGateDetails {
|
||||||
/** Overall entropy score */
|
/** Overall entropy score */
|
||||||
entropyScore: number;
|
entropyScore: number;
|
||||||
|
|
||||||
/** Score threshold for warning */
|
/** Score threshold for warning */
|
||||||
warnThreshold: number;
|
warnThreshold: number;
|
||||||
|
|
||||||
/** Score threshold for block */
|
/** Score threshold for block */
|
||||||
blockThreshold: number;
|
blockThreshold: number;
|
||||||
|
|
||||||
/** Action taken based on score */
|
/** Action taken based on score */
|
||||||
action: 'allow' | 'warn' | 'block';
|
action: 'allow' | 'warn' | 'block';
|
||||||
|
|
||||||
/** High entropy files count */
|
/** High entropy files count */
|
||||||
highEntropyFileCount: number;
|
highEntropyFileCount: number;
|
||||||
|
|
||||||
/** Suspicious patterns detected */
|
/** Suspicious patterns detected */
|
||||||
suspiciousPatterns: string[];
|
suspiciousPatterns: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,373 +1,373 @@
|
|||||||
import { Injectable, InjectionToken } from '@angular/core';
|
import { Injectable, InjectionToken } from '@angular/core';
|
||||||
import { Observable, of, delay } from 'rxjs';
|
import { Observable, of, delay } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
Release,
|
Release,
|
||||||
ReleaseArtifact,
|
ReleaseArtifact,
|
||||||
PolicyEvaluation,
|
PolicyEvaluation,
|
||||||
PolicyGateResult,
|
PolicyGateResult,
|
||||||
DeterminismGateDetails,
|
DeterminismGateDetails,
|
||||||
RemediationHint,
|
RemediationHint,
|
||||||
DeterminismFeatureFlags,
|
DeterminismFeatureFlags,
|
||||||
PolicyGateStatus,
|
PolicyGateStatus,
|
||||||
} from './release.models';
|
} from './release.models';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injection token for Release API client.
|
* Injection token for Release API client.
|
||||||
*/
|
*/
|
||||||
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
|
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release API interface.
|
* Release API interface.
|
||||||
*/
|
*/
|
||||||
export interface ReleaseApi {
|
export interface ReleaseApi {
|
||||||
getRelease(releaseId: string): Observable<Release>;
|
getRelease(releaseId: string): Observable<Release>;
|
||||||
listReleases(): Observable<readonly Release[]>;
|
listReleases(): Observable<readonly Release[]>;
|
||||||
publishRelease(releaseId: string): Observable<Release>;
|
publishRelease(releaseId: string): Observable<Release>;
|
||||||
cancelRelease(releaseId: string): Observable<Release>;
|
cancelRelease(releaseId: string): Observable<Release>;
|
||||||
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
|
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
|
||||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
|
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Mock Data Fixtures
|
// Mock Data Fixtures
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const determinismPassingGate: PolicyGateResult = {
|
const determinismPassingGate: PolicyGateResult = {
|
||||||
gateId: 'gate-det-001',
|
gateId: 'gate-det-001',
|
||||||
gateType: 'determinism',
|
gateType: 'determinism',
|
||||||
name: 'SBOM Determinism',
|
name: 'SBOM Determinism',
|
||||||
status: 'passed',
|
status: 'passed',
|
||||||
message: 'Merkle root consistent. All fragment attestations verified.',
|
message: 'Merkle root consistent. All fragment attestations verified.',
|
||||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||||
blockingPublish: true,
|
blockingPublish: true,
|
||||||
evidence: {
|
evidence: {
|
||||||
type: 'determinism',
|
type: 'determinism',
|
||||||
url: '/scans/scan-abc123?tab=determinism',
|
url: '/scans/scan-abc123?tab=determinism',
|
||||||
details: {
|
details: {
|
||||||
merkleRoot: 'sha256:a1b2c3d4e5f6...',
|
merkleRoot: 'sha256:a1b2c3d4e5f6...',
|
||||||
fragmentCount: 8,
|
fragmentCount: 8,
|
||||||
verifiedFragments: 8,
|
verifiedFragments: 8,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const determinismFailingGate: PolicyGateResult = {
|
const determinismFailingGate: PolicyGateResult = {
|
||||||
gateId: 'gate-det-002',
|
gateId: 'gate-det-002',
|
||||||
gateType: 'determinism',
|
gateType: 'determinism',
|
||||||
name: 'SBOM Determinism',
|
name: 'SBOM Determinism',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
|
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
|
||||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||||
blockingPublish: true,
|
blockingPublish: true,
|
||||||
evidence: {
|
evidence: {
|
||||||
type: 'determinism',
|
type: 'determinism',
|
||||||
url: '/scans/scan-def456?tab=determinism',
|
url: '/scans/scan-def456?tab=determinism',
|
||||||
details: {
|
details: {
|
||||||
merkleRoot: 'sha256:f1e2d3c4b5a6...',
|
merkleRoot: 'sha256:f1e2d3c4b5a6...',
|
||||||
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
|
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
|
||||||
fragmentCount: 8,
|
fragmentCount: 8,
|
||||||
verifiedFragments: 6,
|
verifiedFragments: 6,
|
||||||
failedFragments: [
|
failedFragments: [
|
||||||
'sha256:layer3digest...',
|
'sha256:layer3digest...',
|
||||||
'sha256:layer5digest...',
|
'sha256:layer5digest...',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
remediation: {
|
remediation: {
|
||||||
gateType: 'determinism',
|
gateType: 'determinism',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
|
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
action: 'rebuild',
|
action: 'rebuild',
|
||||||
title: 'Rebuild with deterministic toolchain',
|
title: 'Rebuild with deterministic toolchain',
|
||||||
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
|
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
|
||||||
command: 'stella scan --deterministic --sign --push',
|
command: 'stella scan --deterministic --sign --push',
|
||||||
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
|
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
|
||||||
automated: false,
|
automated: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: 'provide-provenance',
|
action: 'provide-provenance',
|
||||||
title: 'Provide provenance attestation',
|
title: 'Provide provenance attestation',
|
||||||
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
|
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
|
||||||
documentationUrl: 'https://docs.stellaops.io/provenance',
|
documentationUrl: 'https://docs.stellaops.io/provenance',
|
||||||
automated: false,
|
automated: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: 'sign-artifact',
|
action: 'sign-artifact',
|
||||||
title: 'Re-sign with valid key',
|
title: 'Re-sign with valid key',
|
||||||
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
|
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
|
||||||
command: 'stella sign --artifact sha256:...',
|
command: 'stella sign --artifact sha256:...',
|
||||||
automated: true,
|
automated: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: 'request-exception',
|
action: 'request-exception',
|
||||||
title: 'Request policy exception',
|
title: 'Request policy exception',
|
||||||
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
|
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
|
||||||
automated: true,
|
automated: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
estimatedEffort: '15-30 minutes',
|
estimatedEffort: '15-30 minutes',
|
||||||
exceptionAllowed: true,
|
exceptionAllowed: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const vulnerabilityPassingGate: PolicyGateResult = {
|
const vulnerabilityPassingGate: PolicyGateResult = {
|
||||||
gateId: 'gate-vuln-001',
|
gateId: 'gate-vuln-001',
|
||||||
gateType: 'vulnerability',
|
gateType: 'vulnerability',
|
||||||
name: 'Vulnerability Scan',
|
name: 'Vulnerability Scan',
|
||||||
status: 'passed',
|
status: 'passed',
|
||||||
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
|
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
|
||||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||||
blockingPublish: false,
|
blockingPublish: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const entropyWarningGate: PolicyGateResult = {
|
const entropyWarningGate: PolicyGateResult = {
|
||||||
gateId: 'gate-ent-001',
|
gateId: 'gate-ent-001',
|
||||||
gateType: 'entropy',
|
gateType: 'entropy',
|
||||||
name: 'Entropy Analysis',
|
name: 'Entropy Analysis',
|
||||||
status: 'warning',
|
status: 'warning',
|
||||||
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
|
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
|
||||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||||
blockingPublish: false,
|
blockingPublish: false,
|
||||||
remediation: {
|
remediation: {
|
||||||
gateType: 'entropy',
|
gateType: 'entropy',
|
||||||
severity: 'medium',
|
severity: 'medium',
|
||||||
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
|
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
action: 'provide-provenance',
|
action: 'provide-provenance',
|
||||||
title: 'Provide source provenance',
|
title: 'Provide source provenance',
|
||||||
description: 'Attach build provenance or source mappings for high-entropy binaries.',
|
description: 'Attach build provenance or source mappings for high-entropy binaries.',
|
||||||
automated: false,
|
automated: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
estimatedEffort: '10 minutes',
|
estimatedEffort: '10 minutes',
|
||||||
exceptionAllowed: true,
|
exceptionAllowed: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const licensePassingGate: PolicyGateResult = {
|
const licensePassingGate: PolicyGateResult = {
|
||||||
gateId: 'gate-lic-001',
|
gateId: 'gate-lic-001',
|
||||||
gateType: 'license',
|
gateType: 'license',
|
||||||
name: 'License Compliance',
|
name: 'License Compliance',
|
||||||
status: 'passed',
|
status: 'passed',
|
||||||
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
|
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
|
||||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||||
blockingPublish: false,
|
blockingPublish: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const signaturePassingGate: PolicyGateResult = {
|
const signaturePassingGate: PolicyGateResult = {
|
||||||
gateId: 'gate-sig-001',
|
gateId: 'gate-sig-001',
|
||||||
gateType: 'signature',
|
gateType: 'signature',
|
||||||
name: 'Signature Verification',
|
name: 'Signature Verification',
|
||||||
status: 'passed',
|
status: 'passed',
|
||||||
message: 'Image signature verified against tenant keyring.',
|
message: 'Image signature verified against tenant keyring.',
|
||||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||||
blockingPublish: true,
|
blockingPublish: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const signatureFailingGate: PolicyGateResult = {
|
const signatureFailingGate: PolicyGateResult = {
|
||||||
gateId: 'gate-sig-002',
|
gateId: 'gate-sig-002',
|
||||||
gateType: 'signature',
|
gateType: 'signature',
|
||||||
name: 'Signature Verification',
|
name: 'Signature Verification',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
message: 'No valid signature found. Image must be signed before release.',
|
message: 'No valid signature found. Image must be signed before release.',
|
||||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||||
blockingPublish: true,
|
blockingPublish: true,
|
||||||
remediation: {
|
remediation: {
|
||||||
gateType: 'signature',
|
gateType: 'signature',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
summary: 'The image is not signed or the signature cannot be verified.',
|
summary: 'The image is not signed or the signature cannot be verified.',
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
action: 'sign-artifact',
|
action: 'sign-artifact',
|
||||||
title: 'Sign the image',
|
title: 'Sign the image',
|
||||||
description: 'Sign the image using your tenant signing key.',
|
description: 'Sign the image using your tenant signing key.',
|
||||||
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
|
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
|
||||||
automated: true,
|
automated: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
estimatedEffort: '2 minutes',
|
estimatedEffort: '2 minutes',
|
||||||
exceptionAllowed: false,
|
exceptionAllowed: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Artifacts with policy evaluations
|
// Artifacts with policy evaluations
|
||||||
const passingArtifact: ReleaseArtifact = {
|
const passingArtifact: ReleaseArtifact = {
|
||||||
artifactId: 'art-001',
|
artifactId: 'art-001',
|
||||||
name: 'api-service',
|
name: 'api-service',
|
||||||
tag: 'v1.2.3',
|
tag: 'v1.2.3',
|
||||||
digest: 'sha256:abc123def456789012345678901234567890abcdef',
|
digest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||||
size: 245_000_000,
|
size: 245_000_000,
|
||||||
createdAt: '2025-11-27T08:00:00Z',
|
createdAt: '2025-11-27T08:00:00Z',
|
||||||
registry: 'registry.stellaops.io/prod',
|
registry: 'registry.stellaops.io/prod',
|
||||||
policyEvaluation: {
|
policyEvaluation: {
|
||||||
evaluationId: 'eval-001',
|
evaluationId: 'eval-001',
|
||||||
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
|
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||||
overallStatus: 'passed',
|
overallStatus: 'passed',
|
||||||
gates: [
|
gates: [
|
||||||
determinismPassingGate,
|
determinismPassingGate,
|
||||||
vulnerabilityPassingGate,
|
vulnerabilityPassingGate,
|
||||||
entropyWarningGate,
|
entropyWarningGate,
|
||||||
licensePassingGate,
|
licensePassingGate,
|
||||||
signaturePassingGate,
|
signaturePassingGate,
|
||||||
],
|
],
|
||||||
blockingGates: [],
|
blockingGates: [],
|
||||||
canPublish: true,
|
canPublish: true,
|
||||||
determinismDetails: {
|
determinismDetails: {
|
||||||
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
|
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
|
||||||
merkleRootConsistent: true,
|
merkleRootConsistent: true,
|
||||||
contentHash: 'sha256:content1234567890abcdef',
|
contentHash: 'sha256:content1234567890abcdef',
|
||||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
|
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
|
||||||
fragmentCount: 8,
|
fragmentCount: 8,
|
||||||
verifiedFragments: 8,
|
verifiedFragments: 8,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const failingArtifact: ReleaseArtifact = {
|
const failingArtifact: ReleaseArtifact = {
|
||||||
artifactId: 'art-002',
|
artifactId: 'art-002',
|
||||||
name: 'worker-service',
|
name: 'worker-service',
|
||||||
tag: 'v1.2.3',
|
tag: 'v1.2.3',
|
||||||
digest: 'sha256:def456abc789012345678901234567890fedcba98',
|
digest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||||
size: 312_000_000,
|
size: 312_000_000,
|
||||||
createdAt: '2025-11-27T07:45:00Z',
|
createdAt: '2025-11-27T07:45:00Z',
|
||||||
registry: 'registry.stellaops.io/prod',
|
registry: 'registry.stellaops.io/prod',
|
||||||
policyEvaluation: {
|
policyEvaluation: {
|
||||||
evaluationId: 'eval-002',
|
evaluationId: 'eval-002',
|
||||||
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
|
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||||
overallStatus: 'failed',
|
overallStatus: 'failed',
|
||||||
gates: [
|
gates: [
|
||||||
determinismFailingGate,
|
determinismFailingGate,
|
||||||
vulnerabilityPassingGate,
|
vulnerabilityPassingGate,
|
||||||
licensePassingGate,
|
licensePassingGate,
|
||||||
signatureFailingGate,
|
signatureFailingGate,
|
||||||
],
|
],
|
||||||
blockingGates: ['gate-det-002', 'gate-sig-002'],
|
blockingGates: ['gate-det-002', 'gate-sig-002'],
|
||||||
canPublish: false,
|
canPublish: false,
|
||||||
determinismDetails: {
|
determinismDetails: {
|
||||||
merkleRoot: 'sha256:f1e2d3c4b5a67890',
|
merkleRoot: 'sha256:f1e2d3c4b5a67890',
|
||||||
merkleRootConsistent: false,
|
merkleRootConsistent: false,
|
||||||
contentHash: 'sha256:content9876543210',
|
contentHash: 'sha256:content9876543210',
|
||||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
|
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
|
||||||
fragmentCount: 8,
|
fragmentCount: 8,
|
||||||
verifiedFragments: 6,
|
verifiedFragments: 6,
|
||||||
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
|
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Release fixtures
|
// Release fixtures
|
||||||
const passingRelease: Release = {
|
const passingRelease: Release = {
|
||||||
releaseId: 'rel-001',
|
releaseId: 'rel-001',
|
||||||
name: 'Platform v1.2.3',
|
name: 'Platform v1.2.3',
|
||||||
version: '1.2.3',
|
version: '1.2.3',
|
||||||
status: 'pending_approval',
|
status: 'pending_approval',
|
||||||
createdAt: '2025-11-27T08:30:00Z',
|
createdAt: '2025-11-27T08:30:00Z',
|
||||||
createdBy: 'deploy-bot',
|
createdBy: 'deploy-bot',
|
||||||
artifacts: [passingArtifact],
|
artifacts: [passingArtifact],
|
||||||
targetEnvironment: 'production',
|
targetEnvironment: 'production',
|
||||||
notes: 'Feature release with API improvements and bug fixes.',
|
notes: 'Feature release with API improvements and bug fixes.',
|
||||||
approvals: [
|
approvals: [
|
||||||
{
|
{
|
||||||
approvalId: 'apr-001',
|
approvalId: 'apr-001',
|
||||||
approver: 'security-team',
|
approver: 'security-team',
|
||||||
decision: 'approved',
|
decision: 'approved',
|
||||||
comment: 'Security review passed.',
|
comment: 'Security review passed.',
|
||||||
decidedAt: '2025-11-27T09:00:00Z',
|
decidedAt: '2025-11-27T09:00:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
approvalId: 'apr-002',
|
approvalId: 'apr-002',
|
||||||
approver: 'release-manager',
|
approver: 'release-manager',
|
||||||
decision: 'pending',
|
decision: 'pending',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const blockedRelease: Release = {
|
const blockedRelease: Release = {
|
||||||
releaseId: 'rel-002',
|
releaseId: 'rel-002',
|
||||||
name: 'Platform v1.2.4-rc1',
|
name: 'Platform v1.2.4-rc1',
|
||||||
version: '1.2.4-rc1',
|
version: '1.2.4-rc1',
|
||||||
status: 'blocked',
|
status: 'blocked',
|
||||||
createdAt: '2025-11-27T07:00:00Z',
|
createdAt: '2025-11-27T07:00:00Z',
|
||||||
createdBy: 'deploy-bot',
|
createdBy: 'deploy-bot',
|
||||||
artifacts: [failingArtifact],
|
artifacts: [failingArtifact],
|
||||||
targetEnvironment: 'staging',
|
targetEnvironment: 'staging',
|
||||||
notes: 'Release candidate blocked due to policy gate failures.',
|
notes: 'Release candidate blocked due to policy gate failures.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const mixedRelease: Release = {
|
const mixedRelease: Release = {
|
||||||
releaseId: 'rel-003',
|
releaseId: 'rel-003',
|
||||||
name: 'Platform v1.2.5',
|
name: 'Platform v1.2.5',
|
||||||
version: '1.2.5',
|
version: '1.2.5',
|
||||||
status: 'blocked',
|
status: 'blocked',
|
||||||
createdAt: '2025-11-27T06:00:00Z',
|
createdAt: '2025-11-27T06:00:00Z',
|
||||||
createdBy: 'ci-pipeline',
|
createdBy: 'ci-pipeline',
|
||||||
artifacts: [passingArtifact, failingArtifact],
|
artifacts: [passingArtifact, failingArtifact],
|
||||||
targetEnvironment: 'production',
|
targetEnvironment: 'production',
|
||||||
notes: 'Multi-artifact release with mixed policy results.',
|
notes: 'Multi-artifact release with mixed policy results.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
|
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
|
||||||
|
|
||||||
const mockFeatureFlags: DeterminismFeatureFlags = {
|
const mockFeatureFlags: DeterminismFeatureFlags = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
blockOnFailure: true,
|
blockOnFailure: true,
|
||||||
warnOnly: false,
|
warnOnly: false,
|
||||||
bypassRoles: ['security-admin', 'release-manager'],
|
bypassRoles: ['security-admin', 'release-manager'],
|
||||||
requireApprovalForBypass: true,
|
requireApprovalForBypass: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Mock API Implementation
|
// Mock API Implementation
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MockReleaseApi implements ReleaseApi {
|
export class MockReleaseApi implements ReleaseApi {
|
||||||
getRelease(releaseId: string): Observable<Release> {
|
getRelease(releaseId: string): Observable<Release> {
|
||||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||||
if (!release) {
|
if (!release) {
|
||||||
throw new Error(`Release not found: ${releaseId}`);
|
throw new Error(`Release not found: ${releaseId}`);
|
||||||
}
|
}
|
||||||
return of(release).pipe(delay(200));
|
return of(release).pipe(delay(200));
|
||||||
}
|
}
|
||||||
|
|
||||||
listReleases(): Observable<readonly Release[]> {
|
listReleases(): Observable<readonly Release[]> {
|
||||||
return of(mockReleases).pipe(delay(300));
|
return of(mockReleases).pipe(delay(300));
|
||||||
}
|
}
|
||||||
|
|
||||||
publishRelease(releaseId: string): Observable<Release> {
|
publishRelease(releaseId: string): Observable<Release> {
|
||||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||||
if (!release) {
|
if (!release) {
|
||||||
throw new Error(`Release not found: ${releaseId}`);
|
throw new Error(`Release not found: ${releaseId}`);
|
||||||
}
|
}
|
||||||
// Simulate publish (would update status in real implementation)
|
// Simulate publish (would update status in real implementation)
|
||||||
return of({
|
return of({
|
||||||
...release,
|
...release,
|
||||||
status: 'published',
|
status: 'published',
|
||||||
publishedAt: new Date().toISOString(),
|
publishedAt: new Date().toISOString(),
|
||||||
} as Release).pipe(delay(500));
|
} as Release).pipe(delay(500));
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelRelease(releaseId: string): Observable<Release> {
|
cancelRelease(releaseId: string): Observable<Release> {
|
||||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||||
if (!release) {
|
if (!release) {
|
||||||
throw new Error(`Release not found: ${releaseId}`);
|
throw new Error(`Release not found: ${releaseId}`);
|
||||||
}
|
}
|
||||||
return of({
|
return of({
|
||||||
...release,
|
...release,
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
} as Release).pipe(delay(300));
|
} as Release).pipe(delay(300));
|
||||||
}
|
}
|
||||||
|
|
||||||
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
|
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
|
||||||
return of(mockFeatureFlags).pipe(delay(100));
|
return of(mockFeatureFlags).pipe(delay(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
|
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
|
||||||
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
|
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,161 +1,161 @@
|
|||||||
/**
|
/**
|
||||||
* Release and Policy Gate models for UI-POLICY-DET-01.
|
* Release and Policy Gate models for UI-POLICY-DET-01.
|
||||||
* Supports determinism-gated release flows with remediation hints.
|
* Supports determinism-gated release flows with remediation hints.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Policy gate evaluation status
|
// Policy gate evaluation status
|
||||||
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
|
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
|
||||||
|
|
||||||
// Types of policy gates
|
// Types of policy gates
|
||||||
export type PolicyGateType =
|
export type PolicyGateType =
|
||||||
| 'determinism'
|
| 'determinism'
|
||||||
| 'vulnerability'
|
| 'vulnerability'
|
||||||
| 'license'
|
| 'license'
|
||||||
| 'entropy'
|
| 'entropy'
|
||||||
| 'signature'
|
| 'signature'
|
||||||
| 'sbom-completeness'
|
| 'sbom-completeness'
|
||||||
| 'custom';
|
| 'custom';
|
||||||
|
|
||||||
// Remediation action types
|
// Remediation action types
|
||||||
export type RemediationActionType =
|
export type RemediationActionType =
|
||||||
| 'rebuild'
|
| 'rebuild'
|
||||||
| 'provide-provenance'
|
| 'provide-provenance'
|
||||||
| 'sign-artifact'
|
| 'sign-artifact'
|
||||||
| 'update-dependency'
|
| 'update-dependency'
|
||||||
| 'request-exception'
|
| 'request-exception'
|
||||||
| 'manual-review';
|
| 'manual-review';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single remediation step with optional automation support.
|
* A single remediation step with optional automation support.
|
||||||
*/
|
*/
|
||||||
export interface RemediationStep {
|
export interface RemediationStep {
|
||||||
readonly action: RemediationActionType;
|
readonly action: RemediationActionType;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
readonly command?: string; // Optional CLI command to run
|
readonly command?: string; // Optional CLI command to run
|
||||||
readonly documentationUrl?: string;
|
readonly documentationUrl?: string;
|
||||||
readonly automated: boolean; // Can be triggered from UI
|
readonly automated: boolean; // Can be triggered from UI
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remediation hints for a failed policy gate.
|
* Remediation hints for a failed policy gate.
|
||||||
*/
|
*/
|
||||||
export interface RemediationHint {
|
export interface RemediationHint {
|
||||||
readonly gateType: PolicyGateType;
|
readonly gateType: PolicyGateType;
|
||||||
readonly severity: 'critical' | 'high' | 'medium' | 'low';
|
readonly severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
readonly steps: readonly RemediationStep[];
|
readonly steps: readonly RemediationStep[];
|
||||||
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
|
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
|
||||||
readonly exceptionAllowed: boolean;
|
readonly exceptionAllowed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Individual policy gate evaluation result.
|
* Individual policy gate evaluation result.
|
||||||
*/
|
*/
|
||||||
export interface PolicyGateResult {
|
export interface PolicyGateResult {
|
||||||
readonly gateId: string;
|
readonly gateId: string;
|
||||||
readonly gateType: PolicyGateType;
|
readonly gateType: PolicyGateType;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly status: PolicyGateStatus;
|
readonly status: PolicyGateStatus;
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
readonly evaluatedAt: string;
|
readonly evaluatedAt: string;
|
||||||
readonly blockingPublish: boolean;
|
readonly blockingPublish: boolean;
|
||||||
readonly evidence?: {
|
readonly evidence?: {
|
||||||
readonly type: string;
|
readonly type: string;
|
||||||
readonly url?: string;
|
readonly url?: string;
|
||||||
readonly details?: Record<string, unknown>;
|
readonly details?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
readonly remediation?: RemediationHint;
|
readonly remediation?: RemediationHint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determinism-specific gate details.
|
* Determinism-specific gate details.
|
||||||
*/
|
*/
|
||||||
export interface DeterminismGateDetails {
|
export interface DeterminismGateDetails {
|
||||||
readonly merkleRoot?: string;
|
readonly merkleRoot?: string;
|
||||||
readonly merkleRootConsistent: boolean;
|
readonly merkleRootConsistent: boolean;
|
||||||
readonly contentHash?: string;
|
readonly contentHash?: string;
|
||||||
readonly compositionManifestUri?: string;
|
readonly compositionManifestUri?: string;
|
||||||
readonly fragmentCount?: number;
|
readonly fragmentCount?: number;
|
||||||
readonly verifiedFragments?: number;
|
readonly verifiedFragments?: number;
|
||||||
readonly failedFragments?: readonly string[]; // Layer digests that failed
|
readonly failedFragments?: readonly string[]; // Layer digests that failed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overall policy evaluation for a release artifact.
|
* Overall policy evaluation for a release artifact.
|
||||||
*/
|
*/
|
||||||
export interface PolicyEvaluation {
|
export interface PolicyEvaluation {
|
||||||
readonly evaluationId: string;
|
readonly evaluationId: string;
|
||||||
readonly artifactDigest: string;
|
readonly artifactDigest: string;
|
||||||
readonly evaluatedAt: string;
|
readonly evaluatedAt: string;
|
||||||
readonly overallStatus: PolicyGateStatus;
|
readonly overallStatus: PolicyGateStatus;
|
||||||
readonly gates: readonly PolicyGateResult[];
|
readonly gates: readonly PolicyGateResult[];
|
||||||
readonly blockingGates: readonly string[]; // Gate IDs that block publish
|
readonly blockingGates: readonly string[]; // Gate IDs that block publish
|
||||||
readonly canPublish: boolean;
|
readonly canPublish: boolean;
|
||||||
readonly determinismDetails?: DeterminismGateDetails;
|
readonly determinismDetails?: DeterminismGateDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release artifact with policy evaluation.
|
* Release artifact with policy evaluation.
|
||||||
*/
|
*/
|
||||||
export interface ReleaseArtifact {
|
export interface ReleaseArtifact {
|
||||||
readonly artifactId: string;
|
readonly artifactId: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly tag: string;
|
readonly tag: string;
|
||||||
readonly digest: string;
|
readonly digest: string;
|
||||||
readonly size: number;
|
readonly size: number;
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly registry: string;
|
readonly registry: string;
|
||||||
readonly policyEvaluation?: PolicyEvaluation;
|
readonly policyEvaluation?: PolicyEvaluation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release workflow status.
|
* Release workflow status.
|
||||||
*/
|
*/
|
||||||
export type ReleaseStatus =
|
export type ReleaseStatus =
|
||||||
| 'draft'
|
| 'draft'
|
||||||
| 'pending_approval'
|
| 'pending_approval'
|
||||||
| 'approved'
|
| 'approved'
|
||||||
| 'publishing'
|
| 'publishing'
|
||||||
| 'published'
|
| 'published'
|
||||||
| 'blocked'
|
| 'blocked'
|
||||||
| 'cancelled';
|
| 'cancelled';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release with multiple artifacts and policy gates.
|
* Release with multiple artifacts and policy gates.
|
||||||
*/
|
*/
|
||||||
export interface Release {
|
export interface Release {
|
||||||
readonly releaseId: string;
|
readonly releaseId: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly version: string;
|
readonly version: string;
|
||||||
readonly status: ReleaseStatus;
|
readonly status: ReleaseStatus;
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly artifacts: readonly ReleaseArtifact[];
|
readonly artifacts: readonly ReleaseArtifact[];
|
||||||
readonly targetEnvironment: string;
|
readonly targetEnvironment: string;
|
||||||
readonly notes?: string;
|
readonly notes?: string;
|
||||||
readonly approvals?: readonly ReleaseApproval[];
|
readonly approvals?: readonly ReleaseApproval[];
|
||||||
readonly publishedAt?: string;
|
readonly publishedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release approval record.
|
* Release approval record.
|
||||||
*/
|
*/
|
||||||
export interface ReleaseApproval {
|
export interface ReleaseApproval {
|
||||||
readonly approvalId: string;
|
readonly approvalId: string;
|
||||||
readonly approver: string;
|
readonly approver: string;
|
||||||
readonly decision: 'approved' | 'rejected' | 'pending';
|
readonly decision: 'approved' | 'rejected' | 'pending';
|
||||||
readonly comment?: string;
|
readonly comment?: string;
|
||||||
readonly decidedAt?: string;
|
readonly decidedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature flag configuration for determinism blocking.
|
* Feature flag configuration for determinism blocking.
|
||||||
*/
|
*/
|
||||||
export interface DeterminismFeatureFlags {
|
export interface DeterminismFeatureFlags {
|
||||||
readonly enabled: boolean;
|
readonly enabled: boolean;
|
||||||
readonly blockOnFailure: boolean;
|
readonly blockOnFailure: boolean;
|
||||||
readonly warnOnly: boolean;
|
readonly warnOnly: boolean;
|
||||||
readonly bypassRoles?: readonly string[];
|
readonly bypassRoles?: readonly string[];
|
||||||
readonly requireApprovalForBypass: boolean;
|
readonly requireApprovalForBypass: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,147 +1,147 @@
|
|||||||
export type ScanAttestationStatusKind = 'verified' | 'pending' | 'failed';
|
export type ScanAttestationStatusKind = 'verified' | 'pending' | 'failed';
|
||||||
|
|
||||||
export interface ScanAttestationStatus {
|
export interface ScanAttestationStatus {
|
||||||
readonly uuid: string;
|
readonly uuid: string;
|
||||||
readonly status: ScanAttestationStatusKind;
|
readonly status: ScanAttestationStatusKind;
|
||||||
readonly index?: number;
|
readonly index?: number;
|
||||||
readonly logUrl?: string;
|
readonly logUrl?: string;
|
||||||
readonly checkedAt?: string;
|
readonly checkedAt?: string;
|
||||||
readonly statusMessage?: string;
|
readonly statusMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determinism models based on docs/modules/scanner/deterministic-sbom-compose.md
|
// Determinism models based on docs/modules/scanner/deterministic-sbom-compose.md
|
||||||
|
|
||||||
export type DeterminismStatus = 'verified' | 'pending' | 'failed' | 'unknown';
|
export type DeterminismStatus = 'verified' | 'pending' | 'failed' | 'unknown';
|
||||||
|
|
||||||
export interface FragmentAttestation {
|
export interface FragmentAttestation {
|
||||||
readonly layerDigest: string;
|
readonly layerDigest: string;
|
||||||
readonly fragmentSha256: string;
|
readonly fragmentSha256: string;
|
||||||
readonly dsseEnvelopeSha256: string;
|
readonly dsseEnvelopeSha256: string;
|
||||||
readonly dsseStatus: 'verified' | 'pending' | 'failed';
|
readonly dsseStatus: 'verified' | 'pending' | 'failed';
|
||||||
readonly verifiedAt?: string;
|
readonly verifiedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompositionManifest {
|
export interface CompositionManifest {
|
||||||
readonly compositionUri: string;
|
readonly compositionUri: string;
|
||||||
readonly merkleRoot: string;
|
readonly merkleRoot: string;
|
||||||
readonly fragmentCount: number;
|
readonly fragmentCount: number;
|
||||||
readonly fragments: readonly FragmentAttestation[];
|
readonly fragments: readonly FragmentAttestation[];
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeterminismEvidence {
|
export interface DeterminismEvidence {
|
||||||
readonly status: DeterminismStatus;
|
readonly status: DeterminismStatus;
|
||||||
readonly merkleRoot?: string;
|
readonly merkleRoot?: string;
|
||||||
readonly merkleRootConsistent: boolean;
|
readonly merkleRootConsistent: boolean;
|
||||||
readonly compositionManifest?: CompositionManifest;
|
readonly compositionManifest?: CompositionManifest;
|
||||||
readonly contentHash?: string;
|
readonly contentHash?: string;
|
||||||
readonly verifiedAt?: string;
|
readonly verifiedAt?: string;
|
||||||
readonly failureReason?: string;
|
readonly failureReason?: string;
|
||||||
readonly stellaProperties?: {
|
readonly stellaProperties?: {
|
||||||
readonly 'stellaops:stella.contentHash'?: string;
|
readonly 'stellaops:stella.contentHash'?: string;
|
||||||
readonly 'stellaops:composition.manifest'?: string;
|
readonly 'stellaops:composition.manifest'?: string;
|
||||||
readonly 'stellaops:merkle.root'?: string;
|
readonly 'stellaops:merkle.root'?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entropy analysis models based on docs/modules/scanner/entropy.md
|
// Entropy analysis models based on docs/modules/scanner/entropy.md
|
||||||
|
|
||||||
export interface EntropyWindow {
|
export interface EntropyWindow {
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
readonly length: number;
|
readonly length: number;
|
||||||
readonly entropy: number; // 0-8 bits/byte
|
readonly entropy: number; // 0-8 bits/byte
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntropyFile {
|
export interface EntropyFile {
|
||||||
readonly path: string;
|
readonly path: string;
|
||||||
readonly size: number;
|
readonly size: number;
|
||||||
readonly opaqueBytes: number;
|
readonly opaqueBytes: number;
|
||||||
readonly opaqueRatio: number; // 0-1
|
readonly opaqueRatio: number; // 0-1
|
||||||
readonly flags: readonly string[]; // e.g., 'stripped', 'section:.UPX0', 'no-symbols', 'packed'
|
readonly flags: readonly string[]; // e.g., 'stripped', 'section:.UPX0', 'no-symbols', 'packed'
|
||||||
readonly windows: readonly EntropyWindow[];
|
readonly windows: readonly EntropyWindow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntropyLayerSummary {
|
export interface EntropyLayerSummary {
|
||||||
readonly digest: string;
|
readonly digest: string;
|
||||||
readonly opaqueBytes: number;
|
readonly opaqueBytes: number;
|
||||||
readonly totalBytes: number;
|
readonly totalBytes: number;
|
||||||
readonly opaqueRatio: number; // 0-1
|
readonly opaqueRatio: number; // 0-1
|
||||||
readonly indicators: readonly string[]; // e.g., 'packed', 'no-symbols'
|
readonly indicators: readonly string[]; // e.g., 'packed', 'no-symbols'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntropyReport {
|
export interface EntropyReport {
|
||||||
readonly schema: string;
|
readonly schema: string;
|
||||||
readonly generatedAt: string;
|
readonly generatedAt: string;
|
||||||
readonly imageDigest: string;
|
readonly imageDigest: string;
|
||||||
readonly layerDigest?: string;
|
readonly layerDigest?: string;
|
||||||
readonly files: readonly EntropyFile[];
|
readonly files: readonly EntropyFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntropyLayerSummaryReport {
|
export interface EntropyLayerSummaryReport {
|
||||||
readonly schema: string;
|
readonly schema: string;
|
||||||
readonly generatedAt: string;
|
readonly generatedAt: string;
|
||||||
readonly imageDigest: string;
|
readonly imageDigest: string;
|
||||||
readonly layers: readonly EntropyLayerSummary[];
|
readonly layers: readonly EntropyLayerSummary[];
|
||||||
readonly imageOpaqueRatio: number; // 0-1
|
readonly imageOpaqueRatio: number; // 0-1
|
||||||
readonly entropyPenalty: number; // 0-0.3
|
readonly entropyPenalty: number; // 0-0.3
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntropyEvidence {
|
export interface EntropyEvidence {
|
||||||
readonly report?: EntropyReport;
|
readonly report?: EntropyReport;
|
||||||
readonly layerSummary?: EntropyLayerSummaryReport;
|
readonly layerSummary?: EntropyLayerSummaryReport;
|
||||||
readonly downloadUrl?: string; // URL to entropy.report.json
|
readonly downloadUrl?: string; // URL to entropy.report.json
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binary Evidence models for SPRINT_20251226_014_BINIDX (SCANINT-17,18,19)
|
// Binary Evidence models for SPRINT_20251226_014_BINIDX (SCANINT-17,18,19)
|
||||||
|
|
||||||
export type BinaryFixStatus = 'fixed' | 'vulnerable' | 'not_affected' | 'wontfix' | 'unknown';
|
export type BinaryFixStatus = 'fixed' | 'vulnerable' | 'not_affected' | 'wontfix' | 'unknown';
|
||||||
|
|
||||||
export interface BinaryIdentity {
|
export interface BinaryIdentity {
|
||||||
readonly format: 'elf' | 'pe' | 'macho';
|
readonly format: 'elf' | 'pe' | 'macho';
|
||||||
readonly buildId?: string;
|
readonly buildId?: string;
|
||||||
readonly fileSha256: string;
|
readonly fileSha256: string;
|
||||||
readonly architecture: string;
|
readonly architecture: string;
|
||||||
readonly binaryKey: string;
|
readonly binaryKey: string;
|
||||||
readonly path?: string;
|
readonly path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BinaryFixStatusInfo {
|
export interface BinaryFixStatusInfo {
|
||||||
readonly state: BinaryFixStatus;
|
readonly state: BinaryFixStatus;
|
||||||
readonly fixedVersion?: string;
|
readonly fixedVersion?: string;
|
||||||
readonly method: 'changelog' | 'patch_analysis' | 'advisory';
|
readonly method: 'changelog' | 'patch_analysis' | 'advisory';
|
||||||
readonly confidence: number;
|
readonly confidence: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BinaryVulnMatch {
|
export interface BinaryVulnMatch {
|
||||||
readonly cveId: string;
|
readonly cveId: string;
|
||||||
readonly method: 'buildid_catalog' | 'fingerprint_match' | 'range_match';
|
readonly method: 'buildid_catalog' | 'fingerprint_match' | 'range_match';
|
||||||
readonly confidence: number;
|
readonly confidence: number;
|
||||||
readonly vulnerablePurl: string;
|
readonly vulnerablePurl: string;
|
||||||
readonly fixStatus?: BinaryFixStatusInfo;
|
readonly fixStatus?: BinaryFixStatusInfo;
|
||||||
readonly similarity?: number;
|
readonly similarity?: number;
|
||||||
readonly matchedFunction?: string;
|
readonly matchedFunction?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BinaryFinding {
|
export interface BinaryFinding {
|
||||||
readonly identity: BinaryIdentity;
|
readonly identity: BinaryIdentity;
|
||||||
readonly layerDigest: string;
|
readonly layerDigest: string;
|
||||||
readonly matches: readonly BinaryVulnMatch[];
|
readonly matches: readonly BinaryVulnMatch[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BinaryEvidence {
|
export interface BinaryEvidence {
|
||||||
readonly binaries: readonly BinaryFinding[];
|
readonly binaries: readonly BinaryFinding[];
|
||||||
readonly scanId: string;
|
readonly scanId: string;
|
||||||
readonly scannedAt: string;
|
readonly scannedAt: string;
|
||||||
readonly distro?: string;
|
readonly distro?: string;
|
||||||
readonly release?: string;
|
readonly release?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScanDetail {
|
export interface ScanDetail {
|
||||||
readonly scanId: string;
|
readonly scanId: string;
|
||||||
readonly imageDigest: string;
|
readonly imageDigest: string;
|
||||||
readonly completedAt: string;
|
readonly completedAt: string;
|
||||||
readonly attestation?: ScanAttestationStatus;
|
readonly attestation?: ScanAttestationStatus;
|
||||||
readonly determinism?: DeterminismEvidence;
|
readonly determinism?: DeterminismEvidence;
|
||||||
readonly entropy?: EntropyEvidence;
|
readonly entropy?: EntropyEvidence;
|
||||||
readonly binaryEvidence?: BinaryEvidence;
|
readonly binaryEvidence?: BinaryEvidence;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,267 +1,267 @@
|
|||||||
import { Injectable, InjectionToken } from '@angular/core';
|
import { Injectable, InjectionToken } from '@angular/core';
|
||||||
import { Observable, of, delay } from 'rxjs';
|
import { Observable, of, delay } from 'rxjs';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Vulnerability,
|
Vulnerability,
|
||||||
VulnerabilitiesQueryOptions,
|
VulnerabilitiesQueryOptions,
|
||||||
VulnerabilitiesResponse,
|
VulnerabilitiesResponse,
|
||||||
VulnerabilityStats,
|
VulnerabilityStats,
|
||||||
VulnWorkflowRequest,
|
VulnWorkflowRequest,
|
||||||
VulnWorkflowResponse,
|
VulnWorkflowResponse,
|
||||||
VulnExportRequest,
|
VulnExportRequest,
|
||||||
VulnExportResponse,
|
VulnExportResponse,
|
||||||
} from './vulnerability.models';
|
} from './vulnerability.models';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vulnerability API interface.
|
* Vulnerability API interface.
|
||||||
* Implements WEB-VULN-29-001 contract with tenant scoping and RBAC/ABAC enforcement.
|
* Implements WEB-VULN-29-001 contract with tenant scoping and RBAC/ABAC enforcement.
|
||||||
*/
|
*/
|
||||||
export interface VulnerabilityApi {
|
export interface VulnerabilityApi {
|
||||||
/** List vulnerabilities with filtering and pagination. */
|
/** List vulnerabilities with filtering and pagination. */
|
||||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse>;
|
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse>;
|
||||||
|
|
||||||
/** Get a single vulnerability by ID. */
|
/** Get a single vulnerability by ID. */
|
||||||
getVulnerability(vulnId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<Vulnerability>;
|
getVulnerability(vulnId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<Vulnerability>;
|
||||||
|
|
||||||
/** Get vulnerability statistics. */
|
/** Get vulnerability statistics. */
|
||||||
getStats(options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats>;
|
getStats(options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats>;
|
||||||
|
|
||||||
/** Submit a workflow action (ack, close, reopen, etc.). */
|
/** Submit a workflow action (ack, close, reopen, etc.). */
|
||||||
submitWorkflowAction(request: VulnWorkflowRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnWorkflowResponse>;
|
submitWorkflowAction(request: VulnWorkflowRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnWorkflowResponse>;
|
||||||
|
|
||||||
/** Request a vulnerability export. */
|
/** Request a vulnerability export. */
|
||||||
requestExport(request: VulnExportRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
|
requestExport(request: VulnExportRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
|
||||||
|
|
||||||
/** Get export status by ID. */
|
/** Get export status by ID. */
|
||||||
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
|
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VULNERABILITY_API = new InjectionToken<VulnerabilityApi>('VULNERABILITY_API');
|
export const VULNERABILITY_API = new InjectionToken<VulnerabilityApi>('VULNERABILITY_API');
|
||||||
|
|
||||||
const MOCK_VULNERABILITIES: Vulnerability[] = [
|
const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||||
{
|
{
|
||||||
vulnId: 'vuln-001',
|
vulnId: 'vuln-001',
|
||||||
cveId: 'CVE-2021-44228',
|
cveId: 'CVE-2021-44228',
|
||||||
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
|
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
|
||||||
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
cvssScore: 10.0,
|
cvssScore: 10.0,
|
||||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||||
status: 'open',
|
status: 'open',
|
||||||
publishedAt: '2021-12-10T00:00:00Z',
|
publishedAt: '2021-12-10T00:00:00Z',
|
||||||
modifiedAt: '2024-06-27T00:00:00Z',
|
modifiedAt: '2024-06-27T00:00:00Z',
|
||||||
affectedComponents: [
|
affectedComponents: [
|
||||||
{
|
{
|
||||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
|
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
|
||||||
name: 'log4j-core',
|
name: 'log4j-core',
|
||||||
version: '2.14.1',
|
version: '2.14.1',
|
||||||
fixedVersion: '2.17.1',
|
fixedVersion: '2.17.1',
|
||||||
assetIds: ['asset-web-prod', 'asset-api-prod'],
|
assetIds: ['asset-web-prod', 'asset-api-prod'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
references: [
|
references: [
|
||||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||||
'https://logging.apache.org/log4j/2.x/security.html',
|
'https://logging.apache.org/log4j/2.x/security.html',
|
||||||
],
|
],
|
||||||
hasException: false,
|
hasException: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
vulnId: 'vuln-002',
|
vulnId: 'vuln-002',
|
||||||
cveId: 'CVE-2021-45046',
|
cveId: 'CVE-2021-45046',
|
||||||
title: 'Log4j2 Thread Context Message Pattern DoS',
|
title: 'Log4j2 Thread Context Message Pattern DoS',
|
||||||
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
|
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
cvssScore: 9.0,
|
cvssScore: 9.0,
|
||||||
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||||
status: 'excepted',
|
status: 'excepted',
|
||||||
publishedAt: '2021-12-14T00:00:00Z',
|
publishedAt: '2021-12-14T00:00:00Z',
|
||||||
modifiedAt: '2023-11-06T00:00:00Z',
|
modifiedAt: '2023-11-06T00:00:00Z',
|
||||||
affectedComponents: [
|
affectedComponents: [
|
||||||
{
|
{
|
||||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0',
|
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0',
|
||||||
name: 'log4j-core',
|
name: 'log4j-core',
|
||||||
version: '2.15.0',
|
version: '2.15.0',
|
||||||
fixedVersion: '2.17.1',
|
fixedVersion: '2.17.1',
|
||||||
assetIds: ['asset-internal-001'],
|
assetIds: ['asset-internal-001'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hasException: true,
|
hasException: true,
|
||||||
exceptionId: 'exc-test-001',
|
exceptionId: 'exc-test-001',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
vulnId: 'vuln-003',
|
vulnId: 'vuln-003',
|
||||||
cveId: 'CVE-2023-44487',
|
cveId: 'CVE-2023-44487',
|
||||||
title: 'HTTP/2 Rapid Reset Attack',
|
title: 'HTTP/2 Rapid Reset Attack',
|
||||||
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
|
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
cvssScore: 7.5,
|
cvssScore: 7.5,
|
||||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H',
|
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H',
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
publishedAt: '2023-10-10T00:00:00Z',
|
publishedAt: '2023-10-10T00:00:00Z',
|
||||||
modifiedAt: '2024-05-01T00:00:00Z',
|
modifiedAt: '2024-05-01T00:00:00Z',
|
||||||
affectedComponents: [
|
affectedComponents: [
|
||||||
{
|
{
|
||||||
purl: 'pkg:golang/golang.org/x/net@0.15.0',
|
purl: 'pkg:golang/golang.org/x/net@0.15.0',
|
||||||
name: 'golang.org/x/net',
|
name: 'golang.org/x/net',
|
||||||
version: '0.15.0',
|
version: '0.15.0',
|
||||||
fixedVersion: '0.17.0',
|
fixedVersion: '0.17.0',
|
||||||
assetIds: ['asset-api-prod', 'asset-worker-prod'],
|
assetIds: ['asset-api-prod', 'asset-worker-prod'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
purl: 'pkg:npm/nghttp2@1.55.0',
|
purl: 'pkg:npm/nghttp2@1.55.0',
|
||||||
name: 'nghttp2',
|
name: 'nghttp2',
|
||||||
version: '1.55.0',
|
version: '1.55.0',
|
||||||
fixedVersion: '1.57.0',
|
fixedVersion: '1.57.0',
|
||||||
assetIds: ['asset-web-prod'],
|
assetIds: ['asset-web-prod'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hasException: false,
|
hasException: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
vulnId: 'vuln-004',
|
vulnId: 'vuln-004',
|
||||||
cveId: 'CVE-2024-21626',
|
cveId: 'CVE-2024-21626',
|
||||||
title: 'runc container escape vulnerability',
|
title: 'runc container escape vulnerability',
|
||||||
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
|
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
cvssScore: 8.6,
|
cvssScore: 8.6,
|
||||||
cvssVector: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H',
|
cvssVector: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H',
|
||||||
status: 'fixed',
|
status: 'fixed',
|
||||||
publishedAt: '2024-01-31T00:00:00Z',
|
publishedAt: '2024-01-31T00:00:00Z',
|
||||||
modifiedAt: '2024-09-13T00:00:00Z',
|
modifiedAt: '2024-09-13T00:00:00Z',
|
||||||
affectedComponents: [
|
affectedComponents: [
|
||||||
{
|
{
|
||||||
purl: 'pkg:golang/github.com/opencontainers/runc@1.1.10',
|
purl: 'pkg:golang/github.com/opencontainers/runc@1.1.10',
|
||||||
name: 'runc',
|
name: 'runc',
|
||||||
version: '1.1.10',
|
version: '1.1.10',
|
||||||
fixedVersion: '1.1.12',
|
fixedVersion: '1.1.12',
|
||||||
assetIds: ['asset-builder-001'],
|
assetIds: ['asset-builder-001'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hasException: false,
|
hasException: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
vulnId: 'vuln-005',
|
vulnId: 'vuln-005',
|
||||||
cveId: 'CVE-2023-38545',
|
cveId: 'CVE-2023-38545',
|
||||||
title: 'curl SOCKS5 heap buffer overflow',
|
title: 'curl SOCKS5 heap buffer overflow',
|
||||||
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
|
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
cvssScore: 9.8,
|
cvssScore: 9.8,
|
||||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
|
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
|
||||||
status: 'open',
|
status: 'open',
|
||||||
publishedAt: '2023-10-11T00:00:00Z',
|
publishedAt: '2023-10-11T00:00:00Z',
|
||||||
modifiedAt: '2024-06-10T00:00:00Z',
|
modifiedAt: '2024-06-10T00:00:00Z',
|
||||||
affectedComponents: [
|
affectedComponents: [
|
||||||
{
|
{
|
||||||
purl: 'pkg:deb/debian/curl@7.88.1-10',
|
purl: 'pkg:deb/debian/curl@7.88.1-10',
|
||||||
name: 'curl',
|
name: 'curl',
|
||||||
version: '7.88.1-10',
|
version: '7.88.1-10',
|
||||||
fixedVersion: '8.4.0',
|
fixedVersion: '8.4.0',
|
||||||
assetIds: ['asset-web-prod', 'asset-api-prod', 'asset-worker-prod'],
|
assetIds: ['asset-web-prod', 'asset-api-prod', 'asset-worker-prod'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hasException: false,
|
hasException: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
vulnId: 'vuln-006',
|
vulnId: 'vuln-006',
|
||||||
cveId: 'CVE-2022-22965',
|
cveId: 'CVE-2022-22965',
|
||||||
title: 'Spring4Shell - Spring Framework RCE',
|
title: 'Spring4Shell - Spring Framework RCE',
|
||||||
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
|
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
cvssScore: 9.8,
|
cvssScore: 9.8,
|
||||||
status: 'wont_fix',
|
status: 'wont_fix',
|
||||||
publishedAt: '2022-03-31T00:00:00Z',
|
publishedAt: '2022-03-31T00:00:00Z',
|
||||||
modifiedAt: '2024-08-20T00:00:00Z',
|
modifiedAt: '2024-08-20T00:00:00Z',
|
||||||
affectedComponents: [
|
affectedComponents: [
|
||||||
{
|
{
|
||||||
purl: 'pkg:maven/org.springframework/spring-beans@5.3.17',
|
purl: 'pkg:maven/org.springframework/spring-beans@5.3.17',
|
||||||
name: 'spring-beans',
|
name: 'spring-beans',
|
||||||
version: '5.3.17',
|
version: '5.3.17',
|
||||||
fixedVersion: '5.3.18',
|
fixedVersion: '5.3.18',
|
||||||
assetIds: ['asset-legacy-001'],
|
assetIds: ['asset-legacy-001'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hasException: true,
|
hasException: true,
|
||||||
exceptionId: 'exc-legacy-spring',
|
exceptionId: 'exc-legacy-spring',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
vulnId: 'vuln-007',
|
vulnId: 'vuln-007',
|
||||||
cveId: 'CVE-2023-45853',
|
cveId: 'CVE-2023-45853',
|
||||||
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
|
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
|
||||||
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
|
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
|
||||||
severity: 'medium',
|
severity: 'medium',
|
||||||
cvssScore: 5.3,
|
cvssScore: 5.3,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
publishedAt: '2023-10-14T00:00:00Z',
|
publishedAt: '2023-10-14T00:00:00Z',
|
||||||
affectedComponents: [
|
affectedComponents: [
|
||||||
{
|
{
|
||||||
purl: 'pkg:deb/debian/zlib@1.2.13',
|
purl: 'pkg:deb/debian/zlib@1.2.13',
|
||||||
name: 'zlib',
|
name: 'zlib',
|
||||||
version: '1.2.13',
|
version: '1.2.13',
|
||||||
fixedVersion: '1.3.1',
|
fixedVersion: '1.3.1',
|
||||||
assetIds: ['asset-web-prod'],
|
assetIds: ['asset-web-prod'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hasException: false,
|
hasException: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
vulnId: 'vuln-008',
|
vulnId: 'vuln-008',
|
||||||
cveId: 'CVE-2024-0567',
|
cveId: 'CVE-2024-0567',
|
||||||
title: 'GnuTLS certificate verification bypass',
|
title: 'GnuTLS certificate verification bypass',
|
||||||
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
|
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
|
||||||
severity: 'medium',
|
severity: 'medium',
|
||||||
cvssScore: 5.9,
|
cvssScore: 5.9,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
publishedAt: '2024-01-16T00:00:00Z',
|
publishedAt: '2024-01-16T00:00:00Z',
|
||||||
affectedComponents: [
|
affectedComponents: [
|
||||||
{
|
{
|
||||||
purl: 'pkg:rpm/fedora/gnutls@3.8.2',
|
purl: 'pkg:rpm/fedora/gnutls@3.8.2',
|
||||||
name: 'gnutls',
|
name: 'gnutls',
|
||||||
version: '3.8.2',
|
version: '3.8.2',
|
||||||
fixedVersion: '3.8.3',
|
fixedVersion: '3.8.3',
|
||||||
assetIds: ['asset-internal-001'],
|
assetIds: ['asset-internal-001'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hasException: false,
|
hasException: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
vulnId: 'vuln-009',
|
vulnId: 'vuln-009',
|
||||||
cveId: 'CVE-2023-5363',
|
cveId: 'CVE-2023-5363',
|
||||||
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
|
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
|
||||||
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
|
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
|
||||||
severity: 'low',
|
severity: 'low',
|
||||||
cvssScore: 3.7,
|
cvssScore: 3.7,
|
||||||
status: 'fixed',
|
status: 'fixed',
|
||||||
publishedAt: '2023-10-24T00:00:00Z',
|
publishedAt: '2023-10-24T00:00:00Z',
|
||||||
affectedComponents: [
|
affectedComponents: [
|
||||||
{
|
{
|
||||||
purl: 'pkg:nuget/System.Security.Cryptography.Pkcs@7.0.2',
|
purl: 'pkg:nuget/System.Security.Cryptography.Pkcs@7.0.2',
|
||||||
name: 'System.Security.Cryptography.Pkcs',
|
name: 'System.Security.Cryptography.Pkcs',
|
||||||
version: '7.0.2',
|
version: '7.0.2',
|
||||||
fixedVersion: '8.0.0',
|
fixedVersion: '8.0.0',
|
||||||
assetIds: ['asset-api-prod'],
|
assetIds: ['asset-api-prod'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hasException: false,
|
hasException: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
vulnId: 'vuln-010',
|
vulnId: 'vuln-010',
|
||||||
cveId: 'CVE-2024-24790',
|
cveId: 'CVE-2024-24790',
|
||||||
title: 'Go net/netip ParseAddr stack exhaustion',
|
title: 'Go net/netip ParseAddr stack exhaustion',
|
||||||
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
|
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
|
||||||
severity: 'low',
|
severity: 'low',
|
||||||
cvssScore: 4.0,
|
cvssScore: 4.0,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
publishedAt: '2024-06-05T00:00:00Z',
|
publishedAt: '2024-06-05T00:00:00Z',
|
||||||
affectedComponents: [
|
affectedComponents: [
|
||||||
{
|
{
|
||||||
purl: 'pkg:golang/stdlib@1.21.10',
|
purl: 'pkg:golang/stdlib@1.21.10',
|
||||||
name: 'go stdlib',
|
name: 'go stdlib',
|
||||||
version: '1.21.10',
|
version: '1.21.10',
|
||||||
fixedVersion: '1.21.11',
|
fixedVersion: '1.21.11',
|
||||||
assetIds: ['asset-api-prod'],
|
assetIds: ['asset-api-prod'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hasException: false,
|
hasException: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -302,19 +302,19 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
|
|||||||
|
|
||||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
|
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
|
||||||
let items = [...MOCK_VULNERABILITIES];
|
let items = [...MOCK_VULNERABILITIES];
|
||||||
|
|
||||||
if (options?.severity && options.severity !== 'all') {
|
if (options?.severity && options.severity !== 'all') {
|
||||||
items = items.filter((v) => v.severity === options.severity);
|
items = items.filter((v) => v.severity === options.severity);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.status && options.status !== 'all') {
|
if (options?.status && options.status !== 'all') {
|
||||||
items = items.filter((v) => v.status === options.status);
|
items = items.filter((v) => v.status === options.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.hasException !== undefined) {
|
if (options?.hasException !== undefined) {
|
||||||
items = items.filter((v) => v.hasException === options.hasException);
|
items = items.filter((v) => v.hasException === options.hasException);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.search) {
|
if (options?.search) {
|
||||||
const search = options.search.toLowerCase();
|
const search = options.search.toLowerCase();
|
||||||
items = items.filter(
|
items = items.filter(
|
||||||
@@ -356,25 +356,25 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
|
|||||||
etag: `"vuln-${vulnId}-etag"`,
|
etag: `"vuln-${vulnId}-etag"`,
|
||||||
}).pipe(delay(100));
|
}).pipe(delay(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
getStats(_options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats> {
|
getStats(_options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats> {
|
||||||
const vulns = MOCK_VULNERABILITIES;
|
const vulns = MOCK_VULNERABILITIES;
|
||||||
const stats: VulnerabilityStats = {
|
const stats: VulnerabilityStats = {
|
||||||
total: vulns.length,
|
total: vulns.length,
|
||||||
bySeverity: {
|
bySeverity: {
|
||||||
critical: vulns.filter((v) => v.severity === 'critical').length,
|
critical: vulns.filter((v) => v.severity === 'critical').length,
|
||||||
high: vulns.filter((v) => v.severity === 'high').length,
|
high: vulns.filter((v) => v.severity === 'high').length,
|
||||||
medium: vulns.filter((v) => v.severity === 'medium').length,
|
medium: vulns.filter((v) => v.severity === 'medium').length,
|
||||||
low: vulns.filter((v) => v.severity === 'low').length,
|
low: vulns.filter((v) => v.severity === 'low').length,
|
||||||
unknown: vulns.filter((v) => v.severity === 'unknown').length,
|
unknown: vulns.filter((v) => v.severity === 'unknown').length,
|
||||||
},
|
},
|
||||||
byStatus: {
|
byStatus: {
|
||||||
open: vulns.filter((v) => v.status === 'open').length,
|
open: vulns.filter((v) => v.status === 'open').length,
|
||||||
fixed: vulns.filter((v) => v.status === 'fixed').length,
|
fixed: vulns.filter((v) => v.status === 'fixed').length,
|
||||||
wont_fix: vulns.filter((v) => v.status === 'wont_fix').length,
|
wont_fix: vulns.filter((v) => v.status === 'wont_fix').length,
|
||||||
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
|
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
|
||||||
excepted: vulns.filter((v) => v.status === 'excepted').length,
|
excepted: vulns.filter((v) => v.status === 'excepted').length,
|
||||||
},
|
},
|
||||||
withExceptions: vulns.filter((v) => v.hasException).length,
|
withExceptions: vulns.filter((v) => v.hasException).length,
|
||||||
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
|
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
|
||||||
computedAt: MockVulnerabilityApiService.FixedNowIso,
|
computedAt: MockVulnerabilityApiService.FixedNowIso,
|
||||||
@@ -409,24 +409,24 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
|
|||||||
fileSize: 1024 * (request.includeComponents ? 50 : 20),
|
fileSize: 1024 * (request.includeComponents ? 50 : 20),
|
||||||
traceId,
|
traceId,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.mockExports.set(exportId, exportResponse);
|
this.mockExports.set(exportId, exportResponse);
|
||||||
return of(exportResponse).pipe(delay(500));
|
return of(exportResponse).pipe(delay(500));
|
||||||
}
|
}
|
||||||
|
|
||||||
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse> {
|
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse> {
|
||||||
const traceId = options?.traceId ?? 'mock-trace-vuln-export-status';
|
const traceId = options?.traceId ?? 'mock-trace-vuln-export-status';
|
||||||
const existing = this.mockExports.get(exportId);
|
const existing = this.mockExports.get(exportId);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return of(existing).pipe(delay(100));
|
return of(existing).pipe(delay(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
return of({
|
return of({
|
||||||
exportId,
|
exportId,
|
||||||
status: 'failed' as const,
|
status: 'failed' as const,
|
||||||
traceId,
|
traceId,
|
||||||
error: { code: 'ERR_EXPORT_NOT_FOUND', message: 'Export not found' },
|
error: { code: 'ERR_EXPORT_NOT_FOUND', message: 'Export not found' },
|
||||||
}).pipe(delay(100));
|
}).pipe(delay(100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,208 +1,208 @@
|
|||||||
export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
|
export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
|
||||||
export type VulnerabilityStatus = 'open' | 'fixed' | 'wont_fix' | 'in_progress' | 'excepted';
|
export type VulnerabilityStatus = 'open' | 'fixed' | 'wont_fix' | 'in_progress' | 'excepted';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workflow action types for vulnerability lifecycle.
|
* Workflow action types for vulnerability lifecycle.
|
||||||
*/
|
*/
|
||||||
export type VulnWorkflowAction = 'open' | 'ack' | 'close' | 'reopen' | 'export';
|
export type VulnWorkflowAction = 'open' | 'ack' | 'close' | 'reopen' | 'export';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Actor types for workflow actions.
|
* Actor types for workflow actions.
|
||||||
*/
|
*/
|
||||||
export type VulnActorType = 'user' | 'service' | 'automation';
|
export type VulnActorType = 'user' | 'service' | 'automation';
|
||||||
|
|
||||||
export interface Vulnerability {
|
export interface Vulnerability {
|
||||||
readonly vulnId: string;
|
readonly vulnId: string;
|
||||||
readonly cveId: string;
|
readonly cveId: string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly description?: string;
|
readonly description?: string;
|
||||||
readonly severity: VulnerabilitySeverity;
|
readonly severity: VulnerabilitySeverity;
|
||||||
readonly cvssScore?: number;
|
readonly cvssScore?: number;
|
||||||
readonly cvssVector?: string;
|
readonly cvssVector?: string;
|
||||||
readonly status: VulnerabilityStatus;
|
readonly status: VulnerabilityStatus;
|
||||||
readonly publishedAt?: string;
|
readonly publishedAt?: string;
|
||||||
readonly modifiedAt?: string;
|
readonly modifiedAt?: string;
|
||||||
readonly affectedComponents: readonly AffectedComponent[];
|
readonly affectedComponents: readonly AffectedComponent[];
|
||||||
readonly references?: readonly string[];
|
readonly references?: readonly string[];
|
||||||
readonly hasException?: boolean;
|
readonly hasException?: boolean;
|
||||||
readonly exceptionId?: string;
|
readonly exceptionId?: string;
|
||||||
/** ETag for optimistic concurrency. */
|
/** ETag for optimistic concurrency. */
|
||||||
readonly etag?: string;
|
readonly etag?: string;
|
||||||
/** Reachability score from signals integration. */
|
/** Reachability score from signals integration. */
|
||||||
readonly reachabilityScore?: number;
|
readonly reachabilityScore?: number;
|
||||||
/** Reachability status from signals. */
|
/** Reachability status from signals. */
|
||||||
readonly reachabilityStatus?: 'reachable' | 'unreachable' | 'unknown';
|
readonly reachabilityStatus?: 'reachable' | 'unreachable' | 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AffectedComponent {
|
export interface AffectedComponent {
|
||||||
readonly purl: string;
|
readonly purl: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly version: string;
|
readonly version: string;
|
||||||
readonly fixedVersion?: string;
|
readonly fixedVersion?: string;
|
||||||
readonly assetIds: readonly string[];
|
readonly assetIds: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VulnerabilityStats {
|
export interface VulnerabilityStats {
|
||||||
readonly total: number;
|
readonly total: number;
|
||||||
readonly bySeverity: Record<VulnerabilitySeverity, number>;
|
readonly bySeverity: Record<VulnerabilitySeverity, number>;
|
||||||
readonly byStatus: Record<VulnerabilityStatus, number>;
|
readonly byStatus: Record<VulnerabilityStatus, number>;
|
||||||
readonly withExceptions: number;
|
readonly withExceptions: number;
|
||||||
readonly criticalOpen: number;
|
readonly criticalOpen: number;
|
||||||
/** Last computation timestamp. */
|
/** Last computation timestamp. */
|
||||||
readonly computedAt?: string;
|
readonly computedAt?: string;
|
||||||
/** Trace ID for the stats computation. */
|
/** Trace ID for the stats computation. */
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VulnerabilitiesQueryOptions {
|
export interface VulnerabilitiesQueryOptions {
|
||||||
readonly severity?: VulnerabilitySeverity | 'all';
|
readonly severity?: VulnerabilitySeverity | 'all';
|
||||||
readonly status?: VulnerabilityStatus | 'all';
|
readonly status?: VulnerabilityStatus | 'all';
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
readonly hasException?: boolean;
|
readonly hasException?: boolean;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly pageSize?: number;
|
readonly pageSize?: number;
|
||||||
readonly tenantId?: string;
|
readonly tenantId?: string;
|
||||||
readonly projectId?: string;
|
readonly projectId?: string;
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
/** Filter by reachability status. */
|
/** Filter by reachability status. */
|
||||||
readonly reachability?: 'reachable' | 'unreachable' | 'unknown' | 'all';
|
readonly reachability?: 'reachable' | 'unreachable' | 'unknown' | 'all';
|
||||||
/** Include reachability data in response. */
|
/** Include reachability data in response. */
|
||||||
readonly includeReachability?: boolean;
|
readonly includeReachability?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VulnerabilitiesResponse {
|
export interface VulnerabilitiesResponse {
|
||||||
readonly items: readonly Vulnerability[];
|
readonly items: readonly Vulnerability[];
|
||||||
readonly total: number;
|
readonly total: number;
|
||||||
readonly hasMore?: boolean;
|
readonly hasMore?: boolean;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly pageSize?: number;
|
readonly pageSize?: number;
|
||||||
/** ETag for the response. */
|
/** ETag for the response. */
|
||||||
readonly etag?: string;
|
readonly etag?: string;
|
||||||
/** Trace ID for the request. */
|
/** Trace ID for the request. */
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workflow action request for Findings Ledger integration.
|
* Workflow action request for Findings Ledger integration.
|
||||||
* Implements WEB-VULN-29-002 contract.
|
* Implements WEB-VULN-29-002 contract.
|
||||||
*/
|
*/
|
||||||
export interface VulnWorkflowRequest {
|
export interface VulnWorkflowRequest {
|
||||||
/** Workflow action type. */
|
/** Workflow action type. */
|
||||||
readonly action: VulnWorkflowAction;
|
readonly action: VulnWorkflowAction;
|
||||||
/** Finding/vulnerability ID. */
|
/** Finding/vulnerability ID. */
|
||||||
readonly findingId: string;
|
readonly findingId: string;
|
||||||
/** Reason code for the action. */
|
/** Reason code for the action. */
|
||||||
readonly reasonCode?: string;
|
readonly reasonCode?: string;
|
||||||
/** Optional comment. */
|
/** Optional comment. */
|
||||||
readonly comment?: string;
|
readonly comment?: string;
|
||||||
/** Attachments for the action. */
|
/** Attachments for the action. */
|
||||||
readonly attachments?: readonly VulnWorkflowAttachment[];
|
readonly attachments?: readonly VulnWorkflowAttachment[];
|
||||||
/** Actor performing the action. */
|
/** Actor performing the action. */
|
||||||
readonly actor: VulnWorkflowActor;
|
readonly actor: VulnWorkflowActor;
|
||||||
/** Additional metadata. */
|
/** Additional metadata. */
|
||||||
readonly metadata?: Record<string, unknown>;
|
readonly metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attachment for workflow actions.
|
* Attachment for workflow actions.
|
||||||
*/
|
*/
|
||||||
export interface VulnWorkflowAttachment {
|
export interface VulnWorkflowAttachment {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly digest: string;
|
readonly digest: string;
|
||||||
readonly contentType?: string;
|
readonly contentType?: string;
|
||||||
readonly size?: number;
|
readonly size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Actor for workflow actions.
|
* Actor for workflow actions.
|
||||||
*/
|
*/
|
||||||
export interface VulnWorkflowActor {
|
export interface VulnWorkflowActor {
|
||||||
readonly subject: string;
|
readonly subject: string;
|
||||||
readonly type: VulnActorType;
|
readonly type: VulnActorType;
|
||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly email?: string;
|
readonly email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workflow action response from Findings Ledger.
|
* Workflow action response from Findings Ledger.
|
||||||
*/
|
*/
|
||||||
export interface VulnWorkflowResponse {
|
export interface VulnWorkflowResponse {
|
||||||
/** Action status. */
|
/** Action status. */
|
||||||
readonly status: 'accepted' | 'rejected' | 'pending';
|
readonly status: 'accepted' | 'rejected' | 'pending';
|
||||||
/** Ledger event ID for correlation. */
|
/** Ledger event ID for correlation. */
|
||||||
readonly ledgerEventId: string;
|
readonly ledgerEventId: string;
|
||||||
/** ETag for optimistic concurrency. */
|
/** ETag for optimistic concurrency. */
|
||||||
readonly etag: string;
|
readonly etag: string;
|
||||||
/** Trace ID for the request. */
|
/** Trace ID for the request. */
|
||||||
readonly traceId: string;
|
readonly traceId: string;
|
||||||
/** Correlation ID. */
|
/** Correlation ID. */
|
||||||
readonly correlationId: string;
|
readonly correlationId: string;
|
||||||
/** Error details if rejected. */
|
/** Error details if rejected. */
|
||||||
readonly error?: VulnWorkflowError;
|
readonly error?: VulnWorkflowError;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workflow error response.
|
* Workflow error response.
|
||||||
*/
|
*/
|
||||||
export interface VulnWorkflowError {
|
export interface VulnWorkflowError {
|
||||||
readonly code: string;
|
readonly code: string;
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
readonly details?: Record<string, unknown>;
|
readonly details?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export request for vulnerability data.
|
* Export request for vulnerability data.
|
||||||
*/
|
*/
|
||||||
export interface VulnExportRequest {
|
export interface VulnExportRequest {
|
||||||
/** Format for export. */
|
/** Format for export. */
|
||||||
readonly format: 'csv' | 'json' | 'cyclonedx' | 'spdx';
|
readonly format: 'csv' | 'json' | 'cyclonedx' | 'spdx';
|
||||||
/** Filter options. */
|
/** Filter options. */
|
||||||
readonly filter?: VulnerabilitiesQueryOptions;
|
readonly filter?: VulnerabilitiesQueryOptions;
|
||||||
/** Include affected components. */
|
/** Include affected components. */
|
||||||
readonly includeComponents?: boolean;
|
readonly includeComponents?: boolean;
|
||||||
/** Include reachability data. */
|
/** Include reachability data. */
|
||||||
readonly includeReachability?: boolean;
|
readonly includeReachability?: boolean;
|
||||||
/** Maximum records (for large exports). */
|
/** Maximum records (for large exports). */
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export response with signed download URL.
|
* Export response with signed download URL.
|
||||||
*/
|
*/
|
||||||
export interface VulnExportResponse {
|
export interface VulnExportResponse {
|
||||||
/** Export job ID. */
|
/** Export job ID. */
|
||||||
readonly exportId: string;
|
readonly exportId: string;
|
||||||
/** Current status. */
|
/** Current status. */
|
||||||
readonly status: 'pending' | 'processing' | 'completed' | 'failed';
|
readonly status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
/** Signed download URL (when completed). */
|
/** Signed download URL (when completed). */
|
||||||
readonly downloadUrl?: string;
|
readonly downloadUrl?: string;
|
||||||
/** URL expiration timestamp. */
|
/** URL expiration timestamp. */
|
||||||
readonly expiresAt?: string;
|
readonly expiresAt?: string;
|
||||||
/** Record count. */
|
/** Record count. */
|
||||||
readonly recordCount?: number;
|
readonly recordCount?: number;
|
||||||
/** File size in bytes. */
|
/** File size in bytes. */
|
||||||
readonly fileSize?: number;
|
readonly fileSize?: number;
|
||||||
/** Trace ID. */
|
/** Trace ID. */
|
||||||
readonly traceId: string;
|
readonly traceId: string;
|
||||||
/** Error if failed. */
|
/** Error if failed. */
|
||||||
readonly error?: VulnWorkflowError;
|
readonly error?: VulnWorkflowError;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request logging metadata for observability.
|
* Request logging metadata for observability.
|
||||||
*/
|
*/
|
||||||
export interface VulnRequestLog {
|
export interface VulnRequestLog {
|
||||||
readonly requestId: string;
|
readonly requestId: string;
|
||||||
readonly traceId: string;
|
readonly traceId: string;
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly projectId?: string;
|
readonly projectId?: string;
|
||||||
readonly operation: string;
|
readonly operation: string;
|
||||||
readonly path: string;
|
readonly path: string;
|
||||||
readonly method: string;
|
readonly method: string;
|
||||||
readonly timestamp: string;
|
readonly timestamp: string;
|
||||||
readonly durationMs?: number;
|
readonly durationMs?: number;
|
||||||
readonly statusCode?: number;
|
readonly statusCode?: number;
|
||||||
readonly error?: string;
|
readonly error?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const STEP_TYPES: StepTypeDefinition[] = [
|
|||||||
label: 'Script',
|
label: 'Script',
|
||||||
description: 'Execute a custom script or command',
|
description: 'Execute a custom script or command',
|
||||||
icon: 'code',
|
icon: 'code',
|
||||||
color: '#6366f1',
|
color: '#D4920A',
|
||||||
defaultConfig: { command: '', timeout: 300 },
|
defaultConfig: { command: '', timeout: 300 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,171 +1,171 @@
|
|||||||
import {
|
import {
|
||||||
HttpErrorResponse,
|
HttpErrorResponse,
|
||||||
HttpEvent,
|
HttpEvent,
|
||||||
HttpHandler,
|
HttpHandler,
|
||||||
HttpInterceptor,
|
HttpInterceptor,
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
} from '@angular/common/http';
|
} from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Observable, firstValueFrom, from, throwError } from 'rxjs';
|
import { Observable, firstValueFrom, from, throwError } from 'rxjs';
|
||||||
import { catchError, switchMap } from 'rxjs/operators';
|
import { catchError, switchMap } from 'rxjs/operators';
|
||||||
|
|
||||||
import { AppConfigService } from '../config/app-config.service';
|
import { AppConfigService } from '../config/app-config.service';
|
||||||
import { DpopService } from './dpop/dpop.service';
|
import { DpopService } from './dpop/dpop.service';
|
||||||
import { AuthorityAuthService } from './authority-auth.service';
|
import { AuthorityAuthService } from './authority-auth.service';
|
||||||
|
|
||||||
const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
|
const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthHttpInterceptor implements HttpInterceptor {
|
export class AuthHttpInterceptor implements HttpInterceptor {
|
||||||
private excludedOrigins: Set<string> | null = null;
|
private excludedOrigins: Set<string> | null = null;
|
||||||
private tokenEndpoint: string | null = null;
|
private tokenEndpoint: string | null = null;
|
||||||
private authorityResolved = false;
|
private authorityResolved = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly auth: AuthorityAuthService,
|
private readonly auth: AuthorityAuthService,
|
||||||
private readonly config: AppConfigService,
|
private readonly config: AppConfigService,
|
||||||
private readonly dpop: DpopService
|
private readonly dpop: DpopService
|
||||||
) {
|
) {
|
||||||
// lazy resolve authority configuration in intercept to allow APP_INITIALIZER to run first
|
// lazy resolve authority configuration in intercept to allow APP_INITIALIZER to run first
|
||||||
}
|
}
|
||||||
|
|
||||||
intercept(
|
intercept(
|
||||||
request: HttpRequest<unknown>,
|
request: HttpRequest<unknown>,
|
||||||
next: HttpHandler
|
next: HttpHandler
|
||||||
): Observable<HttpEvent<unknown>> {
|
): Observable<HttpEvent<unknown>> {
|
||||||
this.ensureAuthorityInfo();
|
this.ensureAuthorityInfo();
|
||||||
|
|
||||||
if (request.headers.has('Authorization') || this.shouldSkip(request.url)) {
|
if (request.headers.has('Authorization') || this.shouldSkip(request.url)) {
|
||||||
return next.handle(request);
|
return next.handle(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
return from(
|
return from(
|
||||||
this.auth.getAuthHeadersForRequest(
|
this.auth.getAuthHeadersForRequest(
|
||||||
this.resolveAbsoluteUrl(request.url),
|
this.resolveAbsoluteUrl(request.url),
|
||||||
request.method
|
request.method
|
||||||
)
|
)
|
||||||
).pipe(
|
).pipe(
|
||||||
switchMap((headers) => {
|
switchMap((headers) => {
|
||||||
if (!headers) {
|
if (!headers) {
|
||||||
return next.handle(request);
|
return next.handle(request);
|
||||||
}
|
}
|
||||||
const authorizedRequest = request.clone({
|
const authorizedRequest = request.clone({
|
||||||
setHeaders: {
|
setHeaders: {
|
||||||
Authorization: headers.authorization,
|
Authorization: headers.authorization,
|
||||||
DPoP: headers.dpop,
|
DPoP: headers.dpop,
|
||||||
},
|
},
|
||||||
headers: request.headers.set(RETRY_HEADER, '0'),
|
headers: request.headers.set(RETRY_HEADER, '0'),
|
||||||
});
|
});
|
||||||
return next.handle(authorizedRequest);
|
return next.handle(authorizedRequest);
|
||||||
}),
|
}),
|
||||||
catchError((error: HttpErrorResponse) =>
|
catchError((error: HttpErrorResponse) =>
|
||||||
this.handleError(request, error, next)
|
this.handleError(request, error, next)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleError(
|
private handleError(
|
||||||
request: HttpRequest<unknown>,
|
request: HttpRequest<unknown>,
|
||||||
error: HttpErrorResponse,
|
error: HttpErrorResponse,
|
||||||
next: HttpHandler
|
next: HttpHandler
|
||||||
): Observable<HttpEvent<unknown>> {
|
): Observable<HttpEvent<unknown>> {
|
||||||
if (error.status !== 401) {
|
if (error.status !== 401) {
|
||||||
return throwError(() => error);
|
return throwError(() => error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nonce = error.headers?.get('DPoP-Nonce');
|
const nonce = error.headers?.get('DPoP-Nonce');
|
||||||
if (!nonce) {
|
if (!nonce) {
|
||||||
return throwError(() => error);
|
return throwError(() => error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.headers.get(RETRY_HEADER) === '1') {
|
if (request.headers.get(RETRY_HEADER) === '1') {
|
||||||
return throwError(() => error);
|
return throwError(() => error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return from(this.retryWithNonce(request, nonce, next)).pipe(
|
return from(this.retryWithNonce(request, nonce, next)).pipe(
|
||||||
catchError(() => throwError(() => error))
|
catchError(() => throwError(() => error))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async retryWithNonce(
|
private async retryWithNonce(
|
||||||
request: HttpRequest<unknown>,
|
request: HttpRequest<unknown>,
|
||||||
nonce: string,
|
nonce: string,
|
||||||
next: HttpHandler
|
next: HttpHandler
|
||||||
): Promise<HttpEvent<unknown>> {
|
): Promise<HttpEvent<unknown>> {
|
||||||
await this.dpop.setNonce(nonce);
|
await this.dpop.setNonce(nonce);
|
||||||
const headers = await this.auth.getAuthHeadersForRequest(
|
const headers = await this.auth.getAuthHeadersForRequest(
|
||||||
this.resolveAbsoluteUrl(request.url),
|
this.resolveAbsoluteUrl(request.url),
|
||||||
request.method
|
request.method
|
||||||
);
|
);
|
||||||
if (!headers) {
|
if (!headers) {
|
||||||
throw new Error('Unable to refresh authorization headers after nonce.');
|
throw new Error('Unable to refresh authorization headers after nonce.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const retried = request.clone({
|
const retried = request.clone({
|
||||||
setHeaders: {
|
setHeaders: {
|
||||||
Authorization: headers.authorization,
|
Authorization: headers.authorization,
|
||||||
DPoP: headers.dpop,
|
DPoP: headers.dpop,
|
||||||
},
|
},
|
||||||
headers: request.headers.set(RETRY_HEADER, '1'),
|
headers: request.headers.set(RETRY_HEADER, '1'),
|
||||||
});
|
});
|
||||||
|
|
||||||
return firstValueFrom(next.handle(retried));
|
return firstValueFrom(next.handle(retried));
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldSkip(url: string): boolean {
|
private shouldSkip(url: string): boolean {
|
||||||
this.ensureAuthorityInfo();
|
this.ensureAuthorityInfo();
|
||||||
const absolute = this.resolveAbsoluteUrl(url);
|
const absolute = this.resolveAbsoluteUrl(url);
|
||||||
if (!absolute) {
|
if (!absolute) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resolved = new URL(absolute);
|
const resolved = new URL(absolute);
|
||||||
if (resolved.pathname.endsWith('/config.json')) {
|
if (resolved.pathname.endsWith('/config.json')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (this.tokenEndpoint && absolute.startsWith(this.tokenEndpoint)) {
|
if (this.tokenEndpoint && absolute.startsWith(this.tokenEndpoint)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const origin = resolved.origin;
|
const origin = resolved.origin;
|
||||||
return this.excludedOrigins?.has(origin) ?? false;
|
return this.excludedOrigins?.has(origin) ?? false;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveAbsoluteUrl(url: string): string {
|
private resolveAbsoluteUrl(url: string): string {
|
||||||
try {
|
try {
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
const base =
|
const base =
|
||||||
typeof window !== 'undefined' && window.location
|
typeof window !== 'undefined' && window.location
|
||||||
? window.location.origin
|
? window.location.origin
|
||||||
: undefined;
|
: undefined;
|
||||||
return base ? new URL(url, base).toString() : url;
|
return base ? new URL(url, base).toString() : url;
|
||||||
} catch {
|
} catch {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureAuthorityInfo(): void {
|
private ensureAuthorityInfo(): void {
|
||||||
if (this.authorityResolved) {
|
if (this.authorityResolved) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const authority = this.config.authority;
|
const authority = this.config.authority;
|
||||||
this.tokenEndpoint = new URL(
|
this.tokenEndpoint = new URL(
|
||||||
authority.tokenEndpoint,
|
authority.tokenEndpoint,
|
||||||
authority.issuer
|
authority.issuer
|
||||||
).toString();
|
).toString();
|
||||||
this.excludedOrigins = new Set<string>([
|
this.excludedOrigins = new Set<string>([
|
||||||
this.tokenEndpoint,
|
this.tokenEndpoint,
|
||||||
new URL(authority.authorizeEndpoint, authority.issuer).origin,
|
new URL(authority.authorizeEndpoint, authority.issuer).origin,
|
||||||
]);
|
]);
|
||||||
this.authorityResolved = true;
|
this.authorityResolved = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Configuration not yet loaded; interceptor will retry on the next request.
|
// Configuration not yet loaded; interceptor will retry on the next request.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,56 @@
|
|||||||
export interface AuthTokens {
|
export interface AuthTokens {
|
||||||
readonly accessToken: string;
|
readonly accessToken: string;
|
||||||
readonly expiresAtEpochMs: number;
|
readonly expiresAtEpochMs: number;
|
||||||
readonly refreshToken?: string;
|
readonly refreshToken?: string;
|
||||||
readonly tokenType: 'Bearer';
|
readonly tokenType: 'Bearer';
|
||||||
readonly scope: string;
|
readonly scope: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthIdentity {
|
export interface AuthIdentity {
|
||||||
readonly subject: string;
|
readonly subject: string;
|
||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly email?: string;
|
readonly email?: string;
|
||||||
readonly roles: readonly string[];
|
readonly roles: readonly string[];
|
||||||
readonly idToken?: string;
|
readonly idToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthSession {
|
export interface AuthSession {
|
||||||
readonly tokens: AuthTokens;
|
readonly tokens: AuthTokens;
|
||||||
readonly identity: AuthIdentity;
|
readonly identity: AuthIdentity;
|
||||||
/**
|
/**
|
||||||
* SHA-256 JWK thumbprint of the active DPoP key pair.
|
* SHA-256 JWK thumbprint of the active DPoP key pair.
|
||||||
*/
|
*/
|
||||||
readonly dpopKeyThumbprint: string;
|
readonly dpopKeyThumbprint: string;
|
||||||
readonly issuedAtEpochMs: number;
|
readonly issuedAtEpochMs: number;
|
||||||
readonly tenantId: string | null;
|
readonly tenantId: string | null;
|
||||||
readonly scopes: readonly string[];
|
readonly scopes: readonly string[];
|
||||||
readonly audiences: readonly string[];
|
readonly audiences: readonly string[];
|
||||||
readonly authenticationTimeEpochMs: number | null;
|
readonly authenticationTimeEpochMs: number | null;
|
||||||
readonly freshAuthActive: boolean;
|
readonly freshAuthActive: boolean;
|
||||||
readonly freshAuthExpiresAtEpochMs: number | null;
|
readonly freshAuthExpiresAtEpochMs: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersistedSessionMetadata {
|
export interface PersistedSessionMetadata {
|
||||||
readonly subject: string;
|
readonly subject: string;
|
||||||
readonly expiresAtEpochMs: number;
|
readonly expiresAtEpochMs: number;
|
||||||
readonly issuedAtEpochMs: number;
|
readonly issuedAtEpochMs: number;
|
||||||
readonly dpopKeyThumbprint: string;
|
readonly dpopKeyThumbprint: string;
|
||||||
readonly tenantId?: string | null;
|
readonly tenantId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthStatus =
|
export type AuthStatus =
|
||||||
| 'unauthenticated'
|
| 'unauthenticated'
|
||||||
| 'authenticated'
|
| 'authenticated'
|
||||||
| 'refreshing'
|
| 'refreshing'
|
||||||
| 'loading';
|
| 'loading';
|
||||||
|
|
||||||
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
|
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
|
||||||
|
|
||||||
export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
|
export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
|
||||||
|
|
||||||
export type AuthErrorReason =
|
export type AuthErrorReason =
|
||||||
| 'invalid_state'
|
| 'invalid_state'
|
||||||
| 'token_exchange_failed'
|
| 'token_exchange_failed'
|
||||||
| 'refresh_failed'
|
| 'refresh_failed'
|
||||||
| 'dpop_generation_failed'
|
| 'dpop_generation_failed'
|
||||||
| 'configuration_missing';
|
| 'configuration_missing';
|
||||||
|
|||||||
@@ -1,55 +1,55 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
|
import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
|
||||||
import { AuthSessionStore } from './auth-session.store';
|
import { AuthSessionStore } from './auth-session.store';
|
||||||
|
|
||||||
describe('AuthSessionStore', () => {
|
describe('AuthSessionStore', () => {
|
||||||
let store: AuthSessionStore;
|
let store: AuthSessionStore;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [AuthSessionStore],
|
providers: [AuthSessionStore],
|
||||||
});
|
});
|
||||||
store = TestBed.inject(AuthSessionStore);
|
store = TestBed.inject(AuthSessionStore);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists minimal metadata when session is set', () => {
|
it('persists minimal metadata when session is set', () => {
|
||||||
const tokens: AuthTokens = {
|
const tokens: AuthTokens = {
|
||||||
accessToken: 'token-abc',
|
accessToken: 'token-abc',
|
||||||
expiresAtEpochMs: Date.now() + 120_000,
|
expiresAtEpochMs: Date.now() + 120_000,
|
||||||
refreshToken: 'refresh-xyz',
|
refreshToken: 'refresh-xyz',
|
||||||
scope: 'openid ui.read',
|
scope: 'openid ui.read',
|
||||||
tokenType: 'Bearer',
|
tokenType: 'Bearer',
|
||||||
};
|
};
|
||||||
|
|
||||||
const session: AuthSession = {
|
const session: AuthSession = {
|
||||||
tokens,
|
tokens,
|
||||||
identity: {
|
identity: {
|
||||||
subject: 'user-123',
|
subject: 'user-123',
|
||||||
name: 'Alex Operator',
|
name: 'Alex Operator',
|
||||||
roles: ['ui.read'],
|
roles: ['ui.read'],
|
||||||
},
|
},
|
||||||
dpopKeyThumbprint: 'thumbprint-1',
|
dpopKeyThumbprint: 'thumbprint-1',
|
||||||
issuedAtEpochMs: Date.now(),
|
issuedAtEpochMs: Date.now(),
|
||||||
tenantId: 'tenant-default',
|
tenantId: 'tenant-default',
|
||||||
scopes: ['ui.read'],
|
scopes: ['ui.read'],
|
||||||
audiences: ['console'],
|
audiences: ['console'],
|
||||||
authenticationTimeEpochMs: Date.now(),
|
authenticationTimeEpochMs: Date.now(),
|
||||||
freshAuthActive: true,
|
freshAuthActive: true,
|
||||||
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
|
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
store.setSession(session);
|
store.setSession(session);
|
||||||
|
|
||||||
const persisted = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
const persisted = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
expect(persisted).toBeTruthy();
|
expect(persisted).toBeTruthy();
|
||||||
const parsed = JSON.parse(persisted ?? '{}');
|
const parsed = JSON.parse(persisted ?? '{}');
|
||||||
expect(parsed.subject).toBe('user-123');
|
expect(parsed.subject).toBe('user-123');
|
||||||
expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
|
expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
|
||||||
expect(parsed.tenantId).toBe('tenant-default');
|
expect(parsed.tenantId).toBe('tenant-default');
|
||||||
|
|
||||||
store.clear();
|
store.clear();
|
||||||
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,129 +1,129 @@
|
|||||||
import { Injectable, computed, signal } from '@angular/core';
|
import { Injectable, computed, signal } from '@angular/core';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AuthSession,
|
AuthSession,
|
||||||
AuthStatus,
|
AuthStatus,
|
||||||
PersistedSessionMetadata,
|
PersistedSessionMetadata,
|
||||||
SESSION_STORAGE_KEY,
|
SESSION_STORAGE_KEY,
|
||||||
} from './auth-session.model';
|
} from './auth-session.model';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class AuthSessionStore {
|
export class AuthSessionStore {
|
||||||
private readonly sessionSignal = signal<AuthSession | null>(null);
|
private readonly sessionSignal = signal<AuthSession | null>(null);
|
||||||
private readonly statusSignal = signal<AuthStatus>('unauthenticated');
|
private readonly statusSignal = signal<AuthStatus>('unauthenticated');
|
||||||
private readonly persistedSignal =
|
private readonly persistedSignal =
|
||||||
signal<PersistedSessionMetadata | null>(this.readPersistedMetadata());
|
signal<PersistedSessionMetadata | null>(this.readPersistedMetadata());
|
||||||
|
|
||||||
readonly session = computed(() => this.sessionSignal());
|
readonly session = computed(() => this.sessionSignal());
|
||||||
readonly status = computed(() => this.statusSignal());
|
readonly status = computed(() => this.statusSignal());
|
||||||
|
|
||||||
readonly identity = computed(() => this.sessionSignal()?.identity ?? null);
|
readonly identity = computed(() => this.sessionSignal()?.identity ?? null);
|
||||||
readonly subjectHint = computed(
|
readonly subjectHint = computed(
|
||||||
() =>
|
() =>
|
||||||
this.sessionSignal()?.identity.subject ??
|
this.sessionSignal()?.identity.subject ??
|
||||||
this.persistedSignal()?.subject ??
|
this.persistedSignal()?.subject ??
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly expiresAtEpochMs = computed(
|
readonly expiresAtEpochMs = computed(
|
||||||
() => this.sessionSignal()?.tokens.expiresAtEpochMs ?? null
|
() => this.sessionSignal()?.tokens.expiresAtEpochMs ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly isAuthenticated = computed(
|
readonly isAuthenticated = computed(
|
||||||
() => this.sessionSignal() !== null && this.statusSignal() !== 'loading'
|
() => this.sessionSignal() !== null && this.statusSignal() !== 'loading'
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly tenantId = computed(
|
readonly tenantId = computed(
|
||||||
() =>
|
() =>
|
||||||
this.sessionSignal()?.tenantId ??
|
this.sessionSignal()?.tenantId ??
|
||||||
this.persistedSignal()?.tenantId ??
|
this.persistedSignal()?.tenantId ??
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
setStatus(status: AuthStatus): void {
|
setStatus(status: AuthStatus): void {
|
||||||
this.statusSignal.set(status);
|
this.statusSignal.set(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSession(session: AuthSession | null): void {
|
setSession(session: AuthSession | null): void {
|
||||||
this.sessionSignal.set(session);
|
this.sessionSignal.set(session);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
this.statusSignal.set('unauthenticated');
|
this.statusSignal.set('unauthenticated');
|
||||||
this.persistedSignal.set(null);
|
this.persistedSignal.set(null);
|
||||||
this.clearPersistedMetadata();
|
this.clearPersistedMetadata();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.statusSignal.set('authenticated');
|
this.statusSignal.set('authenticated');
|
||||||
const metadata: PersistedSessionMetadata = {
|
const metadata: PersistedSessionMetadata = {
|
||||||
subject: session.identity.subject,
|
subject: session.identity.subject,
|
||||||
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
|
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
|
||||||
issuedAtEpochMs: session.issuedAtEpochMs,
|
issuedAtEpochMs: session.issuedAtEpochMs,
|
||||||
dpopKeyThumbprint: session.dpopKeyThumbprint,
|
dpopKeyThumbprint: session.dpopKeyThumbprint,
|
||||||
tenantId: session.tenantId,
|
tenantId: session.tenantId,
|
||||||
};
|
};
|
||||||
this.persistedSignal.set(metadata);
|
this.persistedSignal.set(metadata);
|
||||||
this.persistMetadata(metadata);
|
this.persistMetadata(metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.sessionSignal.set(null);
|
this.sessionSignal.set(null);
|
||||||
this.statusSignal.set('unauthenticated');
|
this.statusSignal.set('unauthenticated');
|
||||||
this.persistedSignal.set(null);
|
this.persistedSignal.set(null);
|
||||||
this.clearPersistedMetadata();
|
this.clearPersistedMetadata();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readPersistedMetadata(): PersistedSessionMetadata | null {
|
private readPersistedMetadata(): PersistedSessionMetadata | null {
|
||||||
if (typeof sessionStorage === 'undefined') {
|
if (typeof sessionStorage === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(raw) as PersistedSessionMetadata;
|
const parsed = JSON.parse(raw) as PersistedSessionMetadata;
|
||||||
if (
|
if (
|
||||||
typeof parsed.subject !== 'string' ||
|
typeof parsed.subject !== 'string' ||
|
||||||
typeof parsed.expiresAtEpochMs !== 'number' ||
|
typeof parsed.expiresAtEpochMs !== 'number' ||
|
||||||
typeof parsed.issuedAtEpochMs !== 'number' ||
|
typeof parsed.issuedAtEpochMs !== 'number' ||
|
||||||
typeof parsed.dpopKeyThumbprint !== 'string'
|
typeof parsed.dpopKeyThumbprint !== 'string'
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const tenantId =
|
const tenantId =
|
||||||
typeof parsed.tenantId === 'string'
|
typeof parsed.tenantId === 'string'
|
||||||
? parsed.tenantId.trim() || null
|
? parsed.tenantId.trim() || null
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
subject: parsed.subject,
|
subject: parsed.subject,
|
||||||
expiresAtEpochMs: parsed.expiresAtEpochMs,
|
expiresAtEpochMs: parsed.expiresAtEpochMs,
|
||||||
issuedAtEpochMs: parsed.issuedAtEpochMs,
|
issuedAtEpochMs: parsed.issuedAtEpochMs,
|
||||||
dpopKeyThumbprint: parsed.dpopKeyThumbprint,
|
dpopKeyThumbprint: parsed.dpopKeyThumbprint,
|
||||||
tenantId,
|
tenantId,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private persistMetadata(metadata: PersistedSessionMetadata): void {
|
private persistMetadata(metadata: PersistedSessionMetadata): void {
|
||||||
if (typeof sessionStorage === 'undefined') {
|
if (typeof sessionStorage === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
|
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearPersistedMetadata(): void {
|
private clearPersistedMetadata(): void {
|
||||||
if (typeof sessionStorage === 'undefined') {
|
if (typeof sessionStorage === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
getActiveTenantId(): string | null {
|
getActiveTenantId(): string | null {
|
||||||
return this.tenantId();
|
return this.tenantId();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
const LOGIN_REQUEST_KEY = 'stellaops.auth.login.request';
|
const LOGIN_REQUEST_KEY = 'stellaops.auth.login.request';
|
||||||
|
|
||||||
export interface PendingLoginRequest {
|
export interface PendingLoginRequest {
|
||||||
readonly state: string;
|
readonly state: string;
|
||||||
readonly codeVerifier: string;
|
readonly codeVerifier: string;
|
||||||
readonly createdAtEpochMs: number;
|
readonly createdAtEpochMs: number;
|
||||||
readonly returnUrl?: string;
|
readonly returnUrl?: string;
|
||||||
readonly nonce?: string;
|
readonly nonce?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class AuthStorageService {
|
export class AuthStorageService {
|
||||||
savePendingLogin(request: PendingLoginRequest): void {
|
savePendingLogin(request: PendingLoginRequest): void {
|
||||||
if (typeof sessionStorage === 'undefined') {
|
if (typeof sessionStorage === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sessionStorage.setItem(LOGIN_REQUEST_KEY, JSON.stringify(request));
|
sessionStorage.setItem(LOGIN_REQUEST_KEY, JSON.stringify(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
consumePendingLogin(expectedState: string): PendingLoginRequest | null {
|
consumePendingLogin(expectedState: string): PendingLoginRequest | null {
|
||||||
if (typeof sessionStorage === 'undefined') {
|
if (typeof sessionStorage === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = sessionStorage.getItem(LOGIN_REQUEST_KEY);
|
const raw = sessionStorage.getItem(LOGIN_REQUEST_KEY);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionStorage.removeItem(LOGIN_REQUEST_KEY);
|
sessionStorage.removeItem(LOGIN_REQUEST_KEY);
|
||||||
try {
|
try {
|
||||||
const request = JSON.parse(raw) as PendingLoginRequest;
|
const request = JSON.parse(raw) as PendingLoginRequest;
|
||||||
if (request.state !== expectedState) {
|
if (request.state !== expectedState) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return request;
|
return request;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,217 +1,217 @@
|
|||||||
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
|
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
StellaOpsScopes,
|
StellaOpsScopes,
|
||||||
StellaOpsScope,
|
StellaOpsScope,
|
||||||
ScopeGroups,
|
ScopeGroups,
|
||||||
hasScope,
|
hasScope,
|
||||||
hasAllScopes,
|
hasAllScopes,
|
||||||
hasAnyScope,
|
hasAnyScope,
|
||||||
} from './scopes';
|
} from './scopes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User info from authentication.
|
* User info from authentication.
|
||||||
*/
|
*/
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly email: string;
|
readonly email: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly tenantId: string;
|
readonly tenantId: string;
|
||||||
readonly tenantName: string;
|
readonly tenantName: string;
|
||||||
readonly roles: readonly string[];
|
readonly roles: readonly string[];
|
||||||
readonly scopes: readonly StellaOpsScope[];
|
readonly scopes: readonly StellaOpsScope[];
|
||||||
readonly picture?: string;
|
readonly picture?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injection token for Auth service.
|
* Injection token for Auth service.
|
||||||
*/
|
*/
|
||||||
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
|
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth service interface.
|
* Auth service interface.
|
||||||
*/
|
*/
|
||||||
export interface AuthService {
|
export interface AuthService {
|
||||||
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
|
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
|
||||||
readonly user: ReturnType<typeof signal<AuthUser | null>>;
|
readonly user: ReturnType<typeof signal<AuthUser | null>>;
|
||||||
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
|
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
|
||||||
|
|
||||||
hasScope(scope: StellaOpsScope): boolean;
|
hasScope(scope: StellaOpsScope): boolean;
|
||||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
|
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
|
||||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
|
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
|
||||||
canViewGraph(): boolean;
|
canViewGraph(): boolean;
|
||||||
canEditGraph(): boolean;
|
canEditGraph(): boolean;
|
||||||
canExportGraph(): boolean;
|
canExportGraph(): boolean;
|
||||||
canSimulate(): boolean;
|
canSimulate(): boolean;
|
||||||
// Orchestrator access (UI-ORCH-32-001)
|
// Orchestrator access (UI-ORCH-32-001)
|
||||||
canViewOrchestrator(): boolean;
|
canViewOrchestrator(): boolean;
|
||||||
canOperateOrchestrator(): boolean;
|
canOperateOrchestrator(): boolean;
|
||||||
canManageOrchestratorQuotas(): boolean;
|
canManageOrchestratorQuotas(): boolean;
|
||||||
canInitiateBackfill(): boolean;
|
canInitiateBackfill(): boolean;
|
||||||
// Policy Studio access (UI-POLICY-20-003)
|
// Policy Studio access (UI-POLICY-20-003)
|
||||||
canViewPolicies(): boolean;
|
canViewPolicies(): boolean;
|
||||||
canAuthorPolicies(): boolean;
|
canAuthorPolicies(): boolean;
|
||||||
canEditPolicies(): boolean;
|
canEditPolicies(): boolean;
|
||||||
canReviewPolicies(): boolean;
|
canReviewPolicies(): boolean;
|
||||||
canApprovePolicies(): boolean;
|
canApprovePolicies(): boolean;
|
||||||
canOperatePolicies(): boolean;
|
canOperatePolicies(): boolean;
|
||||||
canActivatePolicies(): boolean;
|
canActivatePolicies(): boolean;
|
||||||
canSimulatePolicies(): boolean;
|
canSimulatePolicies(): boolean;
|
||||||
canPublishPolicies(): boolean;
|
canPublishPolicies(): boolean;
|
||||||
canAuditPolicies(): boolean;
|
canAuditPolicies(): boolean;
|
||||||
// Session management
|
// Session management
|
||||||
logout?(): void;
|
logout?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Mock Auth Service
|
// Mock Auth Service
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const MOCK_USER: AuthUser = {
|
const MOCK_USER: AuthUser = {
|
||||||
id: 'user-001',
|
id: 'user-001',
|
||||||
email: 'developer@example.com',
|
email: 'developer@example.com',
|
||||||
name: 'Developer User',
|
name: 'Developer User',
|
||||||
tenantId: 'tenant-001',
|
tenantId: 'tenant-001',
|
||||||
tenantName: 'Acme Corp',
|
tenantName: 'Acme Corp',
|
||||||
roles: ['developer', 'security-analyst'],
|
roles: ['developer', 'security-analyst'],
|
||||||
scopes: [
|
scopes: [
|
||||||
// Graph permissions
|
// Graph permissions
|
||||||
StellaOpsScopes.GRAPH_READ,
|
StellaOpsScopes.GRAPH_READ,
|
||||||
StellaOpsScopes.GRAPH_WRITE,
|
StellaOpsScopes.GRAPH_WRITE,
|
||||||
StellaOpsScopes.GRAPH_SIMULATE,
|
StellaOpsScopes.GRAPH_SIMULATE,
|
||||||
StellaOpsScopes.GRAPH_EXPORT,
|
StellaOpsScopes.GRAPH_EXPORT,
|
||||||
// SBOM permissions
|
// SBOM permissions
|
||||||
StellaOpsScopes.SBOM_READ,
|
StellaOpsScopes.SBOM_READ,
|
||||||
// Policy permissions (Policy Studio - UI-POLICY-20-003)
|
// Policy permissions (Policy Studio - UI-POLICY-20-003)
|
||||||
StellaOpsScopes.POLICY_READ,
|
StellaOpsScopes.POLICY_READ,
|
||||||
StellaOpsScopes.POLICY_EVALUATE,
|
StellaOpsScopes.POLICY_EVALUATE,
|
||||||
StellaOpsScopes.POLICY_SIMULATE,
|
StellaOpsScopes.POLICY_SIMULATE,
|
||||||
StellaOpsScopes.POLICY_AUTHOR,
|
StellaOpsScopes.POLICY_AUTHOR,
|
||||||
StellaOpsScopes.POLICY_EDIT,
|
StellaOpsScopes.POLICY_EDIT,
|
||||||
StellaOpsScopes.POLICY_REVIEW,
|
StellaOpsScopes.POLICY_REVIEW,
|
||||||
StellaOpsScopes.POLICY_SUBMIT,
|
StellaOpsScopes.POLICY_SUBMIT,
|
||||||
StellaOpsScopes.POLICY_APPROVE,
|
StellaOpsScopes.POLICY_APPROVE,
|
||||||
StellaOpsScopes.POLICY_OPERATE,
|
StellaOpsScopes.POLICY_OPERATE,
|
||||||
StellaOpsScopes.POLICY_ACTIVATE,
|
StellaOpsScopes.POLICY_ACTIVATE,
|
||||||
StellaOpsScopes.POLICY_RUN,
|
StellaOpsScopes.POLICY_RUN,
|
||||||
StellaOpsScopes.POLICY_AUDIT,
|
StellaOpsScopes.POLICY_AUDIT,
|
||||||
// Scanner permissions
|
// Scanner permissions
|
||||||
StellaOpsScopes.SCANNER_READ,
|
StellaOpsScopes.SCANNER_READ,
|
||||||
// Exception permissions
|
// Exception permissions
|
||||||
StellaOpsScopes.EXCEPTION_READ,
|
StellaOpsScopes.EXCEPTION_READ,
|
||||||
StellaOpsScopes.EXCEPTION_WRITE,
|
StellaOpsScopes.EXCEPTION_WRITE,
|
||||||
// Release permissions
|
// Release permissions
|
||||||
StellaOpsScopes.RELEASE_READ,
|
StellaOpsScopes.RELEASE_READ,
|
||||||
// AOC permissions
|
// AOC permissions
|
||||||
StellaOpsScopes.AOC_READ,
|
StellaOpsScopes.AOC_READ,
|
||||||
// Orchestrator permissions (UI-ORCH-32-001)
|
// Orchestrator permissions (UI-ORCH-32-001)
|
||||||
StellaOpsScopes.ORCH_READ,
|
StellaOpsScopes.ORCH_READ,
|
||||||
// UI permissions
|
// UI permissions
|
||||||
StellaOpsScopes.UI_READ,
|
StellaOpsScopes.UI_READ,
|
||||||
// Analytics permissions
|
// Analytics permissions
|
||||||
StellaOpsScopes.ANALYTICS_READ,
|
StellaOpsScopes.ANALYTICS_READ,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MockAuthService implements AuthService {
|
export class MockAuthService implements AuthService {
|
||||||
readonly isAuthenticated = signal(true);
|
readonly isAuthenticated = signal(true);
|
||||||
readonly user = signal<AuthUser | null>(MOCK_USER);
|
readonly user = signal<AuthUser | null>(MOCK_USER);
|
||||||
|
|
||||||
readonly scopes = computed(() => {
|
readonly scopes = computed(() => {
|
||||||
const u = this.user();
|
const u = this.user();
|
||||||
return u?.scopes ?? [];
|
return u?.scopes ?? [];
|
||||||
});
|
});
|
||||||
|
|
||||||
hasScope(scope: StellaOpsScope): boolean {
|
hasScope(scope: StellaOpsScope): boolean {
|
||||||
return hasScope(this.scopes(), scope);
|
return hasScope(this.scopes(), scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
|
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
|
||||||
return hasAllScopes(this.scopes(), scopes);
|
return hasAllScopes(this.scopes(), scopes);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
|
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
|
||||||
return hasAnyScope(this.scopes(), scopes);
|
return hasAnyScope(this.scopes(), scopes);
|
||||||
}
|
}
|
||||||
|
|
||||||
canViewGraph(): boolean {
|
canViewGraph(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.GRAPH_READ);
|
return this.hasScope(StellaOpsScopes.GRAPH_READ);
|
||||||
}
|
}
|
||||||
|
|
||||||
canEditGraph(): boolean {
|
canEditGraph(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
|
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
|
||||||
}
|
}
|
||||||
|
|
||||||
canExportGraph(): boolean {
|
canExportGraph(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
|
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
|
||||||
}
|
}
|
||||||
|
|
||||||
canSimulate(): boolean {
|
canSimulate(): boolean {
|
||||||
return this.hasAnyScope([
|
return this.hasAnyScope([
|
||||||
StellaOpsScopes.GRAPH_SIMULATE,
|
StellaOpsScopes.GRAPH_SIMULATE,
|
||||||
StellaOpsScopes.POLICY_SIMULATE,
|
StellaOpsScopes.POLICY_SIMULATE,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Orchestrator access methods (UI-ORCH-32-001)
|
// Orchestrator access methods (UI-ORCH-32-001)
|
||||||
canViewOrchestrator(): boolean {
|
canViewOrchestrator(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.ORCH_READ);
|
return this.hasScope(StellaOpsScopes.ORCH_READ);
|
||||||
}
|
}
|
||||||
|
|
||||||
canOperateOrchestrator(): boolean {
|
canOperateOrchestrator(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
|
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
canManageOrchestratorQuotas(): boolean {
|
canManageOrchestratorQuotas(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
|
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
|
||||||
}
|
}
|
||||||
|
|
||||||
canInitiateBackfill(): boolean {
|
canInitiateBackfill(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
|
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Policy Studio access methods (UI-POLICY-20-003)
|
// Policy Studio access methods (UI-POLICY-20-003)
|
||||||
canViewPolicies(): boolean {
|
canViewPolicies(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.POLICY_READ);
|
return this.hasScope(StellaOpsScopes.POLICY_READ);
|
||||||
}
|
}
|
||||||
|
|
||||||
canAuthorPolicies(): boolean {
|
canAuthorPolicies(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.POLICY_AUTHOR);
|
return this.hasScope(StellaOpsScopes.POLICY_AUTHOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
canEditPolicies(): boolean {
|
canEditPolicies(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.POLICY_EDIT);
|
return this.hasScope(StellaOpsScopes.POLICY_EDIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
canReviewPolicies(): boolean {
|
canReviewPolicies(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.POLICY_REVIEW);
|
return this.hasScope(StellaOpsScopes.POLICY_REVIEW);
|
||||||
}
|
}
|
||||||
|
|
||||||
canApprovePolicies(): boolean {
|
canApprovePolicies(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.POLICY_APPROVE);
|
return this.hasScope(StellaOpsScopes.POLICY_APPROVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
canOperatePolicies(): boolean {
|
canOperatePolicies(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.POLICY_OPERATE);
|
return this.hasScope(StellaOpsScopes.POLICY_OPERATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
canActivatePolicies(): boolean {
|
canActivatePolicies(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.POLICY_ACTIVATE);
|
return this.hasScope(StellaOpsScopes.POLICY_ACTIVATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
canSimulatePolicies(): boolean {
|
canSimulatePolicies(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.POLICY_SIMULATE);
|
return this.hasScope(StellaOpsScopes.POLICY_SIMULATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
canPublishPolicies(): boolean {
|
canPublishPolicies(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.POLICY_PUBLISH);
|
return this.hasScope(StellaOpsScopes.POLICY_PUBLISH);
|
||||||
}
|
}
|
||||||
|
|
||||||
canAuditPolicies(): boolean {
|
canAuditPolicies(): boolean {
|
||||||
return this.hasScope(StellaOpsScopes.POLICY_AUDIT);
|
return this.hasScope(StellaOpsScopes.POLICY_AUDIT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export scopes for convenience
|
// Re-export scopes for convenience
|
||||||
export { StellaOpsScopes, ScopeGroups } from './scopes';
|
export { StellaOpsScopes, ScopeGroups } from './scopes';
|
||||||
export type { StellaOpsScope } from './scopes';
|
export type { StellaOpsScope } from './scopes';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,181 +1,181 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
import { DPoPAlgorithm } from '../../config/app-config.model';
|
import { DPoPAlgorithm } from '../../config/app-config.model';
|
||||||
import { computeJwkThumbprint } from './jose-utilities';
|
import { computeJwkThumbprint } from './jose-utilities';
|
||||||
|
|
||||||
const DB_NAME = 'stellaops-auth';
|
const DB_NAME = 'stellaops-auth';
|
||||||
const STORE_NAME = 'dpopKeys';
|
const STORE_NAME = 'dpopKeys';
|
||||||
const PRIMARY_KEY = 'primary';
|
const PRIMARY_KEY = 'primary';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 1;
|
||||||
|
|
||||||
interface PersistedKeyPair {
|
interface PersistedKeyPair {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly algorithm: DPoPAlgorithm;
|
readonly algorithm: DPoPAlgorithm;
|
||||||
readonly publicJwk: JsonWebKey;
|
readonly publicJwk: JsonWebKey;
|
||||||
readonly privateJwk: JsonWebKey;
|
readonly privateJwk: JsonWebKey;
|
||||||
readonly thumbprint: string;
|
readonly thumbprint: string;
|
||||||
readonly createdAtIso: string;
|
readonly createdAtIso: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadedDpopKeyPair {
|
export interface LoadedDpopKeyPair {
|
||||||
readonly algorithm: DPoPAlgorithm;
|
readonly algorithm: DPoPAlgorithm;
|
||||||
readonly privateKey: CryptoKey;
|
readonly privateKey: CryptoKey;
|
||||||
readonly publicKey: CryptoKey;
|
readonly publicKey: CryptoKey;
|
||||||
readonly publicJwk: JsonWebKey;
|
readonly publicJwk: JsonWebKey;
|
||||||
readonly thumbprint: string;
|
readonly thumbprint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class DpopKeyStore {
|
export class DpopKeyStore {
|
||||||
private dbPromise: Promise<IDBDatabase> | null = null;
|
private dbPromise: Promise<IDBDatabase> | null = null;
|
||||||
|
|
||||||
async load(): Promise<LoadedDpopKeyPair | null> {
|
async load(): Promise<LoadedDpopKeyPair | null> {
|
||||||
const record = await this.read();
|
const record = await this.read();
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [privateKey, publicKey] = await Promise.all([
|
const [privateKey, publicKey] = await Promise.all([
|
||||||
crypto.subtle.importKey(
|
crypto.subtle.importKey(
|
||||||
'jwk',
|
'jwk',
|
||||||
record.privateJwk,
|
record.privateJwk,
|
||||||
this.toKeyAlgorithm(record.algorithm),
|
this.toKeyAlgorithm(record.algorithm),
|
||||||
true,
|
true,
|
||||||
['sign']
|
['sign']
|
||||||
),
|
),
|
||||||
crypto.subtle.importKey(
|
crypto.subtle.importKey(
|
||||||
'jwk',
|
'jwk',
|
||||||
record.publicJwk,
|
record.publicJwk,
|
||||||
this.toKeyAlgorithm(record.algorithm),
|
this.toKeyAlgorithm(record.algorithm),
|
||||||
true,
|
true,
|
||||||
['verify']
|
['verify']
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
algorithm: record.algorithm,
|
algorithm: record.algorithm,
|
||||||
privateKey,
|
privateKey,
|
||||||
publicKey,
|
publicKey,
|
||||||
publicJwk: record.publicJwk,
|
publicJwk: record.publicJwk,
|
||||||
thumbprint: record.thumbprint,
|
thumbprint: record.thumbprint,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(
|
async save(
|
||||||
keyPair: CryptoKeyPair,
|
keyPair: CryptoKeyPair,
|
||||||
algorithm: DPoPAlgorithm
|
algorithm: DPoPAlgorithm
|
||||||
): Promise<LoadedDpopKeyPair> {
|
): Promise<LoadedDpopKeyPair> {
|
||||||
const [publicJwk, privateJwk] = await Promise.all([
|
const [publicJwk, privateJwk] = await Promise.all([
|
||||||
crypto.subtle.exportKey('jwk', keyPair.publicKey),
|
crypto.subtle.exportKey('jwk', keyPair.publicKey),
|
||||||
crypto.subtle.exportKey('jwk', keyPair.privateKey),
|
crypto.subtle.exportKey('jwk', keyPair.privateKey),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!publicJwk) {
|
if (!publicJwk) {
|
||||||
throw new Error('Failed to export public JWK for DPoP key pair.');
|
throw new Error('Failed to export public JWK for DPoP key pair.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const thumbprint = await computeJwkThumbprint(publicJwk);
|
const thumbprint = await computeJwkThumbprint(publicJwk);
|
||||||
const record: PersistedKeyPair = {
|
const record: PersistedKeyPair = {
|
||||||
id: PRIMARY_KEY,
|
id: PRIMARY_KEY,
|
||||||
algorithm,
|
algorithm,
|
||||||
publicJwk,
|
publicJwk,
|
||||||
privateJwk,
|
privateJwk,
|
||||||
thumbprint,
|
thumbprint,
|
||||||
createdAtIso: new Date().toISOString(),
|
createdAtIso: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.write(record);
|
await this.write(record);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
algorithm,
|
algorithm,
|
||||||
privateKey: keyPair.privateKey,
|
privateKey: keyPair.privateKey,
|
||||||
publicKey: keyPair.publicKey,
|
publicKey: keyPair.publicKey,
|
||||||
publicJwk,
|
publicJwk,
|
||||||
thumbprint,
|
thumbprint,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(): Promise<void> {
|
async clear(): Promise<void> {
|
||||||
const db = await this.openDb();
|
const db = await this.openDb();
|
||||||
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
||||||
store.delete(PRIMARY_KEY)
|
store.delete(PRIMARY_KEY)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generate(algorithm: DPoPAlgorithm): Promise<LoadedDpopKeyPair> {
|
async generate(algorithm: DPoPAlgorithm): Promise<LoadedDpopKeyPair> {
|
||||||
const algo = this.toKeyAlgorithm(algorithm);
|
const algo = this.toKeyAlgorithm(algorithm);
|
||||||
const keyPair = await crypto.subtle.generateKey(algo, true, [
|
const keyPair = await crypto.subtle.generateKey(algo, true, [
|
||||||
'sign',
|
'sign',
|
||||||
'verify',
|
'verify',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const stored = await this.save(keyPair, algorithm);
|
const stored = await this.save(keyPair, algorithm);
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async read(): Promise<PersistedKeyPair | null> {
|
private async read(): Promise<PersistedKeyPair | null> {
|
||||||
const db = await this.openDb();
|
const db = await this.openDb();
|
||||||
return transactionPromise(db, STORE_NAME, 'readonly', (store) =>
|
return transactionPromise(db, STORE_NAME, 'readonly', (store) =>
|
||||||
store.get(PRIMARY_KEY)
|
store.get(PRIMARY_KEY)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async write(record: PersistedKeyPair): Promise<void> {
|
private async write(record: PersistedKeyPair): Promise<void> {
|
||||||
const db = await this.openDb();
|
const db = await this.openDb();
|
||||||
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
||||||
store.put(record)
|
store.put(record)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toKeyAlgorithm(algorithm: DPoPAlgorithm): EcKeyImportParams {
|
private toKeyAlgorithm(algorithm: DPoPAlgorithm): EcKeyImportParams {
|
||||||
switch (algorithm) {
|
switch (algorithm) {
|
||||||
case 'ES384':
|
case 'ES384':
|
||||||
return { name: 'ECDSA', namedCurve: 'P-384' };
|
return { name: 'ECDSA', namedCurve: 'P-384' };
|
||||||
case 'EdDSA':
|
case 'EdDSA':
|
||||||
throw new Error('EdDSA DPoP keys are not yet supported.');
|
throw new Error('EdDSA DPoP keys are not yet supported.');
|
||||||
case 'ES256':
|
case 'ES256':
|
||||||
default:
|
default:
|
||||||
return { name: 'ECDSA', namedCurve: 'P-256' };
|
return { name: 'ECDSA', namedCurve: 'P-256' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openDb(): Promise<IDBDatabase> {
|
private async openDb(): Promise<IDBDatabase> {
|
||||||
if (typeof indexedDB === 'undefined') {
|
if (typeof indexedDB === 'undefined') {
|
||||||
throw new Error('IndexedDB is not available for DPoP key persistence.');
|
throw new Error('IndexedDB is not available for DPoP key persistence.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.dbPromise) {
|
if (!this.dbPromise) {
|
||||||
this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = () => {
|
||||||
const db = request.result;
|
const db = request.result;
|
||||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => resolve(request.result);
|
||||||
request.onerror = () => reject(request.error);
|
request.onerror = () => reject(request.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dbPromise;
|
return this.dbPromise;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function transactionPromise<T>(
|
function transactionPromise<T>(
|
||||||
db: IDBDatabase,
|
db: IDBDatabase,
|
||||||
storeName: string,
|
storeName: string,
|
||||||
mode: IDBTransactionMode,
|
mode: IDBTransactionMode,
|
||||||
executor: (store: IDBObjectStore) => IDBRequest<T>
|
executor: (store: IDBObjectStore) => IDBRequest<T>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const transaction = db.transaction(storeName, mode);
|
const transaction = db.transaction(storeName, mode);
|
||||||
const store = transaction.objectStore(storeName);
|
const store = transaction.objectStore(storeName);
|
||||||
const request = executor(store);
|
const request = executor(store);
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => resolve(request.result);
|
||||||
request.onerror = () => reject(request.error);
|
request.onerror = () => reject(request.error);
|
||||||
transaction.onabort = () => reject(transaction.error);
|
transaction.onabort = () => reject(transaction.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +1,103 @@
|
|||||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { APP_CONFIG, AppConfig } from '../../config/app-config.model';
|
import { APP_CONFIG, AppConfig } from '../../config/app-config.model';
|
||||||
import { AppConfigService } from '../../config/app-config.service';
|
import { AppConfigService } from '../../config/app-config.service';
|
||||||
import { base64UrlDecode } from './jose-utilities';
|
import { base64UrlDecode } from './jose-utilities';
|
||||||
import { DpopKeyStore } from './dpop-key-store';
|
import { DpopKeyStore } from './dpop-key-store';
|
||||||
import { DpopService } from './dpop.service';
|
import { DpopService } from './dpop.service';
|
||||||
|
|
||||||
describe('DpopService', () => {
|
describe('DpopService', () => {
|
||||||
const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
|
const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
|
||||||
const config: AppConfig = {
|
const config: AppConfig = {
|
||||||
authority: {
|
authority: {
|
||||||
issuer: 'https://auth.stellaops.test/',
|
issuer: 'https://auth.stellaops.test/',
|
||||||
clientId: 'ui-client',
|
clientId: 'ui-client',
|
||||||
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
|
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
|
||||||
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
|
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
|
||||||
redirectUri: 'https://ui.stellaops.test/auth/callback',
|
redirectUri: 'https://ui.stellaops.test/auth/callback',
|
||||||
scope: 'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
|
scope: 'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
|
||||||
audience: 'https://scanner.stellaops.test',
|
audience: 'https://scanner.stellaops.test',
|
||||||
},
|
},
|
||||||
apiBaseUrls: {
|
apiBaseUrls: {
|
||||||
authority: 'https://auth.stellaops.test',
|
authority: 'https://auth.stellaops.test',
|
||||||
scanner: 'https://scanner.stellaops.test',
|
scanner: 'https://scanner.stellaops.test',
|
||||||
policy: 'https://policy.stellaops.test',
|
policy: 'https://policy.stellaops.test',
|
||||||
concelier: 'https://concelier.stellaops.test',
|
concelier: 'https://concelier.stellaops.test',
|
||||||
attestor: 'https://attestor.stellaops.test',
|
attestor: 'https://attestor.stellaops.test',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
|
jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [HttpClientTestingModule],
|
imports: [HttpClientTestingModule],
|
||||||
providers: [
|
providers: [
|
||||||
AppConfigService,
|
AppConfigService,
|
||||||
DpopKeyStore,
|
DpopKeyStore,
|
||||||
DpopService,
|
DpopService,
|
||||||
{
|
{
|
||||||
provide: APP_CONFIG,
|
provide: APP_CONFIG,
|
||||||
useValue: config,
|
useValue: config,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
|
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
|
||||||
const store = TestBed.inject(DpopKeyStore);
|
const store = TestBed.inject(DpopKeyStore);
|
||||||
try {
|
try {
|
||||||
await store.clear();
|
await store.clear();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore cleanup issues in test environment
|
// ignore cleanup issues in test environment
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a DPoP proof with expected header values', async () => {
|
it('creates a DPoP proof with expected header values', async () => {
|
||||||
const appConfig = TestBed.inject(AppConfigService);
|
const appConfig = TestBed.inject(AppConfigService);
|
||||||
appConfig.setConfigForTesting(config);
|
appConfig.setConfigForTesting(config);
|
||||||
const service = TestBed.inject(DpopService);
|
const service = TestBed.inject(DpopService);
|
||||||
|
|
||||||
const proof = await service.createProof({
|
const proof = await service.createProof({
|
||||||
htm: 'get',
|
htm: 'get',
|
||||||
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [rawHeader, rawPayload] = proof.split('.');
|
const [rawHeader, rawPayload] = proof.split('.');
|
||||||
const header = JSON.parse(
|
const header = JSON.parse(
|
||||||
new TextDecoder().decode(base64UrlDecode(rawHeader))
|
new TextDecoder().decode(base64UrlDecode(rawHeader))
|
||||||
);
|
);
|
||||||
const payload = JSON.parse(
|
const payload = JSON.parse(
|
||||||
new TextDecoder().decode(base64UrlDecode(rawPayload))
|
new TextDecoder().decode(base64UrlDecode(rawPayload))
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(header.typ).toBe('dpop+jwt');
|
expect(header.typ).toBe('dpop+jwt');
|
||||||
expect(header.alg).toBe('ES256');
|
expect(header.alg).toBe('ES256');
|
||||||
expect(header.jwk.kty).toBe('EC');
|
expect(header.jwk.kty).toBe('EC');
|
||||||
expect(payload.htm).toBe('GET');
|
expect(payload.htm).toBe('GET');
|
||||||
expect(payload.htu).toBe('https://scanner.stellaops.test/api/v1/scans');
|
expect(payload.htu).toBe('https://scanner.stellaops.test/api/v1/scans');
|
||||||
expect(typeof payload.iat).toBe('number');
|
expect(typeof payload.iat).toBe('number');
|
||||||
expect(typeof payload.jti).toBe('string');
|
expect(typeof payload.jti).toBe('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('binds access token hash when provided', async () => {
|
it('binds access token hash when provided', async () => {
|
||||||
const appConfig = TestBed.inject(AppConfigService);
|
const appConfig = TestBed.inject(AppConfigService);
|
||||||
appConfig.setConfigForTesting(config);
|
appConfig.setConfigForTesting(config);
|
||||||
const service = TestBed.inject(DpopService);
|
const service = TestBed.inject(DpopService);
|
||||||
|
|
||||||
const accessToken = 'sample-access-token';
|
const accessToken = 'sample-access-token';
|
||||||
const proof = await service.createProof({
|
const proof = await service.createProof({
|
||||||
htm: 'post',
|
htm: 'post',
|
||||||
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
||||||
accessToken,
|
accessToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = JSON.parse(
|
const payload = JSON.parse(
|
||||||
new TextDecoder().decode(base64UrlDecode(proof.split('.')[1]))
|
new TextDecoder().decode(base64UrlDecode(proof.split('.')[1]))
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(payload.ath).toBeDefined();
|
expect(payload.ath).toBeDefined();
|
||||||
expect(typeof payload.ath).toBe('string');
|
expect(typeof payload.ath).toBe('string');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,148 +1,148 @@
|
|||||||
import { Injectable, computed, signal } from '@angular/core';
|
import { Injectable, computed, signal } from '@angular/core';
|
||||||
|
|
||||||
import { AppConfigService } from '../../config/app-config.service';
|
import { AppConfigService } from '../../config/app-config.service';
|
||||||
import { DPoPAlgorithm } from '../../config/app-config.model';
|
import { DPoPAlgorithm } from '../../config/app-config.model';
|
||||||
import { sha256, base64UrlEncode, derToJoseSignature } from './jose-utilities';
|
import { sha256, base64UrlEncode, derToJoseSignature } from './jose-utilities';
|
||||||
import { DpopKeyStore, LoadedDpopKeyPair } from './dpop-key-store';
|
import { DpopKeyStore, LoadedDpopKeyPair } from './dpop-key-store';
|
||||||
|
|
||||||
export interface DpopProofOptions {
|
export interface DpopProofOptions {
|
||||||
readonly htm: string;
|
readonly htm: string;
|
||||||
readonly htu: string;
|
readonly htu: string;
|
||||||
readonly accessToken?: string;
|
readonly accessToken?: string;
|
||||||
readonly nonce?: string | null;
|
readonly nonce?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class DpopService {
|
export class DpopService {
|
||||||
private keyPairPromise: Promise<LoadedDpopKeyPair> | null = null;
|
private keyPairPromise: Promise<LoadedDpopKeyPair> | null = null;
|
||||||
private readonly nonceSignal = signal<string | null>(null);
|
private readonly nonceSignal = signal<string | null>(null);
|
||||||
readonly nonce = computed(() => this.nonceSignal());
|
readonly nonce = computed(() => this.nonceSignal());
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: AppConfigService,
|
private readonly config: AppConfigService,
|
||||||
private readonly store: DpopKeyStore
|
private readonly store: DpopKeyStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async setNonce(nonce: string | null): Promise<void> {
|
async setNonce(nonce: string | null): Promise<void> {
|
||||||
this.nonceSignal.set(nonce);
|
this.nonceSignal.set(nonce);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getThumbprint(): Promise<string | null> {
|
async getThumbprint(): Promise<string | null> {
|
||||||
const key = await this.getOrCreateKeyPair();
|
const key = await this.getOrCreateKeyPair();
|
||||||
return key.thumbprint ?? null;
|
return key.thumbprint ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async rotateKey(): Promise<void> {
|
async rotateKey(): Promise<void> {
|
||||||
const algorithm = this.resolveAlgorithm();
|
const algorithm = this.resolveAlgorithm();
|
||||||
this.keyPairPromise = this.store.generate(algorithm);
|
this.keyPairPromise = this.store.generate(algorithm);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createProof(options: DpopProofOptions): Promise<string> {
|
async createProof(options: DpopProofOptions): Promise<string> {
|
||||||
const keyPair = await this.getOrCreateKeyPair();
|
const keyPair = await this.getOrCreateKeyPair();
|
||||||
|
|
||||||
const header = {
|
const header = {
|
||||||
typ: 'dpop+jwt',
|
typ: 'dpop+jwt',
|
||||||
alg: keyPair.algorithm,
|
alg: keyPair.algorithm,
|
||||||
jwk: keyPair.publicJwk,
|
jwk: keyPair.publicJwk,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
htm: options.htm.toUpperCase(),
|
htm: options.htm.toUpperCase(),
|
||||||
htu: normalizeHtu(options.htu),
|
htu: normalizeHtu(options.htu),
|
||||||
iat: nowSeconds,
|
iat: nowSeconds,
|
||||||
jti: crypto.randomUUID ? crypto.randomUUID() : createRandomId(),
|
jti: crypto.randomUUID ? crypto.randomUUID() : createRandomId(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const nonce = options.nonce ?? this.nonceSignal();
|
const nonce = options.nonce ?? this.nonceSignal();
|
||||||
if (nonce) {
|
if (nonce) {
|
||||||
payload['nonce'] = nonce;
|
payload['nonce'] = nonce;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.accessToken) {
|
if (options.accessToken) {
|
||||||
const accessTokenHash = await sha256(
|
const accessTokenHash = await sha256(
|
||||||
new TextEncoder().encode(options.accessToken)
|
new TextEncoder().encode(options.accessToken)
|
||||||
);
|
);
|
||||||
payload['ath'] = base64UrlEncode(accessTokenHash);
|
payload['ath'] = base64UrlEncode(accessTokenHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||||
const signature = await crypto.subtle.sign(
|
const signature = await crypto.subtle.sign(
|
||||||
{
|
{
|
||||||
name: 'ECDSA',
|
name: 'ECDSA',
|
||||||
hash: this.resolveHashAlgorithm(keyPair.algorithm),
|
hash: this.resolveHashAlgorithm(keyPair.algorithm),
|
||||||
},
|
},
|
||||||
keyPair.privateKey,
|
keyPair.privateKey,
|
||||||
new TextEncoder().encode(signingInput)
|
new TextEncoder().encode(signingInput)
|
||||||
);
|
);
|
||||||
|
|
||||||
const joseSignature = base64UrlEncode(derToJoseSignature(signature));
|
const joseSignature = base64UrlEncode(derToJoseSignature(signature));
|
||||||
return `${signingInput}.${joseSignature}`;
|
return `${signingInput}.${joseSignature}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrCreateKeyPair(): Promise<LoadedDpopKeyPair> {
|
private async getOrCreateKeyPair(): Promise<LoadedDpopKeyPair> {
|
||||||
if (!this.keyPairPromise) {
|
if (!this.keyPairPromise) {
|
||||||
this.keyPairPromise = this.loadKeyPair();
|
this.keyPairPromise = this.loadKeyPair();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await this.keyPairPromise;
|
return await this.keyPairPromise;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Reset the memoized promise so a subsequent call can retry.
|
// Reset the memoized promise so a subsequent call can retry.
|
||||||
this.keyPairPromise = null;
|
this.keyPairPromise = null;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadKeyPair(): Promise<LoadedDpopKeyPair> {
|
private async loadKeyPair(): Promise<LoadedDpopKeyPair> {
|
||||||
const algorithm = this.resolveAlgorithm();
|
const algorithm = this.resolveAlgorithm();
|
||||||
try {
|
try {
|
||||||
const existing = await this.store.load();
|
const existing = await this.store.load();
|
||||||
if (existing && existing.algorithm === algorithm) {
|
if (existing && existing.algorithm === algorithm) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to regeneration
|
// fall through to regeneration
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.store.generate(algorithm);
|
return this.store.generate(algorithm);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveAlgorithm(): DPoPAlgorithm {
|
private resolveAlgorithm(): DPoPAlgorithm {
|
||||||
const authority = this.config.authority;
|
const authority = this.config.authority;
|
||||||
return authority.dpopAlgorithms?.[0] ?? 'ES256';
|
return authority.dpopAlgorithms?.[0] ?? 'ES256';
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveHashAlgorithm(algorithm: DPoPAlgorithm): string {
|
private resolveHashAlgorithm(algorithm: DPoPAlgorithm): string {
|
||||||
switch (algorithm) {
|
switch (algorithm) {
|
||||||
case 'ES384':
|
case 'ES384':
|
||||||
return 'SHA-384';
|
return 'SHA-384';
|
||||||
case 'ES256':
|
case 'ES256':
|
||||||
default:
|
default:
|
||||||
return 'SHA-256';
|
return 'SHA-256';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHtu(value: string): string {
|
function normalizeHtu(value: string): string {
|
||||||
try {
|
try {
|
||||||
const base =
|
const base =
|
||||||
typeof window !== 'undefined' && window.location
|
typeof window !== 'undefined' && window.location
|
||||||
? window.location.origin
|
? window.location.origin
|
||||||
: undefined;
|
: undefined;
|
||||||
const url = base ? new URL(value, base) : new URL(value);
|
const url = base ? new URL(value, base) : new URL(value);
|
||||||
url.hash = '';
|
url.hash = '';
|
||||||
return url.toString();
|
return url.toString();
|
||||||
} catch {
|
} catch {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRandomId(): string {
|
function createRandomId(): string {
|
||||||
const array = new Uint8Array(16);
|
const array = new Uint8Array(16);
|
||||||
crypto.getRandomValues(array);
|
crypto.getRandomValues(array);
|
||||||
return base64UrlEncode(array);
|
return base64UrlEncode(array);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +1,123 @@
|
|||||||
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||||
return new Uint8Array(digest);
|
return new Uint8Array(digest);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function base64UrlEncode(
|
export function base64UrlEncode(
|
||||||
input: ArrayBuffer | Uint8Array | string
|
input: ArrayBuffer | Uint8Array | string
|
||||||
): string {
|
): string {
|
||||||
let bytes: Uint8Array;
|
let bytes: Uint8Array;
|
||||||
if (typeof input === 'string') {
|
if (typeof input === 'string') {
|
||||||
bytes = new TextEncoder().encode(input);
|
bytes = new TextEncoder().encode(input);
|
||||||
} else if (input instanceof Uint8Array) {
|
} else if (input instanceof Uint8Array) {
|
||||||
bytes = input;
|
bytes = input;
|
||||||
} else {
|
} else {
|
||||||
bytes = new Uint8Array(input);
|
bytes = new Uint8Array(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
let binary = '';
|
let binary = '';
|
||||||
for (let i = 0; i < bytes.byteLength; i += 1) {
|
for (let i = 0; i < bytes.byteLength; i += 1) {
|
||||||
binary += String.fromCharCode(bytes[i]);
|
binary += String.fromCharCode(bytes[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function base64UrlDecode(value: string): Uint8Array {
|
export function base64UrlDecode(value: string): Uint8Array {
|
||||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
const padding = normalized.length % 4;
|
const padding = normalized.length % 4;
|
||||||
const padded =
|
const padded =
|
||||||
padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
|
padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
|
||||||
const binary = atob(padded);
|
const binary = atob(padded);
|
||||||
const bytes = new Uint8Array(binary.length);
|
const bytes = new Uint8Array(binary.length);
|
||||||
for (let i = 0; i < binary.length; i += 1) {
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
bytes[i] = binary.charCodeAt(i);
|
bytes[i] = binary.charCodeAt(i);
|
||||||
}
|
}
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
|
export async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
|
||||||
const canonical = canonicalizeJwk(jwk);
|
const canonical = canonicalizeJwk(jwk);
|
||||||
const digest = await sha256(new TextEncoder().encode(canonical));
|
const digest = await sha256(new TextEncoder().encode(canonical));
|
||||||
return base64UrlEncode(digest);
|
return base64UrlEncode(digest);
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalizeJwk(jwk: JsonWebKey): string {
|
function canonicalizeJwk(jwk: JsonWebKey): string {
|
||||||
if (!jwk.kty) {
|
if (!jwk.kty) {
|
||||||
throw new Error('JWK must include "kty"');
|
throw new Error('JWK must include "kty"');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jwk.kty === 'EC') {
|
if (jwk.kty === 'EC') {
|
||||||
const { crv, kty, x, y } = jwk;
|
const { crv, kty, x, y } = jwk;
|
||||||
if (!crv || !x || !y) {
|
if (!crv || !x || !y) {
|
||||||
throw new Error('EC JWK must include "crv", "x", and "y".');
|
throw new Error('EC JWK must include "crv", "x", and "y".');
|
||||||
}
|
}
|
||||||
return JSON.stringify({ crv, kty, x, y });
|
return JSON.stringify({ crv, kty, x, y });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jwk.kty === 'OKP') {
|
if (jwk.kty === 'OKP') {
|
||||||
const { crv, kty, x } = jwk;
|
const { crv, kty, x } = jwk;
|
||||||
if (!crv || !x) {
|
if (!crv || !x) {
|
||||||
throw new Error('OKP JWK must include "crv" and "x".');
|
throw new Error('OKP JWK must include "crv" and "x".');
|
||||||
}
|
}
|
||||||
return JSON.stringify({ crv, kty, x });
|
return JSON.stringify({ crv, kty, x });
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
|
throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function derToJoseSignature(der: ArrayBuffer): Uint8Array {
|
export function derToJoseSignature(der: ArrayBuffer): Uint8Array {
|
||||||
const bytes = new Uint8Array(der);
|
const bytes = new Uint8Array(der);
|
||||||
if (bytes[0] !== 0x30) {
|
if (bytes[0] !== 0x30) {
|
||||||
// Some implementations already return raw (r || s) signature bytes.
|
// Some implementations already return raw (r || s) signature bytes.
|
||||||
if (bytes.length === 64) {
|
if (bytes.length === 64) {
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
throw new Error('Invalid DER signature: expected sequence.');
|
throw new Error('Invalid DER signature: expected sequence.');
|
||||||
}
|
}
|
||||||
|
|
||||||
let offset = 2; // skip SEQUENCE header and length (assume short form)
|
let offset = 2; // skip SEQUENCE header and length (assume short form)
|
||||||
if (bytes[1] & 0x80) {
|
if (bytes[1] & 0x80) {
|
||||||
const lengthBytes = bytes[1] & 0x7f;
|
const lengthBytes = bytes[1] & 0x7f;
|
||||||
offset = 2 + lengthBytes;
|
offset = 2 + lengthBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bytes[offset] !== 0x02) {
|
if (bytes[offset] !== 0x02) {
|
||||||
throw new Error('Invalid DER signature: expected INTEGER for r.');
|
throw new Error('Invalid DER signature: expected INTEGER for r.');
|
||||||
}
|
}
|
||||||
const rLength = bytes[offset + 1];
|
const rLength = bytes[offset + 1];
|
||||||
let r = bytes.slice(offset + 2, offset + 2 + rLength);
|
let r = bytes.slice(offset + 2, offset + 2 + rLength);
|
||||||
offset = offset + 2 + rLength;
|
offset = offset + 2 + rLength;
|
||||||
|
|
||||||
if (bytes[offset] !== 0x02) {
|
if (bytes[offset] !== 0x02) {
|
||||||
throw new Error('Invalid DER signature: expected INTEGER for s.');
|
throw new Error('Invalid DER signature: expected INTEGER for s.');
|
||||||
}
|
}
|
||||||
const sLength = bytes[offset + 1];
|
const sLength = bytes[offset + 1];
|
||||||
let s = bytes.slice(offset + 2, offset + 2 + sLength);
|
let s = bytes.slice(offset + 2, offset + 2 + sLength);
|
||||||
|
|
||||||
r = trimLeadingZeros(r);
|
r = trimLeadingZeros(r);
|
||||||
s = trimLeadingZeros(s);
|
s = trimLeadingZeros(s);
|
||||||
|
|
||||||
const targetLength = 32;
|
const targetLength = 32;
|
||||||
const signature = new Uint8Array(targetLength * 2);
|
const signature = new Uint8Array(targetLength * 2);
|
||||||
signature.set(padStart(r, targetLength), 0);
|
signature.set(padStart(r, targetLength), 0);
|
||||||
signature.set(padStart(s, targetLength), targetLength);
|
signature.set(padStart(s, targetLength), targetLength);
|
||||||
return signature;
|
return signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimLeadingZeros(bytes: Uint8Array): Uint8Array {
|
function trimLeadingZeros(bytes: Uint8Array): Uint8Array {
|
||||||
let start = 0;
|
let start = 0;
|
||||||
while (start < bytes.length - 1 && bytes[start] === 0x00) {
|
while (start < bytes.length - 1 && bytes[start] === 0x00) {
|
||||||
start += 1;
|
start += 1;
|
||||||
}
|
}
|
||||||
return bytes.subarray(start);
|
return bytes.subarray(start);
|
||||||
}
|
}
|
||||||
|
|
||||||
function padStart(bytes: Uint8Array, length: number): Uint8Array {
|
function padStart(bytes: Uint8Array, length: number): Uint8Array {
|
||||||
if (bytes.length >= length) {
|
if (bytes.length >= length) {
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
const padded = new Uint8Array(length);
|
const padded = new Uint8Array(length);
|
||||||
padded.set(bytes, length - bytes.length);
|
padded.set(bytes, length - bytes.length);
|
||||||
return padded;
|
return padded;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
export {
|
export {
|
||||||
StellaOpsScopes,
|
StellaOpsScopes,
|
||||||
StellaOpsScope,
|
StellaOpsScope,
|
||||||
ScopeGroups,
|
ScopeGroups,
|
||||||
ScopeLabels,
|
ScopeLabels,
|
||||||
hasScope,
|
hasScope,
|
||||||
hasAllScopes,
|
hasAllScopes,
|
||||||
hasAnyScope,
|
hasAnyScope,
|
||||||
} from './scopes';
|
} from './scopes';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
AuthUser,
|
AuthUser,
|
||||||
AuthService,
|
AuthService,
|
||||||
AUTH_SERVICE,
|
AUTH_SERVICE,
|
||||||
MockAuthService,
|
MockAuthService,
|
||||||
} from './auth.service';
|
} from './auth.service';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
requireAuthGuard,
|
requireAuthGuard,
|
||||||
requireScopesGuard,
|
requireScopesGuard,
|
||||||
@@ -32,34 +32,34 @@ export {
|
|||||||
requirePolicyAuditGuard,
|
requirePolicyAuditGuard,
|
||||||
requireAnalyticsViewerGuard,
|
requireAnalyticsViewerGuard,
|
||||||
} from './auth.guard';
|
} from './auth.guard';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
TenantActivationService,
|
TenantActivationService,
|
||||||
TenantScope,
|
TenantScope,
|
||||||
AuthDecision,
|
AuthDecision,
|
||||||
DenyReason,
|
DenyReason,
|
||||||
AuthDecisionAudit,
|
AuthDecisionAudit,
|
||||||
ScopeCheckResult,
|
ScopeCheckResult,
|
||||||
TenantContext,
|
TenantContext,
|
||||||
JwtClaims,
|
JwtClaims,
|
||||||
} from './tenant-activation.service';
|
} from './tenant-activation.service';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
TenantHttpInterceptor,
|
TenantHttpInterceptor,
|
||||||
TENANT_HEADERS,
|
TENANT_HEADERS,
|
||||||
} from './tenant-http.interceptor';
|
} from './tenant-http.interceptor';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
TenantPersistenceService,
|
TenantPersistenceService,
|
||||||
PersistenceAuditMetadata,
|
PersistenceAuditMetadata,
|
||||||
TenantPersistenceCheck,
|
TenantPersistenceCheck,
|
||||||
TenantStoragePath,
|
TenantStoragePath,
|
||||||
PersistenceAuditEvent,
|
PersistenceAuditEvent,
|
||||||
} from './tenant-persistence.service';
|
} from './tenant-persistence.service';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
AbacService,
|
AbacService,
|
||||||
AbacMode,
|
AbacMode,
|
||||||
AbacConfig,
|
AbacConfig,
|
||||||
AbacAuthResult,
|
AbacAuthResult,
|
||||||
} from './abac.service';
|
} from './abac.service';
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { base64UrlEncode, sha256 } from './dpop/jose-utilities';
|
import { base64UrlEncode, sha256 } from './dpop/jose-utilities';
|
||||||
|
|
||||||
export interface PkcePair {
|
export interface PkcePair {
|
||||||
readonly verifier: string;
|
readonly verifier: string;
|
||||||
readonly challenge: string;
|
readonly challenge: string;
|
||||||
readonly method: 'S256';
|
readonly method: 'S256';
|
||||||
}
|
}
|
||||||
|
|
||||||
const VERIFIER_BYTE_LENGTH = 32;
|
const VERIFIER_BYTE_LENGTH = 32;
|
||||||
|
|
||||||
export async function createPkcePair(): Promise<PkcePair> {
|
export async function createPkcePair(): Promise<PkcePair> {
|
||||||
const verifierBytes = new Uint8Array(VERIFIER_BYTE_LENGTH);
|
const verifierBytes = new Uint8Array(VERIFIER_BYTE_LENGTH);
|
||||||
crypto.getRandomValues(verifierBytes);
|
crypto.getRandomValues(verifierBytes);
|
||||||
|
|
||||||
const verifier = base64UrlEncode(verifierBytes);
|
const verifier = base64UrlEncode(verifierBytes);
|
||||||
const challengeBytes = await sha256(new TextEncoder().encode(verifier));
|
const challengeBytes = await sha256(new TextEncoder().encode(verifier));
|
||||||
const challenge = base64UrlEncode(challengeBytes);
|
const challenge = base64UrlEncode(challengeBytes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
verifier,
|
verifier,
|
||||||
challenge,
|
challenge,
|
||||||
method: 'S256',
|
method: 'S256',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,65 @@
|
|||||||
/**
|
/**
|
||||||
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
|
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
|
||||||
*
|
*
|
||||||
* This is a stub implementation to unblock Graph Explorer development.
|
* This is a stub implementation to unblock Graph Explorer development.
|
||||||
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
|
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
|
||||||
*
|
*
|
||||||
* @see docs/modules/platform/architecture-overview.md
|
* @see docs/modules/platform/architecture-overview.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All available StellaOps OAuth2 scopes.
|
* All available StellaOps OAuth2 scopes.
|
||||||
*/
|
*/
|
||||||
export const StellaOpsScopes = {
|
export const StellaOpsScopes = {
|
||||||
// Graph scopes
|
// Graph scopes
|
||||||
GRAPH_READ: 'graph:read',
|
GRAPH_READ: 'graph:read',
|
||||||
GRAPH_WRITE: 'graph:write',
|
GRAPH_WRITE: 'graph:write',
|
||||||
GRAPH_ADMIN: 'graph:admin',
|
GRAPH_ADMIN: 'graph:admin',
|
||||||
GRAPH_EXPORT: 'graph:export',
|
GRAPH_EXPORT: 'graph:export',
|
||||||
GRAPH_SIMULATE: 'graph:simulate',
|
GRAPH_SIMULATE: 'graph:simulate',
|
||||||
|
|
||||||
// SBOM scopes
|
// SBOM scopes
|
||||||
SBOM_READ: 'sbom:read',
|
SBOM_READ: 'sbom:read',
|
||||||
SBOM_WRITE: 'sbom:write',
|
SBOM_WRITE: 'sbom:write',
|
||||||
SBOM_ATTEST: 'sbom:attest',
|
SBOM_ATTEST: 'sbom:attest',
|
||||||
|
|
||||||
// Scanner scopes
|
// Scanner scopes
|
||||||
SCANNER_READ: 'scanner:read',
|
SCANNER_READ: 'scanner:read',
|
||||||
SCANNER_WRITE: 'scanner:write',
|
SCANNER_WRITE: 'scanner:write',
|
||||||
SCANNER_SCAN: 'scanner:scan',
|
SCANNER_SCAN: 'scanner:scan',
|
||||||
SCANNER_EXPORT: 'scanner:export',
|
SCANNER_EXPORT: 'scanner:export',
|
||||||
|
|
||||||
// Policy scopes (full Policy Studio workflow - UI-POLICY-20-003)
|
// Policy scopes (full Policy Studio workflow - UI-POLICY-20-003)
|
||||||
POLICY_READ: 'policy:read',
|
POLICY_READ: 'policy:read',
|
||||||
POLICY_WRITE: 'policy:write',
|
POLICY_WRITE: 'policy:write',
|
||||||
POLICY_EVALUATE: 'policy:evaluate',
|
POLICY_EVALUATE: 'policy:evaluate',
|
||||||
POLICY_SIMULATE: 'policy:simulate',
|
POLICY_SIMULATE: 'policy:simulate',
|
||||||
// Policy Studio authoring & review workflow
|
// Policy Studio authoring & review workflow
|
||||||
POLICY_AUTHOR: 'policy:author',
|
POLICY_AUTHOR: 'policy:author',
|
||||||
POLICY_EDIT: 'policy:edit',
|
POLICY_EDIT: 'policy:edit',
|
||||||
POLICY_REVIEW: 'policy:review',
|
POLICY_REVIEW: 'policy:review',
|
||||||
POLICY_SUBMIT: 'policy:submit',
|
POLICY_SUBMIT: 'policy:submit',
|
||||||
POLICY_APPROVE: 'policy:approve',
|
POLICY_APPROVE: 'policy:approve',
|
||||||
// Policy operations & execution
|
// Policy operations & execution
|
||||||
POLICY_OPERATE: 'policy:operate',
|
POLICY_OPERATE: 'policy:operate',
|
||||||
POLICY_ACTIVATE: 'policy:activate',
|
POLICY_ACTIVATE: 'policy:activate',
|
||||||
POLICY_RUN: 'policy:run',
|
POLICY_RUN: 'policy:run',
|
||||||
POLICY_PUBLISH: 'policy:publish', // Requires interactive auth
|
POLICY_PUBLISH: 'policy:publish', // Requires interactive auth
|
||||||
POLICY_PROMOTE: 'policy:promote', // Requires interactive auth
|
POLICY_PROMOTE: 'policy:promote', // Requires interactive auth
|
||||||
POLICY_AUDIT: 'policy:audit',
|
POLICY_AUDIT: 'policy:audit',
|
||||||
|
|
||||||
// Exception scopes
|
// Exception scopes
|
||||||
EXCEPTION_READ: 'exception:read',
|
EXCEPTION_READ: 'exception:read',
|
||||||
EXCEPTION_WRITE: 'exception:write',
|
EXCEPTION_WRITE: 'exception:write',
|
||||||
EXCEPTION_APPROVE: 'exception:approve',
|
EXCEPTION_APPROVE: 'exception:approve',
|
||||||
|
|
||||||
// Advisory scopes
|
// Advisory scopes
|
||||||
ADVISORY_READ: 'advisory:read',
|
ADVISORY_READ: 'advisory:read',
|
||||||
|
|
||||||
// VEX scopes
|
// VEX scopes
|
||||||
VEX_READ: 'vex:read',
|
VEX_READ: 'vex:read',
|
||||||
VEX_EXPORT: 'vex:export',
|
VEX_EXPORT: 'vex:export',
|
||||||
|
|
||||||
// Release scopes
|
// Release scopes
|
||||||
RELEASE_READ: 'release:read',
|
RELEASE_READ: 'release:read',
|
||||||
RELEASE_WRITE: 'release:write',
|
RELEASE_WRITE: 'release:write',
|
||||||
@@ -72,215 +72,215 @@ export const StellaOpsScopes = {
|
|||||||
// AOC scopes
|
// AOC scopes
|
||||||
AOC_READ: 'aoc:read',
|
AOC_READ: 'aoc:read',
|
||||||
AOC_VERIFY: 'aoc:verify',
|
AOC_VERIFY: 'aoc:verify',
|
||||||
|
|
||||||
// Orchestrator scopes (UI-ORCH-32-001)
|
// Orchestrator scopes (UI-ORCH-32-001)
|
||||||
ORCH_READ: 'orch:read',
|
ORCH_READ: 'orch:read',
|
||||||
ORCH_OPERATE: 'orch:operate',
|
ORCH_OPERATE: 'orch:operate',
|
||||||
ORCH_QUOTA: 'orch:quota',
|
ORCH_QUOTA: 'orch:quota',
|
||||||
ORCH_BACKFILL: 'orch:backfill',
|
ORCH_BACKFILL: 'orch:backfill',
|
||||||
|
|
||||||
// UI scopes
|
// UI scopes
|
||||||
UI_READ: 'ui.read',
|
UI_READ: 'ui.read',
|
||||||
UI_ADMIN: 'ui.admin',
|
UI_ADMIN: 'ui.admin',
|
||||||
|
|
||||||
// Admin scopes
|
// Admin scopes
|
||||||
ADMIN: 'admin',
|
ADMIN: 'admin',
|
||||||
TENANT_ADMIN: 'tenant:admin',
|
TENANT_ADMIN: 'tenant:admin',
|
||||||
|
|
||||||
// Authority admin scopes
|
// Authority admin scopes
|
||||||
AUTHORITY_TENANTS_READ: 'authority:tenants.read',
|
AUTHORITY_TENANTS_READ: 'authority:tenants.read',
|
||||||
AUTHORITY_TENANTS_WRITE: 'authority:tenants.write',
|
AUTHORITY_TENANTS_WRITE: 'authority:tenants.write',
|
||||||
AUTHORITY_USERS_READ: 'authority:users.read',
|
AUTHORITY_USERS_READ: 'authority:users.read',
|
||||||
AUTHORITY_USERS_WRITE: 'authority:users.write',
|
AUTHORITY_USERS_WRITE: 'authority:users.write',
|
||||||
AUTHORITY_ROLES_READ: 'authority:roles.read',
|
AUTHORITY_ROLES_READ: 'authority:roles.read',
|
||||||
AUTHORITY_ROLES_WRITE: 'authority:roles.write',
|
AUTHORITY_ROLES_WRITE: 'authority:roles.write',
|
||||||
AUTHORITY_CLIENTS_READ: 'authority:clients.read',
|
AUTHORITY_CLIENTS_READ: 'authority:clients.read',
|
||||||
AUTHORITY_CLIENTS_WRITE: 'authority:clients.write',
|
AUTHORITY_CLIENTS_WRITE: 'authority:clients.write',
|
||||||
AUTHORITY_TOKENS_READ: 'authority:tokens.read',
|
AUTHORITY_TOKENS_READ: 'authority:tokens.read',
|
||||||
AUTHORITY_TOKENS_REVOKE: 'authority:tokens.revoke',
|
AUTHORITY_TOKENS_REVOKE: 'authority:tokens.revoke',
|
||||||
AUTHORITY_BRANDING_READ: 'authority:branding.read',
|
AUTHORITY_BRANDING_READ: 'authority:branding.read',
|
||||||
AUTHORITY_BRANDING_WRITE: 'authority:branding.write',
|
AUTHORITY_BRANDING_WRITE: 'authority:branding.write',
|
||||||
AUTHORITY_AUDIT_READ: 'authority:audit.read',
|
AUTHORITY_AUDIT_READ: 'authority:audit.read',
|
||||||
|
|
||||||
// Scheduler scopes
|
// Scheduler scopes
|
||||||
SCHEDULER_READ: 'scheduler:read',
|
SCHEDULER_READ: 'scheduler:read',
|
||||||
SCHEDULER_OPERATE: 'scheduler:operate',
|
SCHEDULER_OPERATE: 'scheduler:operate',
|
||||||
SCHEDULER_ADMIN: 'scheduler:admin',
|
SCHEDULER_ADMIN: 'scheduler:admin',
|
||||||
|
|
||||||
// Attestor scopes
|
// Attestor scopes
|
||||||
ATTEST_CREATE: 'attest:create',
|
ATTEST_CREATE: 'attest:create',
|
||||||
ATTEST_ADMIN: 'attest:admin',
|
ATTEST_ADMIN: 'attest:admin',
|
||||||
|
|
||||||
// Signer scopes
|
// Signer scopes
|
||||||
SIGNER_READ: 'signer:read',
|
SIGNER_READ: 'signer:read',
|
||||||
SIGNER_SIGN: 'signer:sign',
|
SIGNER_SIGN: 'signer:sign',
|
||||||
SIGNER_ROTATE: 'signer:rotate',
|
SIGNER_ROTATE: 'signer:rotate',
|
||||||
SIGNER_ADMIN: 'signer:admin',
|
SIGNER_ADMIN: 'signer:admin',
|
||||||
|
|
||||||
// Zastava scopes
|
// Zastava scopes
|
||||||
ZASTAVA_READ: 'zastava:read',
|
ZASTAVA_READ: 'zastava:read',
|
||||||
ZASTAVA_TRIGGER: 'zastava:trigger',
|
ZASTAVA_TRIGGER: 'zastava:trigger',
|
||||||
ZASTAVA_ADMIN: 'zastava:admin',
|
ZASTAVA_ADMIN: 'zastava:admin',
|
||||||
|
|
||||||
// Exceptions scopes
|
// Exceptions scopes
|
||||||
EXCEPTIONS_READ: 'exceptions:read',
|
EXCEPTIONS_READ: 'exceptions:read',
|
||||||
EXCEPTIONS_WRITE: 'exceptions:write',
|
EXCEPTIONS_WRITE: 'exceptions:write',
|
||||||
|
|
||||||
// Findings scope
|
// Findings scope
|
||||||
FINDINGS_READ: 'findings:read',
|
FINDINGS_READ: 'findings:read',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
|
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope groupings for common use cases.
|
* Scope groupings for common use cases.
|
||||||
*/
|
*/
|
||||||
export const ScopeGroups = {
|
export const ScopeGroups = {
|
||||||
GRAPH_VIEWER: [
|
GRAPH_VIEWER: [
|
||||||
StellaOpsScopes.GRAPH_READ,
|
StellaOpsScopes.GRAPH_READ,
|
||||||
StellaOpsScopes.SBOM_READ,
|
StellaOpsScopes.SBOM_READ,
|
||||||
StellaOpsScopes.POLICY_READ,
|
StellaOpsScopes.POLICY_READ,
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
GRAPH_EDITOR: [
|
GRAPH_EDITOR: [
|
||||||
StellaOpsScopes.GRAPH_READ,
|
StellaOpsScopes.GRAPH_READ,
|
||||||
StellaOpsScopes.GRAPH_WRITE,
|
StellaOpsScopes.GRAPH_WRITE,
|
||||||
StellaOpsScopes.SBOM_READ,
|
StellaOpsScopes.SBOM_READ,
|
||||||
StellaOpsScopes.SBOM_WRITE,
|
StellaOpsScopes.SBOM_WRITE,
|
||||||
StellaOpsScopes.POLICY_READ,
|
StellaOpsScopes.POLICY_READ,
|
||||||
StellaOpsScopes.POLICY_EVALUATE,
|
StellaOpsScopes.POLICY_EVALUATE,
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
GRAPH_ADMIN: [
|
GRAPH_ADMIN: [
|
||||||
StellaOpsScopes.GRAPH_READ,
|
StellaOpsScopes.GRAPH_READ,
|
||||||
StellaOpsScopes.GRAPH_WRITE,
|
StellaOpsScopes.GRAPH_WRITE,
|
||||||
StellaOpsScopes.GRAPH_ADMIN,
|
StellaOpsScopes.GRAPH_ADMIN,
|
||||||
StellaOpsScopes.GRAPH_EXPORT,
|
StellaOpsScopes.GRAPH_EXPORT,
|
||||||
StellaOpsScopes.GRAPH_SIMULATE,
|
StellaOpsScopes.GRAPH_SIMULATE,
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
RELEASE_MANAGER: [
|
RELEASE_MANAGER: [
|
||||||
StellaOpsScopes.RELEASE_READ,
|
StellaOpsScopes.RELEASE_READ,
|
||||||
StellaOpsScopes.RELEASE_WRITE,
|
StellaOpsScopes.RELEASE_WRITE,
|
||||||
StellaOpsScopes.RELEASE_PUBLISH,
|
StellaOpsScopes.RELEASE_PUBLISH,
|
||||||
StellaOpsScopes.POLICY_READ,
|
StellaOpsScopes.POLICY_READ,
|
||||||
StellaOpsScopes.POLICY_EVALUATE,
|
StellaOpsScopes.POLICY_EVALUATE,
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
SECURITY_ADMIN: [
|
SECURITY_ADMIN: [
|
||||||
StellaOpsScopes.EXCEPTION_READ,
|
StellaOpsScopes.EXCEPTION_READ,
|
||||||
StellaOpsScopes.EXCEPTION_WRITE,
|
StellaOpsScopes.EXCEPTION_WRITE,
|
||||||
StellaOpsScopes.EXCEPTION_APPROVE,
|
StellaOpsScopes.EXCEPTION_APPROVE,
|
||||||
StellaOpsScopes.RELEASE_BYPASS,
|
StellaOpsScopes.RELEASE_BYPASS,
|
||||||
StellaOpsScopes.POLICY_READ,
|
StellaOpsScopes.POLICY_READ,
|
||||||
StellaOpsScopes.POLICY_WRITE,
|
StellaOpsScopes.POLICY_WRITE,
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
// Orchestrator scope groups (UI-ORCH-32-001)
|
// Orchestrator scope groups (UI-ORCH-32-001)
|
||||||
ORCH_VIEWER: [
|
ORCH_VIEWER: [
|
||||||
StellaOpsScopes.ORCH_READ,
|
StellaOpsScopes.ORCH_READ,
|
||||||
StellaOpsScopes.UI_READ,
|
StellaOpsScopes.UI_READ,
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
ORCH_OPERATOR: [
|
ORCH_OPERATOR: [
|
||||||
StellaOpsScopes.ORCH_READ,
|
StellaOpsScopes.ORCH_READ,
|
||||||
StellaOpsScopes.ORCH_OPERATE,
|
StellaOpsScopes.ORCH_OPERATE,
|
||||||
StellaOpsScopes.UI_READ,
|
StellaOpsScopes.UI_READ,
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
ORCH_ADMIN: [
|
ORCH_ADMIN: [
|
||||||
StellaOpsScopes.ORCH_READ,
|
StellaOpsScopes.ORCH_READ,
|
||||||
StellaOpsScopes.ORCH_OPERATE,
|
StellaOpsScopes.ORCH_OPERATE,
|
||||||
StellaOpsScopes.ORCH_QUOTA,
|
StellaOpsScopes.ORCH_QUOTA,
|
||||||
StellaOpsScopes.ORCH_BACKFILL,
|
StellaOpsScopes.ORCH_BACKFILL,
|
||||||
StellaOpsScopes.UI_READ,
|
StellaOpsScopes.UI_READ,
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
// Policy Studio scope groups (UI-POLICY-20-003)
|
// Policy Studio scope groups (UI-POLICY-20-003)
|
||||||
POLICY_VIEWER: [
|
POLICY_VIEWER: [
|
||||||
StellaOpsScopes.POLICY_READ,
|
StellaOpsScopes.POLICY_READ,
|
||||||
StellaOpsScopes.UI_READ,
|
StellaOpsScopes.UI_READ,
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
POLICY_AUTHOR: [
|
POLICY_AUTHOR: [
|
||||||
StellaOpsScopes.POLICY_READ,
|
StellaOpsScopes.POLICY_READ,
|
||||||
StellaOpsScopes.POLICY_AUTHOR,
|
StellaOpsScopes.POLICY_AUTHOR,
|
||||||
StellaOpsScopes.POLICY_SIMULATE,
|
StellaOpsScopes.POLICY_SIMULATE,
|
||||||
StellaOpsScopes.UI_READ,
|
StellaOpsScopes.UI_READ,
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
POLICY_REVIEWER: [
|
POLICY_REVIEWER: [
|
||||||
StellaOpsScopes.POLICY_READ,
|
StellaOpsScopes.POLICY_READ,
|
||||||
StellaOpsScopes.POLICY_REVIEW,
|
StellaOpsScopes.POLICY_REVIEW,
|
||||||
StellaOpsScopes.POLICY_SIMULATE,
|
StellaOpsScopes.POLICY_SIMULATE,
|
||||||
StellaOpsScopes.UI_READ,
|
StellaOpsScopes.UI_READ,
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
POLICY_APPROVER: [
|
POLICY_APPROVER: [
|
||||||
StellaOpsScopes.POLICY_READ,
|
StellaOpsScopes.POLICY_READ,
|
||||||
StellaOpsScopes.POLICY_REVIEW,
|
StellaOpsScopes.POLICY_REVIEW,
|
||||||
StellaOpsScopes.POLICY_APPROVE,
|
StellaOpsScopes.POLICY_APPROVE,
|
||||||
StellaOpsScopes.POLICY_SIMULATE,
|
StellaOpsScopes.POLICY_SIMULATE,
|
||||||
StellaOpsScopes.UI_READ,
|
StellaOpsScopes.UI_READ,
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
POLICY_OPERATOR: [
|
POLICY_OPERATOR: [
|
||||||
StellaOpsScopes.POLICY_READ,
|
StellaOpsScopes.POLICY_READ,
|
||||||
StellaOpsScopes.POLICY_OPERATE,
|
StellaOpsScopes.POLICY_OPERATE,
|
||||||
StellaOpsScopes.POLICY_SIMULATE,
|
StellaOpsScopes.POLICY_SIMULATE,
|
||||||
StellaOpsScopes.UI_READ,
|
StellaOpsScopes.UI_READ,
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
POLICY_ADMIN: [
|
POLICY_ADMIN: [
|
||||||
StellaOpsScopes.POLICY_READ,
|
StellaOpsScopes.POLICY_READ,
|
||||||
StellaOpsScopes.POLICY_AUTHOR,
|
StellaOpsScopes.POLICY_AUTHOR,
|
||||||
StellaOpsScopes.POLICY_REVIEW,
|
StellaOpsScopes.POLICY_REVIEW,
|
||||||
StellaOpsScopes.POLICY_APPROVE,
|
StellaOpsScopes.POLICY_APPROVE,
|
||||||
StellaOpsScopes.POLICY_OPERATE,
|
StellaOpsScopes.POLICY_OPERATE,
|
||||||
StellaOpsScopes.POLICY_AUDIT,
|
StellaOpsScopes.POLICY_AUDIT,
|
||||||
StellaOpsScopes.POLICY_SIMULATE,
|
StellaOpsScopes.POLICY_SIMULATE,
|
||||||
StellaOpsScopes.UI_READ,
|
StellaOpsScopes.UI_READ,
|
||||||
] as const,
|
] as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Human-readable labels for scopes.
|
* Human-readable labels for scopes.
|
||||||
*/
|
*/
|
||||||
export const ScopeLabels: Record<StellaOpsScope, string> = {
|
export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||||
'graph:read': 'View Graph',
|
'graph:read': 'View Graph',
|
||||||
'graph:write': 'Edit Graph',
|
'graph:write': 'Edit Graph',
|
||||||
'graph:admin': 'Administer Graph',
|
'graph:admin': 'Administer Graph',
|
||||||
'graph:export': 'Export Graph Data',
|
'graph:export': 'Export Graph Data',
|
||||||
'graph:simulate': 'Run Graph Simulations',
|
'graph:simulate': 'Run Graph Simulations',
|
||||||
'sbom:read': 'View SBOMs',
|
'sbom:read': 'View SBOMs',
|
||||||
'sbom:write': 'Create/Edit SBOMs',
|
'sbom:write': 'Create/Edit SBOMs',
|
||||||
'sbom:attest': 'Attest SBOMs',
|
'sbom:attest': 'Attest SBOMs',
|
||||||
'scanner:read': 'View Scan Results',
|
'scanner:read': 'View Scan Results',
|
||||||
'scanner:write': 'Configure Scanner',
|
'scanner:write': 'Configure Scanner',
|
||||||
'scanner:scan': 'Trigger Scans',
|
'scanner:scan': 'Trigger Scans',
|
||||||
'scanner:export': 'Export Scan Results',
|
'scanner:export': 'Export Scan Results',
|
||||||
'policy:read': 'View Policies',
|
'policy:read': 'View Policies',
|
||||||
'policy:write': 'Edit Policies',
|
'policy:write': 'Edit Policies',
|
||||||
'policy:evaluate': 'Evaluate Policies',
|
'policy:evaluate': 'Evaluate Policies',
|
||||||
'policy:simulate': 'Simulate Policy Changes',
|
'policy:simulate': 'Simulate Policy Changes',
|
||||||
// Policy Studio workflow scopes (UI-POLICY-20-003)
|
// Policy Studio workflow scopes (UI-POLICY-20-003)
|
||||||
'policy:author': 'Author Policy Drafts',
|
'policy:author': 'Author Policy Drafts',
|
||||||
'policy:edit': 'Edit Policy Configuration',
|
'policy:edit': 'Edit Policy Configuration',
|
||||||
'policy:review': 'Review Policy Drafts',
|
'policy:review': 'Review Policy Drafts',
|
||||||
'policy:submit': 'Submit Policies for Review',
|
'policy:submit': 'Submit Policies for Review',
|
||||||
'policy:approve': 'Approve/Reject Policies',
|
'policy:approve': 'Approve/Reject Policies',
|
||||||
'policy:operate': 'Operate Policy Promotions',
|
'policy:operate': 'Operate Policy Promotions',
|
||||||
'policy:activate': 'Activate Policies',
|
'policy:activate': 'Activate Policies',
|
||||||
'policy:run': 'Trigger Policy Runs',
|
'policy:run': 'Trigger Policy Runs',
|
||||||
'policy:publish': 'Publish Policy Versions',
|
'policy:publish': 'Publish Policy Versions',
|
||||||
'policy:promote': 'Promote Between Environments',
|
'policy:promote': 'Promote Between Environments',
|
||||||
'policy:audit': 'Audit Policy Activity',
|
'policy:audit': 'Audit Policy Activity',
|
||||||
'exception:read': 'View Exceptions',
|
'exception:read': 'View Exceptions',
|
||||||
'exception:write': 'Create Exceptions',
|
'exception:write': 'Create Exceptions',
|
||||||
'exception:approve': 'Approve Exceptions',
|
'exception:approve': 'Approve Exceptions',
|
||||||
'advisory:read': 'View Advisories',
|
'advisory:read': 'View Advisories',
|
||||||
'vex:read': 'View VEX Evidence',
|
'vex:read': 'View VEX Evidence',
|
||||||
'vex:export': 'Export VEX Evidence',
|
'vex:export': 'Export VEX Evidence',
|
||||||
'release:read': 'View Releases',
|
'release:read': 'View Releases',
|
||||||
'release:write': 'Create Releases',
|
'release:write': 'Create Releases',
|
||||||
'release:publish': 'Publish Releases',
|
'release:publish': 'Publish Releases',
|
||||||
@@ -288,82 +288,82 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
|
|||||||
'analytics.read': 'View Analytics',
|
'analytics.read': 'View Analytics',
|
||||||
'aoc:read': 'View AOC Status',
|
'aoc:read': 'View AOC Status',
|
||||||
'aoc:verify': 'Trigger AOC Verification',
|
'aoc:verify': 'Trigger AOC Verification',
|
||||||
// Orchestrator scope labels (UI-ORCH-32-001)
|
// Orchestrator scope labels (UI-ORCH-32-001)
|
||||||
'orch:read': 'View Orchestrator Jobs',
|
'orch:read': 'View Orchestrator Jobs',
|
||||||
'orch:operate': 'Operate Orchestrator',
|
'orch:operate': 'Operate Orchestrator',
|
||||||
'orch:quota': 'Manage Orchestrator Quotas',
|
'orch:quota': 'Manage Orchestrator Quotas',
|
||||||
'orch:backfill': 'Initiate Backfill Runs',
|
'orch:backfill': 'Initiate Backfill Runs',
|
||||||
// UI scope labels
|
// UI scope labels
|
||||||
'ui.read': 'Console Access',
|
'ui.read': 'Console Access',
|
||||||
'ui.admin': 'Console Admin Access',
|
'ui.admin': 'Console Admin Access',
|
||||||
// Admin scope labels
|
// Admin scope labels
|
||||||
'admin': 'System Administrator',
|
'admin': 'System Administrator',
|
||||||
'tenant:admin': 'Tenant Administrator',
|
'tenant:admin': 'Tenant Administrator',
|
||||||
// Authority admin scope labels
|
// Authority admin scope labels
|
||||||
'authority:tenants.read': 'View Tenants',
|
'authority:tenants.read': 'View Tenants',
|
||||||
'authority:tenants.write': 'Manage Tenants',
|
'authority:tenants.write': 'Manage Tenants',
|
||||||
'authority:users.read': 'View Users',
|
'authority:users.read': 'View Users',
|
||||||
'authority:users.write': 'Manage Users',
|
'authority:users.write': 'Manage Users',
|
||||||
'authority:roles.read': 'View Roles',
|
'authority:roles.read': 'View Roles',
|
||||||
'authority:roles.write': 'Manage Roles',
|
'authority:roles.write': 'Manage Roles',
|
||||||
'authority:clients.read': 'View Clients',
|
'authority:clients.read': 'View Clients',
|
||||||
'authority:clients.write': 'Manage Clients',
|
'authority:clients.write': 'Manage Clients',
|
||||||
'authority:tokens.read': 'View Tokens',
|
'authority:tokens.read': 'View Tokens',
|
||||||
'authority:tokens.revoke': 'Revoke Tokens',
|
'authority:tokens.revoke': 'Revoke Tokens',
|
||||||
'authority:branding.read': 'View Branding',
|
'authority:branding.read': 'View Branding',
|
||||||
'authority:branding.write': 'Manage Branding',
|
'authority:branding.write': 'Manage Branding',
|
||||||
'authority:audit.read': 'View Audit Log',
|
'authority:audit.read': 'View Audit Log',
|
||||||
// Scheduler scope labels
|
// Scheduler scope labels
|
||||||
'scheduler:read': 'View Scheduler Jobs',
|
'scheduler:read': 'View Scheduler Jobs',
|
||||||
'scheduler:operate': 'Operate Scheduler',
|
'scheduler:operate': 'Operate Scheduler',
|
||||||
'scheduler:admin': 'Administer Scheduler',
|
'scheduler:admin': 'Administer Scheduler',
|
||||||
// Attestor scope labels
|
// Attestor scope labels
|
||||||
'attest:create': 'Create Attestations',
|
'attest:create': 'Create Attestations',
|
||||||
'attest:admin': 'Administer Attestor',
|
'attest:admin': 'Administer Attestor',
|
||||||
// Signer scope labels
|
// Signer scope labels
|
||||||
'signer:read': 'View Signer Configuration',
|
'signer:read': 'View Signer Configuration',
|
||||||
'signer:sign': 'Create Signatures',
|
'signer:sign': 'Create Signatures',
|
||||||
'signer:rotate': 'Rotate Signing Keys',
|
'signer:rotate': 'Rotate Signing Keys',
|
||||||
'signer:admin': 'Administer Signer',
|
'signer:admin': 'Administer Signer',
|
||||||
// Zastava scope labels
|
// Zastava scope labels
|
||||||
'zastava:read': 'View Zastava State',
|
'zastava:read': 'View Zastava State',
|
||||||
'zastava:trigger': 'Trigger Zastava Processing',
|
'zastava:trigger': 'Trigger Zastava Processing',
|
||||||
'zastava:admin': 'Administer Zastava',
|
'zastava:admin': 'Administer Zastava',
|
||||||
// Exception scope labels
|
// Exception scope labels
|
||||||
'exceptions:read': 'View Exceptions',
|
'exceptions:read': 'View Exceptions',
|
||||||
'exceptions:write': 'Create Exceptions',
|
'exceptions:write': 'Create Exceptions',
|
||||||
// Findings scope label
|
// Findings scope label
|
||||||
'findings:read': 'View Policy Findings',
|
'findings:read': 'View Policy Findings',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a set of scopes includes a required scope.
|
* Check if a set of scopes includes a required scope.
|
||||||
*/
|
*/
|
||||||
export function hasScope(
|
export function hasScope(
|
||||||
userScopes: readonly string[],
|
userScopes: readonly string[],
|
||||||
requiredScope: StellaOpsScope
|
requiredScope: StellaOpsScope
|
||||||
): boolean {
|
): boolean {
|
||||||
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
|
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a set of scopes includes all required scopes.
|
* Check if a set of scopes includes all required scopes.
|
||||||
*/
|
*/
|
||||||
export function hasAllScopes(
|
export function hasAllScopes(
|
||||||
userScopes: readonly string[],
|
userScopes: readonly string[],
|
||||||
requiredScopes: readonly StellaOpsScope[]
|
requiredScopes: readonly StellaOpsScope[]
|
||||||
): boolean {
|
): boolean {
|
||||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||||
return requiredScopes.every((scope) => userScopes.includes(scope));
|
return requiredScopes.every((scope) => userScopes.includes(scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a set of scopes includes any of the required scopes.
|
* Check if a set of scopes includes any of the required scopes.
|
||||||
*/
|
*/
|
||||||
export function hasAnyScope(
|
export function hasAnyScope(
|
||||||
userScopes: readonly string[],
|
userScopes: readonly string[],
|
||||||
requiredScopes: readonly StellaOpsScope[]
|
requiredScopes: readonly StellaOpsScope[]
|
||||||
): boolean {
|
): boolean {
|
||||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||||
return requiredScopes.some((scope) => userScopes.includes(scope));
|
return requiredScopes.some((scope) => userScopes.includes(scope));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import { InjectionToken } from '@angular/core';
|
import { InjectionToken } from '@angular/core';
|
||||||
|
|
||||||
export type DPoPAlgorithm = 'ES256' | 'ES384' | 'EdDSA';
|
export type DPoPAlgorithm = 'ES256' | 'ES384' | 'EdDSA';
|
||||||
|
|
||||||
export interface AuthorityConfig {
|
export interface AuthorityConfig {
|
||||||
readonly issuer: string;
|
readonly issuer: string;
|
||||||
readonly clientId: string;
|
readonly clientId: string;
|
||||||
readonly authorizeEndpoint: string;
|
readonly authorizeEndpoint: string;
|
||||||
readonly tokenEndpoint: string;
|
readonly tokenEndpoint: string;
|
||||||
readonly logoutEndpoint?: string;
|
readonly logoutEndpoint?: string;
|
||||||
readonly redirectUri: string;
|
readonly redirectUri: string;
|
||||||
readonly postLogoutRedirectUri?: string;
|
readonly postLogoutRedirectUri?: string;
|
||||||
readonly scope: string;
|
readonly scope: string;
|
||||||
readonly audience: string;
|
readonly audience: string;
|
||||||
/**
|
/**
|
||||||
* Preferred algorithms for DPoP proofs, in order of preference.
|
* Preferred algorithms for DPoP proofs, in order of preference.
|
||||||
* Defaults to ES256 if omitted.
|
* Defaults to ES256 if omitted.
|
||||||
*/
|
*/
|
||||||
readonly dpopAlgorithms?: readonly DPoPAlgorithm[];
|
readonly dpopAlgorithms?: readonly DPoPAlgorithm[];
|
||||||
/**
|
/**
|
||||||
* Seconds of leeway before access token expiry that should trigger a proactive refresh.
|
* Seconds of leeway before access token expiry that should trigger a proactive refresh.
|
||||||
* Defaults to 60.
|
* Defaults to 60.
|
||||||
*/
|
*/
|
||||||
readonly refreshLeewaySeconds?: number;
|
readonly refreshLeewaySeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiBaseUrlConfig {
|
export interface ApiBaseUrlConfig {
|
||||||
/**
|
/**
|
||||||
* Optional API gateway base URL for cross-cutting endpoints.
|
* Optional API gateway base URL for cross-cutting endpoints.
|
||||||
@@ -38,11 +38,11 @@ export interface ApiBaseUrlConfig {
|
|||||||
readonly concelier: string;
|
readonly concelier: string;
|
||||||
readonly excitor?: string;
|
readonly excitor?: string;
|
||||||
readonly attestor: string;
|
readonly attestor: string;
|
||||||
readonly authority: string;
|
readonly authority: string;
|
||||||
readonly notify?: string;
|
readonly notify?: string;
|
||||||
readonly scheduler?: string;
|
readonly scheduler?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelemetryConfig {
|
export interface TelemetryConfig {
|
||||||
readonly otlpEndpoint?: string;
|
readonly otlpEndpoint?: string;
|
||||||
readonly sampleRate?: number;
|
readonly sampleRate?: number;
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
APP_CONFIG,
|
APP_CONFIG,
|
||||||
AppConfig,
|
AppConfig,
|
||||||
AuthorityConfig,
|
AuthorityConfig,
|
||||||
DPoPAlgorithm,
|
DPoPAlgorithm,
|
||||||
} from './app-config.model';
|
} from './app-config.model';
|
||||||
|
|
||||||
const DEFAULT_CONFIG_URL = '/config.json';
|
const DEFAULT_CONFIG_URL = '/config.json';
|
||||||
const DEFAULT_DPOP_ALG: DPoPAlgorithm = 'ES256';
|
const DEFAULT_DPOP_ALG: DPoPAlgorithm = 'ES256';
|
||||||
const DEFAULT_REFRESH_LEEWAY_SECONDS = 60;
|
const DEFAULT_REFRESH_LEEWAY_SECONDS = 60;
|
||||||
@@ -41,53 +41,53 @@ export class AppConfigService {
|
|||||||
// that themselves depend on AppConfigService.
|
// that themselves depend on AppConfigService.
|
||||||
this.http = new HttpClient(httpBackend);
|
this.http = new HttpClient(httpBackend);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads application configuration either from the injected static value or via HTTP fetch.
|
* Loads application configuration either from the injected static value or via HTTP fetch.
|
||||||
* Must be called during application bootstrap (see APP_INITIALIZER wiring).
|
* Must be called during application bootstrap (see APP_INITIALIZER wiring).
|
||||||
*/
|
*/
|
||||||
async load(configUrl: string = DEFAULT_CONFIG_URL): Promise<void> {
|
async load(configUrl: string = DEFAULT_CONFIG_URL): Promise<void> {
|
||||||
if (this.configSignal()) {
|
if (this.configSignal()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = this.staticConfig ?? (await this.fetchConfig(configUrl));
|
const config = this.staticConfig ?? (await this.fetchConfig(configUrl));
|
||||||
this.configSignal.set(this.normalizeConfig(config));
|
this.configSignal.set(this.normalizeConfig(config));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows tests to short-circuit configuration loading.
|
* Allows tests to short-circuit configuration loading.
|
||||||
*/
|
*/
|
||||||
setConfigForTesting(config: AppConfig): void {
|
setConfigForTesting(config: AppConfig): void {
|
||||||
this.configSignal.set(this.normalizeConfig(config));
|
this.configSignal.set(this.normalizeConfig(config));
|
||||||
}
|
}
|
||||||
|
|
||||||
get config(): AppConfig {
|
get config(): AppConfig {
|
||||||
const current = this.configSignal();
|
const current = this.configSignal();
|
||||||
if (!current) {
|
if (!current) {
|
||||||
throw new Error('App configuration has not been loaded yet.');
|
throw new Error('App configuration has not been loaded yet.');
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
get authority(): AuthorityConfig {
|
get authority(): AuthorityConfig {
|
||||||
const authority = this.authoritySignal();
|
const authority = this.authoritySignal();
|
||||||
if (!authority) {
|
if (!authority) {
|
||||||
throw new Error('Authority configuration has not been loaded yet.');
|
throw new Error('Authority configuration has not been loaded yet.');
|
||||||
}
|
}
|
||||||
return authority;
|
return authority;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchConfig(configUrl: string): Promise<AppConfig> {
|
private async fetchConfig(configUrl: string): Promise<AppConfig> {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.http.get<AppConfig>(configUrl, {
|
this.http.get<AppConfig>(configUrl, {
|
||||||
headers: { 'Cache-Control': 'no-cache' },
|
headers: { 'Cache-Control': 'no-cache' },
|
||||||
withCredentials: false,
|
withCredentials: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeConfig(config: AppConfig): AppConfig {
|
private normalizeConfig(config: AppConfig): AppConfig {
|
||||||
const authority = {
|
const authority = {
|
||||||
...config.authority,
|
...config.authority,
|
||||||
|
|||||||
@@ -1,139 +1,139 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AUTHORITY_CONSOLE_API,
|
AUTHORITY_CONSOLE_API,
|
||||||
AuthorityConsoleApi,
|
AuthorityConsoleApi,
|
||||||
TenantCatalogResponseDto,
|
TenantCatalogResponseDto,
|
||||||
} from '../api/authority-console.client';
|
} from '../api/authority-console.client';
|
||||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||||
import { ConsoleSessionService } from './console-session.service';
|
import { ConsoleSessionService } from './console-session.service';
|
||||||
import { ConsoleSessionStore } from './console-session.store';
|
import { ConsoleSessionStore } from './console-session.store';
|
||||||
|
|
||||||
class MockConsoleApi implements AuthorityConsoleApi {
|
class MockConsoleApi implements AuthorityConsoleApi {
|
||||||
private createTenantResponse(): TenantCatalogResponseDto {
|
private createTenantResponse(): TenantCatalogResponseDto {
|
||||||
return {
|
return {
|
||||||
tenants: [
|
tenants: [
|
||||||
{
|
{
|
||||||
id: 'tenant-default',
|
id: 'tenant-default',
|
||||||
displayName: 'Tenant Default',
|
displayName: 'Tenant Default',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
isolationMode: 'shared',
|
isolationMode: 'shared',
|
||||||
defaultRoles: ['role.console'],
|
defaultRoles: ['role.console'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
listTenants() {
|
listTenants() {
|
||||||
return of(this.createTenantResponse());
|
return of(this.createTenantResponse());
|
||||||
}
|
}
|
||||||
|
|
||||||
getProfile() {
|
getProfile() {
|
||||||
return of({
|
return of({
|
||||||
subjectId: 'user-1',
|
subjectId: 'user-1',
|
||||||
username: 'user@example.com',
|
username: 'user@example.com',
|
||||||
displayName: 'Console User',
|
displayName: 'Console User',
|
||||||
tenant: 'tenant-default',
|
tenant: 'tenant-default',
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
roles: ['role.console'],
|
roles: ['role.console'],
|
||||||
scopes: ['ui.read'],
|
scopes: ['ui.read'],
|
||||||
audiences: ['console'],
|
audiences: ['console'],
|
||||||
authenticationMethods: ['pwd'],
|
authenticationMethods: ['pwd'],
|
||||||
issuedAt: '2025-10-31T12:00:00Z',
|
issuedAt: '2025-10-31T12:00:00Z',
|
||||||
authenticationTime: '2025-10-31T12:00:00Z',
|
authenticationTime: '2025-10-31T12:00:00Z',
|
||||||
expiresAt: '2025-10-31T12:10:00Z',
|
expiresAt: '2025-10-31T12:10:00Z',
|
||||||
freshAuth: true,
|
freshAuth: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
introspectToken() {
|
introspectToken() {
|
||||||
return of({
|
return of({
|
||||||
active: true,
|
active: true,
|
||||||
tenant: 'tenant-default',
|
tenant: 'tenant-default',
|
||||||
subject: 'user-1',
|
subject: 'user-1',
|
||||||
clientId: 'console-web',
|
clientId: 'console-web',
|
||||||
tokenId: 'token-1',
|
tokenId: 'token-1',
|
||||||
scopes: ['ui.read'],
|
scopes: ['ui.read'],
|
||||||
audiences: ['console'],
|
audiences: ['console'],
|
||||||
issuedAt: '2025-10-31T12:00:00Z',
|
issuedAt: '2025-10-31T12:00:00Z',
|
||||||
authenticationTime: '2025-10-31T12:00:00Z',
|
authenticationTime: '2025-10-31T12:00:00Z',
|
||||||
expiresAt: '2025-10-31T12:10:00Z',
|
expiresAt: '2025-10-31T12:10:00Z',
|
||||||
freshAuth: true,
|
freshAuth: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockAuthSessionStore {
|
class MockAuthSessionStore {
|
||||||
private tenantIdValue: string | null = 'tenant-default';
|
private tenantIdValue: string | null = 'tenant-default';
|
||||||
private readonly sessionValue = {
|
private readonly sessionValue = {
|
||||||
tenantId: 'tenant-default',
|
tenantId: 'tenant-default',
|
||||||
scopes: ['ui.read'],
|
scopes: ['ui.read'],
|
||||||
audiences: ['console'],
|
audiences: ['console'],
|
||||||
authenticationTimeEpochMs: Date.parse('2025-10-31T12:00:00Z'),
|
authenticationTimeEpochMs: Date.parse('2025-10-31T12:00:00Z'),
|
||||||
freshAuthActive: true,
|
freshAuthActive: true,
|
||||||
freshAuthExpiresAtEpochMs: Date.parse('2025-10-31T12:05:00Z'),
|
freshAuthExpiresAtEpochMs: Date.parse('2025-10-31T12:05:00Z'),
|
||||||
};
|
};
|
||||||
|
|
||||||
session = () => this.sessionValue as any;
|
session = () => this.sessionValue as any;
|
||||||
|
|
||||||
getActiveTenantId(): string | null {
|
getActiveTenantId(): string | null {
|
||||||
return this.tenantIdValue;
|
return this.tenantIdValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTenantId(tenantId: string | null): void {
|
setTenantId(tenantId: string | null): void {
|
||||||
this.tenantIdValue = tenantId;
|
this.tenantIdValue = tenantId;
|
||||||
this.sessionValue.tenantId = tenantId ?? 'tenant-default';
|
this.sessionValue.tenantId = tenantId ?? 'tenant-default';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ConsoleSessionService', () => {
|
describe('ConsoleSessionService', () => {
|
||||||
let service: ConsoleSessionService;
|
let service: ConsoleSessionService;
|
||||||
let store: ConsoleSessionStore;
|
let store: ConsoleSessionStore;
|
||||||
let authStore: MockAuthSessionStore;
|
let authStore: MockAuthSessionStore;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
ConsoleSessionStore,
|
ConsoleSessionStore,
|
||||||
ConsoleSessionService,
|
ConsoleSessionService,
|
||||||
{ provide: AUTHORITY_CONSOLE_API, useClass: MockConsoleApi },
|
{ provide: AUTHORITY_CONSOLE_API, useClass: MockConsoleApi },
|
||||||
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
|
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
service = TestBed.inject(ConsoleSessionService);
|
service = TestBed.inject(ConsoleSessionService);
|
||||||
store = TestBed.inject(ConsoleSessionStore);
|
store = TestBed.inject(ConsoleSessionStore);
|
||||||
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
|
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads console context for active tenant', async () => {
|
it('loads console context for active tenant', async () => {
|
||||||
await service.loadConsoleContext();
|
await service.loadConsoleContext();
|
||||||
|
|
||||||
expect(store.tenants().length).toBe(1);
|
expect(store.tenants().length).toBe(1);
|
||||||
expect(store.selectedTenantId()).toBe('tenant-default');
|
expect(store.selectedTenantId()).toBe('tenant-default');
|
||||||
expect(store.profile()?.displayName).toBe('Console User');
|
expect(store.profile()?.displayName).toBe('Console User');
|
||||||
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears store when no tenant available', async () => {
|
it('clears store when no tenant available', async () => {
|
||||||
authStore.setTenantId(null);
|
authStore.setTenantId(null);
|
||||||
store.setTenants(
|
store.setTenants(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: 'existing',
|
id: 'existing',
|
||||||
displayName: 'Existing',
|
displayName: 'Existing',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
isolationMode: 'shared',
|
isolationMode: 'shared',
|
||||||
defaultRoles: [],
|
defaultRoles: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'existing'
|
'existing'
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.loadConsoleContext();
|
await service.loadConsoleContext();
|
||||||
|
|
||||||
expect(store.tenants().length).toBe(0);
|
expect(store.tenants().length).toBe(0);
|
||||||
expect(store.selectedTenantId()).toBeNull();
|
expect(store.selectedTenantId()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,161 +1,161 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AUTHORITY_CONSOLE_API,
|
AUTHORITY_CONSOLE_API,
|
||||||
AuthorityConsoleApi,
|
AuthorityConsoleApi,
|
||||||
AuthorityTenantViewDto,
|
AuthorityTenantViewDto,
|
||||||
ConsoleProfileDto,
|
ConsoleProfileDto,
|
||||||
ConsoleTokenIntrospectionDto,
|
ConsoleTokenIntrospectionDto,
|
||||||
} from '../api/authority-console.client';
|
} from '../api/authority-console.client';
|
||||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||||
import {
|
import {
|
||||||
ConsoleProfile,
|
ConsoleProfile,
|
||||||
ConsoleSessionStore,
|
ConsoleSessionStore,
|
||||||
ConsoleTenant,
|
ConsoleTenant,
|
||||||
ConsoleTokenInfo,
|
ConsoleTokenInfo,
|
||||||
} from './console-session.store';
|
} from './console-session.store';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ConsoleSessionService {
|
export class ConsoleSessionService {
|
||||||
private readonly api = inject<AuthorityConsoleApi>(AUTHORITY_CONSOLE_API);
|
private readonly api = inject<AuthorityConsoleApi>(AUTHORITY_CONSOLE_API);
|
||||||
private readonly store = inject(ConsoleSessionStore);
|
private readonly store = inject(ConsoleSessionStore);
|
||||||
private readonly authSession = inject(AuthSessionStore);
|
private readonly authSession = inject(AuthSessionStore);
|
||||||
|
|
||||||
async loadConsoleContext(tenantId?: string | null): Promise<void> {
|
async loadConsoleContext(tenantId?: string | null): Promise<void> {
|
||||||
const activeTenant =
|
const activeTenant =
|
||||||
(tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
(tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||||
|
|
||||||
if (!activeTenant) {
|
if (!activeTenant) {
|
||||||
this.store.clear();
|
this.store.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.setSelectedTenant(activeTenant);
|
this.store.setSelectedTenant(activeTenant);
|
||||||
this.store.setLoading(true);
|
this.store.setLoading(true);
|
||||||
this.store.setError(null);
|
this.store.setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tenantResponse = await firstValueFrom(
|
const tenantResponse = await firstValueFrom(
|
||||||
this.api.listTenants(activeTenant)
|
this.api.listTenants(activeTenant)
|
||||||
);
|
);
|
||||||
const tenants = (tenantResponse.tenants ?? []).map((tenant) =>
|
const tenants = (tenantResponse.tenants ?? []).map((tenant) =>
|
||||||
this.mapTenant(tenant)
|
this.mapTenant(tenant)
|
||||||
);
|
);
|
||||||
|
|
||||||
const [profileDto, tokenDto] = await Promise.all([
|
const [profileDto, tokenDto] = await Promise.all([
|
||||||
firstValueFrom(this.api.getProfile(activeTenant)),
|
firstValueFrom(this.api.getProfile(activeTenant)),
|
||||||
firstValueFrom(this.api.introspectToken(activeTenant)),
|
firstValueFrom(this.api.introspectToken(activeTenant)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const profile = this.mapProfile(profileDto);
|
const profile = this.mapProfile(profileDto);
|
||||||
const tokenInfo = this.mapTokenInfo(tokenDto);
|
const tokenInfo = this.mapTokenInfo(tokenDto);
|
||||||
|
|
||||||
this.store.setContext({
|
this.store.setContext({
|
||||||
tenants,
|
tenants,
|
||||||
profile,
|
profile,
|
||||||
token: tokenInfo,
|
token: tokenInfo,
|
||||||
selectedTenantId: activeTenant,
|
selectedTenantId: activeTenant,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load console context', error);
|
console.error('Failed to load console context', error);
|
||||||
this.store.setError('Unable to load console context.');
|
this.store.setError('Unable to load console context.');
|
||||||
} finally {
|
} finally {
|
||||||
this.store.setLoading(false);
|
this.store.setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async switchTenant(tenantId: string): Promise<void> {
|
async switchTenant(tenantId: string): Promise<void> {
|
||||||
if (!tenantId || tenantId === this.store.selectedTenantId()) {
|
if (!tenantId || tenantId === this.store.selectedTenantId()) {
|
||||||
return this.loadConsoleContext(tenantId);
|
return this.loadConsoleContext(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.setSelectedTenant(tenantId);
|
this.store.setSelectedTenant(tenantId);
|
||||||
await this.loadConsoleContext(tenantId);
|
await this.loadConsoleContext(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh(): Promise<void> {
|
async refresh(): Promise<void> {
|
||||||
await this.loadConsoleContext(this.store.selectedTenantId());
|
await this.loadConsoleContext(this.store.selectedTenantId());
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.store.clear();
|
this.store.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapTenant(dto: AuthorityTenantViewDto): ConsoleTenant {
|
private mapTenant(dto: AuthorityTenantViewDto): ConsoleTenant {
|
||||||
const roles = Array.isArray(dto.defaultRoles)
|
const roles = Array.isArray(dto.defaultRoles)
|
||||||
? dto.defaultRoles
|
? dto.defaultRoles
|
||||||
.map((role) => role.trim())
|
.map((role) => role.trim())
|
||||||
.filter((role) => role.length > 0)
|
.filter((role) => role.length > 0)
|
||||||
.sort((a, b) => a.localeCompare(b))
|
.sort((a, b) => a.localeCompare(b))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: dto.id,
|
id: dto.id,
|
||||||
displayName: dto.displayName || dto.id,
|
displayName: dto.displayName || dto.id,
|
||||||
status: dto.status ?? 'active',
|
status: dto.status ?? 'active',
|
||||||
isolationMode: dto.isolationMode ?? 'shared',
|
isolationMode: dto.isolationMode ?? 'shared',
|
||||||
defaultRoles: roles,
|
defaultRoles: roles,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapProfile(dto: ConsoleProfileDto): ConsoleProfile {
|
private mapProfile(dto: ConsoleProfileDto): ConsoleProfile {
|
||||||
return {
|
return {
|
||||||
subjectId: dto.subjectId ?? null,
|
subjectId: dto.subjectId ?? null,
|
||||||
username: dto.username ?? null,
|
username: dto.username ?? null,
|
||||||
displayName: dto.displayName ?? dto.username ?? dto.subjectId ?? null,
|
displayName: dto.displayName ?? dto.username ?? dto.subjectId ?? null,
|
||||||
tenant: dto.tenant,
|
tenant: dto.tenant,
|
||||||
sessionId: dto.sessionId ?? null,
|
sessionId: dto.sessionId ?? null,
|
||||||
roles: [...(dto.roles ?? [])].sort((a, b) => a.localeCompare(b)),
|
roles: [...(dto.roles ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||||
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||||
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
||||||
a.localeCompare(b)
|
a.localeCompare(b)
|
||||||
),
|
),
|
||||||
authenticationMethods: [...(dto.authenticationMethods ?? [])],
|
authenticationMethods: [...(dto.authenticationMethods ?? [])],
|
||||||
issuedAt: this.parseInstant(dto.issuedAt),
|
issuedAt: this.parseInstant(dto.issuedAt),
|
||||||
authenticationTime: this.parseInstant(dto.authenticationTime),
|
authenticationTime: this.parseInstant(dto.authenticationTime),
|
||||||
expiresAt: this.parseInstant(dto.expiresAt),
|
expiresAt: this.parseInstant(dto.expiresAt),
|
||||||
freshAuth: !!dto.freshAuth,
|
freshAuth: !!dto.freshAuth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapTokenInfo(dto: ConsoleTokenIntrospectionDto): ConsoleTokenInfo {
|
private mapTokenInfo(dto: ConsoleTokenIntrospectionDto): ConsoleTokenInfo {
|
||||||
const session = this.authSession.session();
|
const session = this.authSession.session();
|
||||||
const freshAuthExpiresAt =
|
const freshAuthExpiresAt =
|
||||||
session?.freshAuthExpiresAtEpochMs != null
|
session?.freshAuthExpiresAtEpochMs != null
|
||||||
? new Date(session.freshAuthExpiresAtEpochMs)
|
? new Date(session.freshAuthExpiresAtEpochMs)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const authenticationTime =
|
const authenticationTime =
|
||||||
session?.authenticationTimeEpochMs != null
|
session?.authenticationTimeEpochMs != null
|
||||||
? new Date(session.authenticationTimeEpochMs)
|
? new Date(session.authenticationTimeEpochMs)
|
||||||
: this.parseInstant(dto.authenticationTime);
|
: this.parseInstant(dto.authenticationTime);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
active: !!dto.active,
|
active: !!dto.active,
|
||||||
tenant: dto.tenant,
|
tenant: dto.tenant,
|
||||||
subject: dto.subject ?? null,
|
subject: dto.subject ?? null,
|
||||||
clientId: dto.clientId ?? null,
|
clientId: dto.clientId ?? null,
|
||||||
tokenId: dto.tokenId ?? null,
|
tokenId: dto.tokenId ?? null,
|
||||||
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||||
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
||||||
a.localeCompare(b)
|
a.localeCompare(b)
|
||||||
),
|
),
|
||||||
issuedAt: this.parseInstant(dto.issuedAt),
|
issuedAt: this.parseInstant(dto.issuedAt),
|
||||||
authenticationTime,
|
authenticationTime,
|
||||||
expiresAt: this.parseInstant(dto.expiresAt),
|
expiresAt: this.parseInstant(dto.expiresAt),
|
||||||
freshAuthActive: session?.freshAuthActive ?? !!dto.freshAuth,
|
freshAuthActive: session?.freshAuthActive ?? !!dto.freshAuth,
|
||||||
freshAuthExpiresAt,
|
freshAuthExpiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseInstant(value: string | null | undefined): Date | null {
|
private parseInstant(value: string | null | undefined): Date | null {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +1,123 @@
|
|||||||
import { ConsoleSessionStore } from './console-session.store';
|
import { ConsoleSessionStore } from './console-session.store';
|
||||||
|
|
||||||
describe('ConsoleSessionStore', () => {
|
describe('ConsoleSessionStore', () => {
|
||||||
let store: ConsoleSessionStore;
|
let store: ConsoleSessionStore;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store = new ConsoleSessionStore();
|
store = new ConsoleSessionStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tracks tenants and selection', () => {
|
it('tracks tenants and selection', () => {
|
||||||
const tenants = [
|
const tenants = [
|
||||||
{
|
{
|
||||||
id: 'tenant-a',
|
id: 'tenant-a',
|
||||||
displayName: 'Tenant A',
|
displayName: 'Tenant A',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
isolationMode: 'shared',
|
isolationMode: 'shared',
|
||||||
defaultRoles: ['role.a'],
|
defaultRoles: ['role.a'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tenant-b',
|
id: 'tenant-b',
|
||||||
displayName: 'Tenant B',
|
displayName: 'Tenant B',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
isolationMode: 'shared',
|
isolationMode: 'shared',
|
||||||
defaultRoles: ['role.b'],
|
defaultRoles: ['role.b'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const selected = store.setTenants(tenants, 'tenant-b');
|
const selected = store.setTenants(tenants, 'tenant-b');
|
||||||
expect(selected).toBe('tenant-b');
|
expect(selected).toBe('tenant-b');
|
||||||
expect(store.selectedTenantId()).toBe('tenant-b');
|
expect(store.selectedTenantId()).toBe('tenant-b');
|
||||||
expect(store.tenants().length).toBe(2);
|
expect(store.tenants().length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets context with profile and token info', () => {
|
it('sets context with profile and token info', () => {
|
||||||
const tenants = [
|
const tenants = [
|
||||||
{
|
{
|
||||||
id: 'tenant-a',
|
id: 'tenant-a',
|
||||||
displayName: 'Tenant A',
|
displayName: 'Tenant A',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
isolationMode: 'shared',
|
isolationMode: 'shared',
|
||||||
defaultRoles: ['role.a'],
|
defaultRoles: ['role.a'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
store.setContext({
|
store.setContext({
|
||||||
tenants,
|
tenants,
|
||||||
selectedTenantId: 'tenant-a',
|
selectedTenantId: 'tenant-a',
|
||||||
profile: {
|
profile: {
|
||||||
subjectId: 'user-1',
|
subjectId: 'user-1',
|
||||||
username: 'user@example.com',
|
username: 'user@example.com',
|
||||||
displayName: 'User Example',
|
displayName: 'User Example',
|
||||||
tenant: 'tenant-a',
|
tenant: 'tenant-a',
|
||||||
sessionId: 'session-123',
|
sessionId: 'session-123',
|
||||||
roles: ['role.a'],
|
roles: ['role.a'],
|
||||||
scopes: ['scope.a'],
|
scopes: ['scope.a'],
|
||||||
audiences: ['aud'],
|
audiences: ['aud'],
|
||||||
authenticationMethods: ['pwd'],
|
authenticationMethods: ['pwd'],
|
||||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||||
freshAuth: true,
|
freshAuth: true,
|
||||||
},
|
},
|
||||||
token: {
|
token: {
|
||||||
active: true,
|
active: true,
|
||||||
tenant: 'tenant-a',
|
tenant: 'tenant-a',
|
||||||
subject: 'user-1',
|
subject: 'user-1',
|
||||||
clientId: 'client',
|
clientId: 'client',
|
||||||
tokenId: 'token-1',
|
tokenId: 'token-1',
|
||||||
scopes: ['scope.a'],
|
scopes: ['scope.a'],
|
||||||
audiences: ['aud'],
|
audiences: ['aud'],
|
||||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||||
freshAuthActive: true,
|
freshAuthActive: true,
|
||||||
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(store.selectedTenantId()).toBe('tenant-a');
|
expect(store.selectedTenantId()).toBe('tenant-a');
|
||||||
expect(store.profile()?.displayName).toBe('User Example');
|
expect(store.profile()?.displayName).toBe('User Example');
|
||||||
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
||||||
expect(store.hasContext()).toBeTrue();
|
expect(store.hasContext()).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears state', () => {
|
it('clears state', () => {
|
||||||
store.setTenants(
|
store.setTenants(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: 'tenant-a',
|
id: 'tenant-a',
|
||||||
displayName: 'Tenant A',
|
displayName: 'Tenant A',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
isolationMode: 'shared',
|
isolationMode: 'shared',
|
||||||
defaultRoles: [],
|
defaultRoles: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'tenant-a'
|
'tenant-a'
|
||||||
);
|
);
|
||||||
store.setProfile({
|
store.setProfile({
|
||||||
subjectId: null,
|
subjectId: null,
|
||||||
username: null,
|
username: null,
|
||||||
displayName: null,
|
displayName: null,
|
||||||
tenant: 'tenant-a',
|
tenant: 'tenant-a',
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
roles: [],
|
roles: [],
|
||||||
scopes: [],
|
scopes: [],
|
||||||
audiences: [],
|
audiences: [],
|
||||||
authenticationMethods: [],
|
authenticationMethods: [],
|
||||||
issuedAt: null,
|
issuedAt: null,
|
||||||
authenticationTime: null,
|
authenticationTime: null,
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
freshAuth: false,
|
freshAuth: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
store.clear();
|
store.clear();
|
||||||
|
|
||||||
expect(store.tenants().length).toBe(0);
|
expect(store.tenants().length).toBe(0);
|
||||||
expect(store.selectedTenantId()).toBeNull();
|
expect(store.selectedTenantId()).toBeNull();
|
||||||
expect(store.profile()).toBeNull();
|
expect(store.profile()).toBeNull();
|
||||||
expect(store.tokenInfo()).toBeNull();
|
expect(store.tokenInfo()).toBeNull();
|
||||||
expect(store.loading()).toBeFalse();
|
expect(store.loading()).toBeFalse();
|
||||||
expect(store.error()).toBeNull();
|
expect(store.error()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,137 +1,137 @@
|
|||||||
import { Injectable, computed, signal } from '@angular/core';
|
import { Injectable, computed, signal } from '@angular/core';
|
||||||
|
|
||||||
export interface ConsoleTenant {
|
export interface ConsoleTenant {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
readonly status: string;
|
readonly status: string;
|
||||||
readonly isolationMode: string;
|
readonly isolationMode: string;
|
||||||
readonly defaultRoles: readonly string[];
|
readonly defaultRoles: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConsoleProfile {
|
export interface ConsoleProfile {
|
||||||
readonly subjectId: string | null;
|
readonly subjectId: string | null;
|
||||||
readonly username: string | null;
|
readonly username: string | null;
|
||||||
readonly displayName: string | null;
|
readonly displayName: string | null;
|
||||||
readonly tenant: string;
|
readonly tenant: string;
|
||||||
readonly sessionId: string | null;
|
readonly sessionId: string | null;
|
||||||
readonly roles: readonly string[];
|
readonly roles: readonly string[];
|
||||||
readonly scopes: readonly string[];
|
readonly scopes: readonly string[];
|
||||||
readonly audiences: readonly string[];
|
readonly audiences: readonly string[];
|
||||||
readonly authenticationMethods: readonly string[];
|
readonly authenticationMethods: readonly string[];
|
||||||
readonly issuedAt: Date | null;
|
readonly issuedAt: Date | null;
|
||||||
readonly authenticationTime: Date | null;
|
readonly authenticationTime: Date | null;
|
||||||
readonly expiresAt: Date | null;
|
readonly expiresAt: Date | null;
|
||||||
readonly freshAuth: boolean;
|
readonly freshAuth: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConsoleTokenInfo {
|
export interface ConsoleTokenInfo {
|
||||||
readonly active: boolean;
|
readonly active: boolean;
|
||||||
readonly tenant: string;
|
readonly tenant: string;
|
||||||
readonly subject: string | null;
|
readonly subject: string | null;
|
||||||
readonly clientId: string | null;
|
readonly clientId: string | null;
|
||||||
readonly tokenId: string | null;
|
readonly tokenId: string | null;
|
||||||
readonly scopes: readonly string[];
|
readonly scopes: readonly string[];
|
||||||
readonly audiences: readonly string[];
|
readonly audiences: readonly string[];
|
||||||
readonly issuedAt: Date | null;
|
readonly issuedAt: Date | null;
|
||||||
readonly authenticationTime: Date | null;
|
readonly authenticationTime: Date | null;
|
||||||
readonly expiresAt: Date | null;
|
readonly expiresAt: Date | null;
|
||||||
readonly freshAuthActive: boolean;
|
readonly freshAuthActive: boolean;
|
||||||
readonly freshAuthExpiresAt: Date | null;
|
readonly freshAuthExpiresAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ConsoleSessionStore {
|
export class ConsoleSessionStore {
|
||||||
private readonly tenantsSignal = signal<ConsoleTenant[]>([]);
|
private readonly tenantsSignal = signal<ConsoleTenant[]>([]);
|
||||||
private readonly selectedTenantIdSignal = signal<string | null>(null);
|
private readonly selectedTenantIdSignal = signal<string | null>(null);
|
||||||
private readonly profileSignal = signal<ConsoleProfile | null>(null);
|
private readonly profileSignal = signal<ConsoleProfile | null>(null);
|
||||||
private readonly tokenSignal = signal<ConsoleTokenInfo | null>(null);
|
private readonly tokenSignal = signal<ConsoleTokenInfo | null>(null);
|
||||||
private readonly loadingSignal = signal(false);
|
private readonly loadingSignal = signal(false);
|
||||||
private readonly errorSignal = signal<string | null>(null);
|
private readonly errorSignal = signal<string | null>(null);
|
||||||
|
|
||||||
readonly tenants = computed(() => this.tenantsSignal());
|
readonly tenants = computed(() => this.tenantsSignal());
|
||||||
readonly selectedTenantId = computed(() => this.selectedTenantIdSignal());
|
readonly selectedTenantId = computed(() => this.selectedTenantIdSignal());
|
||||||
readonly profile = computed(() => this.profileSignal());
|
readonly profile = computed(() => this.profileSignal());
|
||||||
readonly tokenInfo = computed(() => this.tokenSignal());
|
readonly tokenInfo = computed(() => this.tokenSignal());
|
||||||
readonly loading = computed(() => this.loadingSignal());
|
readonly loading = computed(() => this.loadingSignal());
|
||||||
readonly error = computed(() => this.errorSignal());
|
readonly error = computed(() => this.errorSignal());
|
||||||
readonly currentTenant = computed(() => {
|
readonly currentTenant = computed(() => {
|
||||||
const tenantId = this.selectedTenantIdSignal();
|
const tenantId = this.selectedTenantIdSignal();
|
||||||
if (!tenantId) return null;
|
if (!tenantId) return null;
|
||||||
return this.tenantsSignal().find(tenant => tenant.id === tenantId) ?? null;
|
return this.tenantsSignal().find(tenant => tenant.id === tenantId) ?? null;
|
||||||
});
|
});
|
||||||
readonly hasContext = computed(
|
readonly hasContext = computed(
|
||||||
() =>
|
() =>
|
||||||
this.tenantsSignal().length > 0 ||
|
this.tenantsSignal().length > 0 ||
|
||||||
this.profileSignal() !== null ||
|
this.profileSignal() !== null ||
|
||||||
this.tokenSignal() !== null
|
this.tokenSignal() !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
setLoading(loading: boolean): void {
|
setLoading(loading: boolean): void {
|
||||||
this.loadingSignal.set(loading);
|
this.loadingSignal.set(loading);
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(message: string | null): void {
|
setError(message: string | null): void {
|
||||||
this.errorSignal.set(message);
|
this.errorSignal.set(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
setContext(context: {
|
setContext(context: {
|
||||||
tenants: ConsoleTenant[];
|
tenants: ConsoleTenant[];
|
||||||
profile: ConsoleProfile | null;
|
profile: ConsoleProfile | null;
|
||||||
token: ConsoleTokenInfo | null;
|
token: ConsoleTokenInfo | null;
|
||||||
selectedTenantId?: string | null;
|
selectedTenantId?: string | null;
|
||||||
}): void {
|
}): void {
|
||||||
const selected = this.setTenants(context.tenants, context.selectedTenantId);
|
const selected = this.setTenants(context.tenants, context.selectedTenantId);
|
||||||
this.profileSignal.set(context.profile);
|
this.profileSignal.set(context.profile);
|
||||||
this.tokenSignal.set(context.token);
|
this.tokenSignal.set(context.token);
|
||||||
this.selectedTenantIdSignal.set(selected);
|
this.selectedTenantIdSignal.set(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
setProfile(profile: ConsoleProfile | null): void {
|
setProfile(profile: ConsoleProfile | null): void {
|
||||||
this.profileSignal.set(profile);
|
this.profileSignal.set(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTokenInfo(token: ConsoleTokenInfo | null): void {
|
setTokenInfo(token: ConsoleTokenInfo | null): void {
|
||||||
this.tokenSignal.set(token);
|
this.tokenSignal.set(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTenants(
|
setTenants(
|
||||||
tenants: ConsoleTenant[],
|
tenants: ConsoleTenant[],
|
||||||
preferredTenantId?: string | null
|
preferredTenantId?: string | null
|
||||||
): string | null {
|
): string | null {
|
||||||
this.tenantsSignal.set(tenants);
|
this.tenantsSignal.set(tenants);
|
||||||
const currentSelection = this.selectedTenantIdSignal();
|
const currentSelection = this.selectedTenantIdSignal();
|
||||||
const fallbackSelection =
|
const fallbackSelection =
|
||||||
tenants.length > 0 ? tenants[0].id : null;
|
tenants.length > 0 ? tenants[0].id : null;
|
||||||
|
|
||||||
const nextSelection =
|
const nextSelection =
|
||||||
(preferredTenantId &&
|
(preferredTenantId &&
|
||||||
tenants.some((tenant) => tenant.id === preferredTenantId) &&
|
tenants.some((tenant) => tenant.id === preferredTenantId) &&
|
||||||
preferredTenantId) ||
|
preferredTenantId) ||
|
||||||
(currentSelection &&
|
(currentSelection &&
|
||||||
tenants.some((tenant) => tenant.id === currentSelection) &&
|
tenants.some((tenant) => tenant.id === currentSelection) &&
|
||||||
currentSelection) ||
|
currentSelection) ||
|
||||||
fallbackSelection;
|
fallbackSelection;
|
||||||
|
|
||||||
this.selectedTenantIdSignal.set(nextSelection);
|
this.selectedTenantIdSignal.set(nextSelection);
|
||||||
return nextSelection;
|
return nextSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedTenant(tenantId: string | null): void {
|
setSelectedTenant(tenantId: string | null): void {
|
||||||
this.selectedTenantIdSignal.set(tenantId);
|
this.selectedTenantIdSignal.set(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentTenantSnapshot(): ConsoleTenant | null {
|
currentTenantSnapshot(): ConsoleTenant | null {
|
||||||
return this.currentTenant();
|
return this.currentTenant();
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.tenantsSignal.set([]);
|
this.tenantsSignal.set([]);
|
||||||
this.selectedTenantIdSignal.set(null);
|
this.selectedTenantIdSignal.set(null);
|
||||||
this.profileSignal.set(null);
|
this.profileSignal.set(null);
|
||||||
this.tokenSignal.set(null);
|
this.tokenSignal.set(null);
|
||||||
this.loadingSignal.set(false);
|
this.loadingSignal.set(false);
|
||||||
this.errorSignal.set(null);
|
this.errorSignal.set(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export class NavigationService {
|
|||||||
const _ = this.activeRoute(); // Subscribe to route changes
|
const _ = this.activeRoute(); // Subscribe to route changes
|
||||||
this._mobileMenuOpen.set(false);
|
this._mobileMenuOpen.set(false);
|
||||||
this._activeDropdown.set(null);
|
this._activeDropdown.set(null);
|
||||||
});
|
}, { allowSignalWrites: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
export interface OperatorContext {
|
export interface OperatorContext {
|
||||||
readonly reason: string;
|
readonly reason: string;
|
||||||
readonly ticket: string;
|
readonly ticket: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class OperatorContextService {
|
export class OperatorContextService {
|
||||||
private readonly contextSignal = signal<OperatorContext | null>(null);
|
private readonly contextSignal = signal<OperatorContext | null>(null);
|
||||||
|
|
||||||
readonly context = this.contextSignal.asReadonly();
|
readonly context = this.contextSignal.asReadonly();
|
||||||
|
|
||||||
setContext(reason: string, ticket: string): void {
|
setContext(reason: string, ticket: string): void {
|
||||||
const normalizedReason = reason.trim();
|
const normalizedReason = reason.trim();
|
||||||
const normalizedTicket = ticket.trim();
|
const normalizedTicket = ticket.trim();
|
||||||
if (!normalizedReason || !normalizedTicket) {
|
if (!normalizedReason || !normalizedTicket) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'operator_reason and operator_ticket must be provided for orchestrator control actions.'
|
'operator_reason and operator_ticket must be provided for orchestrator control actions.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.contextSignal.set({ reason: normalizedReason, ticket: normalizedTicket });
|
this.contextSignal.set({ reason: normalizedReason, ticket: normalizedTicket });
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.contextSignal.set(null);
|
this.contextSignal.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot(): OperatorContext | null {
|
snapshot(): OperatorContext | null {
|
||||||
return this.contextSignal();
|
return this.contextSignal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
import {
|
import {
|
||||||
HttpEvent,
|
HttpEvent,
|
||||||
HttpHandler,
|
HttpHandler,
|
||||||
HttpInterceptor,
|
HttpInterceptor,
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
} from '@angular/common/http';
|
} from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { OperatorContextService } from './operator-context.service';
|
import { OperatorContextService } from './operator-context.service';
|
||||||
|
|
||||||
export const OPERATOR_METADATA_SENTINEL_HEADER = 'X-Stella-Require-Operator';
|
export const OPERATOR_METADATA_SENTINEL_HEADER = 'X-Stella-Require-Operator';
|
||||||
export const OPERATOR_REASON_HEADER = 'X-Stella-Operator-Reason';
|
export const OPERATOR_REASON_HEADER = 'X-Stella-Operator-Reason';
|
||||||
export const OPERATOR_TICKET_HEADER = 'X-Stella-Operator-Ticket';
|
export const OPERATOR_TICKET_HEADER = 'X-Stella-Operator-Ticket';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OperatorMetadataInterceptor implements HttpInterceptor {
|
export class OperatorMetadataInterceptor implements HttpInterceptor {
|
||||||
constructor(private readonly context: OperatorContextService) {}
|
constructor(private readonly context: OperatorContextService) {}
|
||||||
|
|
||||||
intercept(
|
intercept(
|
||||||
request: HttpRequest<unknown>,
|
request: HttpRequest<unknown>,
|
||||||
next: HttpHandler
|
next: HttpHandler
|
||||||
): Observable<HttpEvent<unknown>> {
|
): Observable<HttpEvent<unknown>> {
|
||||||
if (!request.headers.has(OPERATOR_METADATA_SENTINEL_HEADER)) {
|
if (!request.headers.has(OPERATOR_METADATA_SENTINEL_HEADER)) {
|
||||||
return next.handle(request);
|
return next.handle(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = this.context.snapshot();
|
const current = this.context.snapshot();
|
||||||
const headers = request.headers.delete(OPERATOR_METADATA_SENTINEL_HEADER);
|
const headers = request.headers.delete(OPERATOR_METADATA_SENTINEL_HEADER);
|
||||||
|
|
||||||
if (!current) {
|
if (!current) {
|
||||||
return next.handle(request.clone({ headers }));
|
return next.handle(request.clone({ headers }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const enriched = headers
|
const enriched = headers
|
||||||
.set(OPERATOR_REASON_HEADER, current.reason)
|
.set(OPERATOR_REASON_HEADER, current.reason)
|
||||||
.set(OPERATOR_TICKET_HEADER, current.ticket);
|
.set(OPERATOR_TICKET_HEADER, current.ticket);
|
||||||
|
|
||||||
return next.handle(request.clone({ headers: enriched }));
|
return next.handle(request.clone({ headers: enriched }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ import {
|
|||||||
.stat-value.failed { color: #dc2626; }
|
.stat-value.failed { color: #dc2626; }
|
||||||
.stat-value.pending { color: #d97706; }
|
.stat-value.pending { color: #d97706; }
|
||||||
.stat-value.throttled { color: #2563eb; }
|
.stat-value.throttled { color: #2563eb; }
|
||||||
.stat-value.rate { color: #6366f1; }
|
.stat-value.rate { color: #D4920A; }
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ interface ConfigSubTab {
|
|||||||
.sent-icon { background: #10b981; }
|
.sent-icon { background: #10b981; }
|
||||||
.failed-icon { background: #ef4444; }
|
.failed-icon { background: #ef4444; }
|
||||||
.pending-icon { background: #f59e0b; }
|
.pending-icon { background: #f59e0b; }
|
||||||
.rate-icon { background: #6366f1; }
|
.rate-icon { background: #D4920A; }
|
||||||
|
|
||||||
.stat-content {
|
.stat-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export const OBJECT_LINK_METADATA: Record<ObjectLinkType, { icon: string; color:
|
|||||||
reach: { icon: 'git-branch', color: '#8b5cf6', label: 'Reachability' },
|
reach: { icon: 'git-branch', color: '#8b5cf6', label: 'Reachability' },
|
||||||
runtime: { icon: 'activity', color: '#f59e0b', label: 'Runtime' },
|
runtime: { icon: 'activity', color: '#f59e0b', label: 'Runtime' },
|
||||||
vex: { icon: 'shield', color: '#10b981', label: 'VEX' },
|
vex: { icon: 'shield', color: '#10b981', label: 'VEX' },
|
||||||
attest: { icon: 'file-signature', color: '#6366f1', label: 'Attestation' },
|
attest: { icon: 'file-signature', color: '#D4920A', label: 'Attestation' },
|
||||||
auth: { icon: 'key', color: '#ef4444', label: 'Auth' },
|
auth: { icon: 'key', color: '#ef4444', label: 'Auth' },
|
||||||
docs: { icon: 'book', color: '#64748b', label: 'Docs' },
|
docs: { icon: 'book', color: '#64748b', label: 'Docs' },
|
||||||
finding: { icon: 'alert-triangle', color: '#f97316', label: 'Finding' },
|
finding: { icon: 'alert-triangle', color: '#f97316', label: 'Finding' },
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ import {
|
|||||||
.chip--reach { --chip-color: #8b5cf6; --chip-bg: rgba(139, 92, 246, 0.1); --chip-border: rgba(139, 92, 246, 0.2); }
|
.chip--reach { --chip-color: #8b5cf6; --chip-bg: rgba(139, 92, 246, 0.1); --chip-border: rgba(139, 92, 246, 0.2); }
|
||||||
.chip--runtime { --chip-color: #f59e0b; --chip-bg: rgba(245, 158, 11, 0.1); --chip-border: rgba(245, 158, 11, 0.2); }
|
.chip--runtime { --chip-color: #f59e0b; --chip-bg: rgba(245, 158, 11, 0.1); --chip-border: rgba(245, 158, 11, 0.2); }
|
||||||
.chip--vex { --chip-color: #10b981; --chip-bg: rgba(16, 185, 129, 0.1); --chip-border: rgba(16, 185, 129, 0.2); }
|
.chip--vex { --chip-color: #10b981; --chip-bg: rgba(16, 185, 129, 0.1); --chip-border: rgba(16, 185, 129, 0.2); }
|
||||||
.chip--attest { --chip-color: #6366f1; --chip-bg: rgba(99, 102, 241, 0.1); --chip-border: rgba(99, 102, 241, 0.2); }
|
.chip--attest { --chip-color: #D4920A; --chip-bg: rgba(245, 166, 35, 0.1); --chip-border: rgba(245, 166, 35, 0.2); }
|
||||||
.chip--auth { --chip-color: #ef4444; --chip-bg: rgba(239, 68, 68, 0.1); --chip-border: rgba(239, 68, 68, 0.2); }
|
.chip--auth { --chip-color: #ef4444; --chip-bg: rgba(239, 68, 68, 0.1); --chip-border: rgba(239, 68, 68, 0.2); }
|
||||||
.chip--docs { --chip-color: #64748b; --chip-bg: rgba(100, 116, 139, 0.1); --chip-border: rgba(100, 116, 139, 0.2); }
|
.chip--docs { --chip-color: #64748b; --chip-bg: rgba(100, 116, 139, 0.1); --chip-border: rgba(100, 116, 139, 0.2); }
|
||||||
.chip--finding { --chip-color: #f97316; --chip-bg: rgba(249, 115, 22, 0.1); --chip-border: rgba(249, 115, 22, 0.2); }
|
.chip--finding { --chip-color: #f97316; --chip-bg: rgba(249, 115, 22, 0.1); --chip-border: rgba(249, 115, 22, 0.2); }
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
|||||||
}
|
}
|
||||||
|
|
||||||
.evidence-type-badge.type-patch {
|
.evidence-type-badge.type-patch {
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
background: #e0e7ff;
|
background: #e0e7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ import type {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.citation-type.type-patch {
|
.citation-type.type-patch {
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
background: #e0e7ff;
|
background: #e0e7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ import type {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.step-type.type-vex_document {
|
.step-type.type-vex_document {
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
background: #e0e7ff;
|
background: #e0e7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,184 +1,184 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { AocClient } from '../../core/api/aoc.client';
|
import { AocClient } from '../../core/api/aoc.client';
|
||||||
import {
|
import {
|
||||||
AocVerificationRequest,
|
AocVerificationRequest,
|
||||||
AocVerificationResult,
|
AocVerificationResult,
|
||||||
AocViolationDetail,
|
AocViolationDetail,
|
||||||
} from '../../core/api/aoc.models';
|
} from '../../core/api/aoc.models';
|
||||||
|
|
||||||
type VerifyState = 'idle' | 'running' | 'completed' | 'error';
|
type VerifyState = 'idle' | 'running' | 'completed' | 'error';
|
||||||
|
|
||||||
export interface CliParityGuidance {
|
export interface CliParityGuidance {
|
||||||
command: string;
|
command: string;
|
||||||
description: string;
|
description: string;
|
||||||
flags: { flag: string; description: string }[];
|
flags: { flag: string; description: string }[];
|
||||||
examples: string[];
|
examples: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-verify-action',
|
selector: 'app-verify-action',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './verify-action.component.html',
|
templateUrl: './verify-action.component.html',
|
||||||
styleUrls: ['./verify-action.component.scss'],
|
styleUrls: ['./verify-action.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class VerifyActionComponent {
|
export class VerifyActionComponent {
|
||||||
private readonly aocClient = inject(AocClient);
|
private readonly aocClient = inject(AocClient);
|
||||||
|
|
||||||
/** Tenant ID to verify */
|
/** Tenant ID to verify */
|
||||||
readonly tenantId = input.required<string>();
|
readonly tenantId = input.required<string>();
|
||||||
|
|
||||||
/** Time window in hours (default 24h) */
|
/** Time window in hours (default 24h) */
|
||||||
readonly windowHours = input(24);
|
readonly windowHours = input(24);
|
||||||
|
|
||||||
/** Maximum documents to check */
|
/** Maximum documents to check */
|
||||||
readonly limit = input(10000);
|
readonly limit = input(10000);
|
||||||
|
|
||||||
/** Emits when verification completes */
|
/** Emits when verification completes */
|
||||||
readonly verified = output<AocVerificationResult>();
|
readonly verified = output<AocVerificationResult>();
|
||||||
|
|
||||||
/** Emits when user clicks on a violation */
|
/** Emits when user clicks on a violation */
|
||||||
readonly selectViolation = output<AocViolationDetail>();
|
readonly selectViolation = output<AocViolationDetail>();
|
||||||
|
|
||||||
readonly state = signal<VerifyState>('idle');
|
readonly state = signal<VerifyState>('idle');
|
||||||
readonly result = signal<AocVerificationResult | null>(null);
|
readonly result = signal<AocVerificationResult | null>(null);
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
readonly progress = signal(0);
|
readonly progress = signal(0);
|
||||||
readonly showCliGuidance = signal(false);
|
readonly showCliGuidance = signal(false);
|
||||||
|
|
||||||
readonly statusIcon = computed(() => {
|
readonly statusIcon = computed(() => {
|
||||||
switch (this.state()) {
|
switch (this.state()) {
|
||||||
case 'idle':
|
case 'idle':
|
||||||
return '[ ]';
|
return '[ ]';
|
||||||
case 'running':
|
case 'running':
|
||||||
return '[~]';
|
return '[~]';
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return this.result()?.status === 'passed' ? '[+]' : '[!]';
|
return this.result()?.status === 'passed' ? '[+]' : '[!]';
|
||||||
case 'error':
|
case 'error':
|
||||||
return '[X]';
|
return '[X]';
|
||||||
default:
|
default:
|
||||||
return '[?]';
|
return '[?]';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly statusLabel = computed(() => {
|
readonly statusLabel = computed(() => {
|
||||||
switch (this.state()) {
|
switch (this.state()) {
|
||||||
case 'idle':
|
case 'idle':
|
||||||
return 'Ready to verify';
|
return 'Ready to verify';
|
||||||
case 'running':
|
case 'running':
|
||||||
return 'Verification in progress...';
|
return 'Verification in progress...';
|
||||||
case 'completed':
|
case 'completed':
|
||||||
const r = this.result();
|
const r = this.result();
|
||||||
if (!r) return 'Completed';
|
if (!r) return 'Completed';
|
||||||
return r.status === 'passed'
|
return r.status === 'passed'
|
||||||
? 'Verification passed'
|
? 'Verification passed'
|
||||||
: r.status === 'failed'
|
: r.status === 'failed'
|
||||||
? 'Verification failed'
|
? 'Verification failed'
|
||||||
: 'Verification completed with warnings';
|
: 'Verification completed with warnings';
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'Verification error';
|
return 'Verification error';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly resultSummary = computed(() => {
|
readonly resultSummary = computed(() => {
|
||||||
const r = this.result();
|
const r = this.result();
|
||||||
if (!r) return null;
|
if (!r) return null;
|
||||||
return {
|
return {
|
||||||
passRate: ((r.passedCount / r.checkedCount) * 100).toFixed(2),
|
passRate: ((r.passedCount / r.checkedCount) * 100).toFixed(2),
|
||||||
violationCount: r.violations.length,
|
violationCount: r.violations.length,
|
||||||
uniqueCodes: [...new Set(r.violations.map((v) => v.violationCode))],
|
uniqueCodes: [...new Set(r.violations.map((v) => v.violationCode))],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly cliGuidance: CliParityGuidance = {
|
readonly cliGuidance: CliParityGuidance = {
|
||||||
command: 'stella aoc verify',
|
command: 'stella aoc verify',
|
||||||
description:
|
description:
|
||||||
'Run the same verification from CLI for automation, CI/CD pipelines, or detailed output.',
|
'Run the same verification from CLI for automation, CI/CD pipelines, or detailed output.',
|
||||||
flags: [
|
flags: [
|
||||||
{ flag: '--tenant', description: 'Tenant ID to verify' },
|
{ flag: '--tenant', description: 'Tenant ID to verify' },
|
||||||
{ flag: '--since', description: 'Start time (ISO8601 or duration like "24h")' },
|
{ flag: '--since', description: 'Start time (ISO8601 or duration like "24h")' },
|
||||||
{ flag: '--limit', description: 'Maximum documents to check' },
|
{ flag: '--limit', description: 'Maximum documents to check' },
|
||||||
{ flag: '--output', description: 'Output format: json, table, summary' },
|
{ flag: '--output', description: 'Output format: json, table, summary' },
|
||||||
{ flag: '--fail-on-violation', description: 'Exit with code 1 if any violations found' },
|
{ flag: '--fail-on-violation', description: 'Exit with code 1 if any violations found' },
|
||||||
{ flag: '--verbose', description: 'Show detailed violation information' },
|
{ flag: '--verbose', description: 'Show detailed violation information' },
|
||||||
],
|
],
|
||||||
examples: [
|
examples: [
|
||||||
'stella aoc verify --tenant $TENANT_ID --since 24h',
|
'stella aoc verify --tenant $TENANT_ID --since 24h',
|
||||||
'stella aoc verify --tenant $TENANT_ID --since 24h --output json > report.json',
|
'stella aoc verify --tenant $TENANT_ID --since 24h --output json > report.json',
|
||||||
'stella aoc verify --tenant $TENANT_ID --since 24h --fail-on-violation',
|
'stella aoc verify --tenant $TENANT_ID --since 24h --fail-on-violation',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
async runVerification(): Promise<void> {
|
async runVerification(): Promise<void> {
|
||||||
if (this.state() === 'running') return;
|
if (this.state() === 'running') return;
|
||||||
|
|
||||||
this.state.set('running');
|
this.state.set('running');
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.result.set(null);
|
this.result.set(null);
|
||||||
this.progress.set(0);
|
this.progress.set(0);
|
||||||
|
|
||||||
// Simulate progress updates
|
// Simulate progress updates
|
||||||
const progressInterval = setInterval(() => {
|
const progressInterval = setInterval(() => {
|
||||||
this.progress.update((p) => Math.min(p + Math.random() * 15, 90));
|
this.progress.update((p) => Math.min(p + Math.random() * 15, 90));
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
const since = new Date();
|
const since = new Date();
|
||||||
since.setHours(since.getHours() - this.windowHours());
|
since.setHours(since.getHours() - this.windowHours());
|
||||||
|
|
||||||
const request: AocVerificationRequest = {
|
const request: AocVerificationRequest = {
|
||||||
tenantId: this.tenantId(),
|
tenantId: this.tenantId(),
|
||||||
since: since.toISOString(),
|
since: since.toISOString(),
|
||||||
limit: this.limit(),
|
limit: this.limit(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.aocClient.verify(request).subscribe({
|
this.aocClient.verify(request).subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
this.progress.set(100);
|
this.progress.set(100);
|
||||||
this.result.set(result);
|
this.result.set(result);
|
||||||
this.state.set('completed');
|
this.state.set('completed');
|
||||||
this.verified.emit(result);
|
this.verified.emit(result);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
this.state.set('error');
|
this.state.set('error');
|
||||||
this.error.set(err.message || 'Verification failed');
|
this.error.set(err.message || 'Verification failed');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.state.set('idle');
|
this.state.set('idle');
|
||||||
this.result.set(null);
|
this.result.set(null);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.progress.set(0);
|
this.progress.set(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleCliGuidance(): void {
|
toggleCliGuidance(): void {
|
||||||
this.showCliGuidance.update((v) => !v);
|
this.showCliGuidance.update((v) => !v);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelectViolation(violation: AocViolationDetail): void {
|
onSelectViolation(violation: AocViolationDetail): void {
|
||||||
this.selectViolation.emit(violation);
|
this.selectViolation.emit(violation);
|
||||||
}
|
}
|
||||||
|
|
||||||
copyCommand(command: string): void {
|
copyCommand(command: string): void {
|
||||||
navigator.clipboard.writeText(command);
|
navigator.clipboard.writeText(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
getCliCommand(): string {
|
getCliCommand(): string {
|
||||||
return `stella aoc verify --tenant ${this.tenantId()} --since ${this.windowHours()}h`;
|
return `stella aoc verify --tenant ${this.tenantId()} --since ${this.windowHours()}h`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,182 +1,182 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
AocViolationDetail,
|
AocViolationDetail,
|
||||||
AocViolationGroup,
|
AocViolationGroup,
|
||||||
AocDocumentView,
|
AocDocumentView,
|
||||||
AocProvenance,
|
AocProvenance,
|
||||||
} from '../../core/api/aoc.models';
|
} from '../../core/api/aoc.models';
|
||||||
|
|
||||||
type ViewMode = 'by-violation' | 'by-document';
|
type ViewMode = 'by-violation' | 'by-document';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-violation-drilldown',
|
selector: 'app-violation-drilldown',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './violation-drilldown.component.html',
|
templateUrl: './violation-drilldown.component.html',
|
||||||
styleUrls: ['./violation-drilldown.component.scss'],
|
styleUrls: ['./violation-drilldown.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class ViolationDrilldownComponent {
|
export class ViolationDrilldownComponent {
|
||||||
/** Violation groups to display */
|
/** Violation groups to display */
|
||||||
readonly violationGroups = input.required<AocViolationGroup[]>();
|
readonly violationGroups = input.required<AocViolationGroup[]>();
|
||||||
|
|
||||||
/** Document views for by-document mode */
|
/** Document views for by-document mode */
|
||||||
readonly documentViews = input<AocDocumentView[]>([]);
|
readonly documentViews = input<AocDocumentView[]>([]);
|
||||||
|
|
||||||
/** Emits when user clicks on a document */
|
/** Emits when user clicks on a document */
|
||||||
readonly selectDocument = output<string>();
|
readonly selectDocument = output<string>();
|
||||||
|
|
||||||
/** Emits when user wants to view raw document */
|
/** Emits when user wants to view raw document */
|
||||||
readonly viewRawDocument = output<string>();
|
readonly viewRawDocument = output<string>();
|
||||||
|
|
||||||
/** Current view mode */
|
/** Current view mode */
|
||||||
readonly viewMode = signal<ViewMode>('by-violation');
|
readonly viewMode = signal<ViewMode>('by-violation');
|
||||||
|
|
||||||
/** Currently expanded violation code */
|
/** Currently expanded violation code */
|
||||||
readonly expandedCode = signal<string | null>(null);
|
readonly expandedCode = signal<string | null>(null);
|
||||||
|
|
||||||
/** Currently expanded document ID */
|
/** Currently expanded document ID */
|
||||||
readonly expandedDocId = signal<string | null>(null);
|
readonly expandedDocId = signal<string | null>(null);
|
||||||
|
|
||||||
/** Search filter */
|
/** Search filter */
|
||||||
readonly searchFilter = signal('');
|
readonly searchFilter = signal('');
|
||||||
|
|
||||||
readonly filteredGroups = computed(() => {
|
readonly filteredGroups = computed(() => {
|
||||||
const filter = this.searchFilter().toLowerCase();
|
const filter = this.searchFilter().toLowerCase();
|
||||||
if (!filter) return this.violationGroups();
|
if (!filter) return this.violationGroups();
|
||||||
return this.violationGroups().filter(
|
return this.violationGroups().filter(
|
||||||
(g) =>
|
(g) =>
|
||||||
g.code.toLowerCase().includes(filter) ||
|
g.code.toLowerCase().includes(filter) ||
|
||||||
g.description.toLowerCase().includes(filter) ||
|
g.description.toLowerCase().includes(filter) ||
|
||||||
g.violations.some(
|
g.violations.some(
|
||||||
(v) =>
|
(v) =>
|
||||||
v.documentId.toLowerCase().includes(filter) ||
|
v.documentId.toLowerCase().includes(filter) ||
|
||||||
v.field?.toLowerCase().includes(filter)
|
v.field?.toLowerCase().includes(filter)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly filteredDocuments = computed(() => {
|
readonly filteredDocuments = computed(() => {
|
||||||
const filter = this.searchFilter().toLowerCase();
|
const filter = this.searchFilter().toLowerCase();
|
||||||
if (!filter) return this.documentViews();
|
if (!filter) return this.documentViews();
|
||||||
return this.documentViews().filter(
|
return this.documentViews().filter(
|
||||||
(d) =>
|
(d) =>
|
||||||
d.documentId.toLowerCase().includes(filter) ||
|
d.documentId.toLowerCase().includes(filter) ||
|
||||||
d.documentType.toLowerCase().includes(filter) ||
|
d.documentType.toLowerCase().includes(filter) ||
|
||||||
d.violations.some(
|
d.violations.some(
|
||||||
(v) =>
|
(v) =>
|
||||||
v.violationCode.toLowerCase().includes(filter) ||
|
v.violationCode.toLowerCase().includes(filter) ||
|
||||||
v.field?.toLowerCase().includes(filter)
|
v.field?.toLowerCase().includes(filter)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly totalViolations = computed(() =>
|
readonly totalViolations = computed(() =>
|
||||||
this.violationGroups().reduce((sum, g) => sum + g.violations.length, 0)
|
this.violationGroups().reduce((sum, g) => sum + g.violations.length, 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly totalDocuments = computed(() => {
|
readonly totalDocuments = computed(() => {
|
||||||
const docIds = new Set<string>();
|
const docIds = new Set<string>();
|
||||||
for (const group of this.violationGroups()) {
|
for (const group of this.violationGroups()) {
|
||||||
for (const v of group.violations) {
|
for (const v of group.violations) {
|
||||||
docIds.add(v.documentId);
|
docIds.add(v.documentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return docIds.size;
|
return docIds.size;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly severityCounts = computed(() => {
|
readonly severityCounts = computed(() => {
|
||||||
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||||
for (const group of this.violationGroups()) {
|
for (const group of this.violationGroups()) {
|
||||||
counts[group.severity] += group.violations.length;
|
counts[group.severity] += group.violations.length;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
});
|
});
|
||||||
|
|
||||||
setViewMode(mode: ViewMode): void {
|
setViewMode(mode: ViewMode): void {
|
||||||
this.viewMode.set(mode);
|
this.viewMode.set(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleGroup(code: string): void {
|
toggleGroup(code: string): void {
|
||||||
this.expandedCode.update((current) => (current === code ? null : code));
|
this.expandedCode.update((current) => (current === code ? null : code));
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleDocument(docId: string): void {
|
toggleDocument(docId: string): void {
|
||||||
this.expandedDocId.update((current) => (current === docId ? null : docId));
|
this.expandedDocId.update((current) => (current === docId ? null : docId));
|
||||||
}
|
}
|
||||||
|
|
||||||
onSearch(event: Event): void {
|
onSearch(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
this.searchFilter.set(input.value);
|
this.searchFilter.set(input.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelectDocument(docId: string): void {
|
onSelectDocument(docId: string): void {
|
||||||
this.selectDocument.emit(docId);
|
this.selectDocument.emit(docId);
|
||||||
}
|
}
|
||||||
|
|
||||||
onViewRaw(docId: string): void {
|
onViewRaw(docId: string): void {
|
||||||
this.viewRawDocument.emit(docId);
|
this.viewRawDocument.emit(docId);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSeverityIcon(severity: string): string {
|
getSeverityIcon(severity: string): string {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case 'critical':
|
case 'critical':
|
||||||
return '!!';
|
return '!!';
|
||||||
case 'high':
|
case 'high':
|
||||||
return '!';
|
return '!';
|
||||||
case 'medium':
|
case 'medium':
|
||||||
return '~';
|
return '~';
|
||||||
default:
|
default:
|
||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getSourceTypeIcon(sourceType?: string): string {
|
getSourceTypeIcon(sourceType?: string): string {
|
||||||
switch (sourceType) {
|
switch (sourceType) {
|
||||||
case 'registry':
|
case 'registry':
|
||||||
return '[R]';
|
return '[R]';
|
||||||
case 'git':
|
case 'git':
|
||||||
return '[G]';
|
return '[G]';
|
||||||
case 'upload':
|
case 'upload':
|
||||||
return '[U]';
|
return '[U]';
|
||||||
case 'api':
|
case 'api':
|
||||||
return '[A]';
|
return '[A]';
|
||||||
default:
|
default:
|
||||||
return '[?]';
|
return '[?]';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
formatDigest(digest: string, length = 12): string {
|
formatDigest(digest: string, length = 12): string {
|
||||||
if (digest.length <= length) return digest;
|
if (digest.length <= length) return digest;
|
||||||
return digest.slice(0, length) + '...';
|
return digest.slice(0, length) + '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
formatDate(dateStr: string): string {
|
formatDate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleString();
|
return new Date(dateStr).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
isFieldHighlighted(doc: AocDocumentView, field: string): boolean {
|
isFieldHighlighted(doc: AocDocumentView, field: string): boolean {
|
||||||
return doc.highlightedFields.includes(field);
|
return doc.highlightedFields.includes(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
getFieldValue(content: Record<string, unknown> | undefined, path: string): string {
|
getFieldValue(content: Record<string, unknown> | undefined, path: string): string {
|
||||||
if (!content) return 'N/A';
|
if (!content) return 'N/A';
|
||||||
const parts = path.split('.');
|
const parts = path.split('.');
|
||||||
let current: unknown = content;
|
let current: unknown = content;
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (current == null || typeof current !== 'object') return 'N/A';
|
if (current == null || typeof current !== 'object') return 'N/A';
|
||||||
current = (current as Record<string, unknown>)[part];
|
current = (current as Record<string, unknown>)[part];
|
||||||
}
|
}
|
||||||
if (current == null) return 'null';
|
if (current == null) return 'null';
|
||||||
if (typeof current === 'object') return JSON.stringify(current);
|
if (typeof current === 'object') return JSON.stringify(current);
|
||||||
return String(current);
|
return String(current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
|
|||||||
.stat-card.authority { border-left: 4px solid #8b5cf6; }
|
.stat-card.authority { border-left: 4px solid #8b5cf6; }
|
||||||
.stat-card.vex { border-left: 4px solid #10b981; }
|
.stat-card.vex { border-left: 4px solid #10b981; }
|
||||||
.stat-card.integrations { border-left: 4px solid #f59e0b; }
|
.stat-card.integrations { border-left: 4px solid #f59e0b; }
|
||||||
.stat-card.orchestrator { border-left: 4px solid #6366f1; }
|
.stat-card.orchestrator { border-left: 4px solid #D4920A; }
|
||||||
.anomaly-alerts { margin-bottom: 2rem; }
|
.anomaly-alerts { margin-bottom: 2rem; }
|
||||||
.anomaly-alerts h2 { margin: 0 0 1rem; font-size: 1.1rem; }
|
.anomaly-alerts h2 { margin: 0 0 1rem; font-size: 1.1rem; }
|
||||||
.alert-list { display: flex; gap: 1rem; flex-wrap: wrap; }
|
.alert-list { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
|||||||
@@ -1,61 +1,61 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
|
||||||
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-auth-callback',
|
selector: 'app-auth-callback',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
template: `
|
template: `
|
||||||
<section class="auth-callback">
|
<section class="auth-callback">
|
||||||
<p *ngIf="state() === 'processing'">Completing sign-in…</p>
|
<p *ngIf="state() === 'processing'">Completing sign-in…</p>
|
||||||
<p *ngIf="state() === 'error'" class="error">
|
<p *ngIf="state() === 'error'" class="error">
|
||||||
We were unable to complete the sign-in flow. Please try again.
|
We were unable to complete the sign-in flow. Please try again.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
`,
|
`,
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
.auth-callback {
|
.auth-callback {
|
||||||
margin: 4rem auto;
|
margin: 4rem auto;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AuthCallbackComponent implements OnInit {
|
export class AuthCallbackComponent implements OnInit {
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly auth = inject(AuthorityAuthService);
|
private readonly auth = inject(AuthorityAuthService);
|
||||||
|
|
||||||
readonly state = signal<'processing' | 'error'>('processing');
|
readonly state = signal<'processing' | 'error'>('processing');
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
const params = this.route.snapshot.queryParamMap;
|
const params = this.route.snapshot.queryParamMap;
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
params.keys.forEach((key) => {
|
params.keys.forEach((key) => {
|
||||||
const value = params.get(key);
|
const value = params.get(key);
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
searchParams.set(key, value);
|
searchParams.set(key, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.auth.completeLoginFromRedirect(searchParams);
|
const result = await this.auth.completeLoginFromRedirect(searchParams);
|
||||||
const returnUrl = result.returnUrl ?? '/';
|
const returnUrl = result.returnUrl ?? '/';
|
||||||
await this.router.navigateByUrl(returnUrl, { replaceUrl: true });
|
await this.router.navigateByUrl(returnUrl, { replaceUrl: true });
|
||||||
} catch {
|
} catch {
|
||||||
this.state.set('error');
|
this.state.set('error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,226 +1,226 @@
|
|||||||
@use 'tokens/breakpoints' as *;
|
@use 'tokens/breakpoints' as *;
|
||||||
|
|
||||||
.console-profile {
|
.console-profile {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-profile__header {
|
.console-profile__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--font-size-2xl);
|
font-size: var(--font-size-2xl);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-profile__subtitle {
|
.console-profile__subtitle {
|
||||||
margin: var(--space-1) 0 0;
|
margin: var(--space-1) 0 0;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-brand-primary);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
|
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
|
||||||
box-shadow var(--motion-duration-fast) var(--motion-ease-default);
|
box-shadow var(--motion-duration-fast) var(--motion-ease-default);
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
background: var(--color-brand-primary-hover);
|
background: var(--color-brand-primary-hover);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
cursor: progress;
|
cursor: progress;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
transform: none;
|
transform: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-profile__loading {
|
.console-profile__loading {
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--color-brand-light);
|
background: var(--color-brand-light);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-brand-primary);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-profile__error {
|
.console-profile__error {
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--color-status-error-bg);
|
background: var(--color-status-error-bg);
|
||||||
color: var(--color-status-error);
|
color: var(--color-status-error);
|
||||||
border: 1px solid var(--color-status-error);
|
border: 1px solid var(--color-status-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-profile__card {
|
.console-profile__card {
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dl {
|
dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
gap: var(--space-3) var(--space-4);
|
gap: var(--space-3) var(--space-4);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
dt {
|
dt {
|
||||||
margin: 0 0 var(--space-1);
|
margin: 0 0 var(--space-1);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
dd {
|
dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip,
|
.chip,
|
||||||
.tenant-chip {
|
.tenant-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
padding: var(--space-1) var(--space-2-5);
|
padding: var(--space-1) var(--space-2-5);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
background-color: var(--color-surface-secondary);
|
background-color: var(--color-surface-secondary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip--active {
|
.chip--active {
|
||||||
background-color: var(--color-status-success-bg);
|
background-color: var(--color-status-success-bg);
|
||||||
color: var(--color-status-success);
|
color: var(--color-status-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip--inactive {
|
.chip--inactive {
|
||||||
background-color: var(--color-status-error-bg);
|
background-color: var(--color-status-error-bg);
|
||||||
color: var(--color-status-error);
|
color: var(--color-status-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tenant-chip {
|
.tenant-chip {
|
||||||
background-color: var(--color-brand-light);
|
background-color: var(--color-brand-light);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-brand-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tenant-count {
|
.tenant-count {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tenant-list {
|
.tenant-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-2-5);
|
gap: var(--space-2-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tenant-list__item--active button {
|
.tenant-list__item--active button {
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
background-color: var(--color-brand-light);
|
background-color: var(--color-brand-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tenant-list button {
|
.tenant-list button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
padding: var(--space-2-5) var(--space-3);
|
padding: var(--space-2-5) var(--space-3);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
|
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||||
box-shadow var(--motion-duration-fast) var(--motion-ease-default),
|
box-shadow var(--motion-duration-fast) var(--motion-ease-default),
|
||||||
transform var(--motion-duration-fast) var(--motion-ease-default);
|
transform var(--motion-duration-fast) var(--motion-ease-default);
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tenant-list__heading {
|
.tenant-list__heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tenant-meta {
|
.tenant-meta {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fresh-auth {
|
.fresh-auth {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fresh-auth--active {
|
.fresh-auth--active {
|
||||||
background-color: var(--color-status-success-bg);
|
background-color: var(--color-status-success-bg);
|
||||||
color: var(--color-status-success);
|
color: var(--color-status-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fresh-auth--stale {
|
.fresh-auth--stale {
|
||||||
background-color: var(--color-status-warning-bg);
|
background-color: var(--color-status-warning-bg);
|
||||||
color: var(--color-status-warning);
|
color: var(--color-status-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-profile__empty {
|
.console-profile__empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,110 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||||
import { ConsoleProfileComponent } from './console-profile.component';
|
import { ConsoleProfileComponent } from './console-profile.component';
|
||||||
|
|
||||||
class MockConsoleSessionService {
|
class MockConsoleSessionService {
|
||||||
loadConsoleContext = jasmine
|
loadConsoleContext = jasmine
|
||||||
.createSpy('loadConsoleContext')
|
.createSpy('loadConsoleContext')
|
||||||
.and.returnValue(Promise.resolve());
|
.and.returnValue(Promise.resolve());
|
||||||
refresh = jasmine
|
refresh = jasmine
|
||||||
.createSpy('refresh')
|
.createSpy('refresh')
|
||||||
.and.returnValue(Promise.resolve());
|
.and.returnValue(Promise.resolve());
|
||||||
switchTenant = jasmine
|
switchTenant = jasmine
|
||||||
.createSpy('switchTenant')
|
.createSpy('switchTenant')
|
||||||
.and.returnValue(Promise.resolve());
|
.and.returnValue(Promise.resolve());
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ConsoleProfileComponent', () => {
|
describe('ConsoleProfileComponent', () => {
|
||||||
let fixture: ComponentFixture<ConsoleProfileComponent>;
|
let fixture: ComponentFixture<ConsoleProfileComponent>;
|
||||||
let service: MockConsoleSessionService;
|
let service: MockConsoleSessionService;
|
||||||
let store: ConsoleSessionStore;
|
let store: ConsoleSessionStore;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [ConsoleProfileComponent],
|
imports: [ConsoleProfileComponent],
|
||||||
providers: [
|
providers: [
|
||||||
ConsoleSessionStore,
|
ConsoleSessionStore,
|
||||||
{ provide: ConsoleSessionService, useClass: MockConsoleSessionService },
|
{ provide: ConsoleSessionService, useClass: MockConsoleSessionService },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
service = TestBed.inject(
|
service = TestBed.inject(
|
||||||
ConsoleSessionService
|
ConsoleSessionService
|
||||||
) as unknown as MockConsoleSessionService;
|
) as unknown as MockConsoleSessionService;
|
||||||
store = TestBed.inject(ConsoleSessionStore);
|
store = TestBed.inject(ConsoleSessionStore);
|
||||||
fixture = TestBed.createComponent(ConsoleProfileComponent);
|
fixture = TestBed.createComponent(ConsoleProfileComponent);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders profile and tenant information', async () => {
|
it('renders profile and tenant information', async () => {
|
||||||
store.setContext({
|
store.setContext({
|
||||||
tenants: [
|
tenants: [
|
||||||
{
|
{
|
||||||
id: 'tenant-default',
|
id: 'tenant-default',
|
||||||
displayName: 'Tenant Default',
|
displayName: 'Tenant Default',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
isolationMode: 'shared',
|
isolationMode: 'shared',
|
||||||
defaultRoles: ['role.console'],
|
defaultRoles: ['role.console'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
selectedTenantId: 'tenant-default',
|
selectedTenantId: 'tenant-default',
|
||||||
profile: {
|
profile: {
|
||||||
subjectId: 'user-1',
|
subjectId: 'user-1',
|
||||||
username: 'user@example.com',
|
username: 'user@example.com',
|
||||||
displayName: 'Console User',
|
displayName: 'Console User',
|
||||||
tenant: 'tenant-default',
|
tenant: 'tenant-default',
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
roles: ['role.console'],
|
roles: ['role.console'],
|
||||||
scopes: ['ui.read'],
|
scopes: ['ui.read'],
|
||||||
audiences: ['console'],
|
audiences: ['console'],
|
||||||
authenticationMethods: ['pwd'],
|
authenticationMethods: ['pwd'],
|
||||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||||
freshAuth: true,
|
freshAuth: true,
|
||||||
},
|
},
|
||||||
token: {
|
token: {
|
||||||
active: true,
|
active: true,
|
||||||
tenant: 'tenant-default',
|
tenant: 'tenant-default',
|
||||||
subject: 'user-1',
|
subject: 'user-1',
|
||||||
clientId: 'console-web',
|
clientId: 'console-web',
|
||||||
tokenId: 'token-1',
|
tokenId: 'token-1',
|
||||||
scopes: ['ui.read'],
|
scopes: ['ui.read'],
|
||||||
audiences: ['console'],
|
audiences: ['console'],
|
||||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||||
freshAuthActive: true,
|
freshAuthActive: true,
|
||||||
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
expect(compiled.querySelector('h1')?.textContent).toContain(
|
expect(compiled.querySelector('h1')?.textContent).toContain(
|
||||||
'Console Session'
|
'Console Session'
|
||||||
);
|
);
|
||||||
expect(compiled.querySelector('.tenant-name')?.textContent).toContain(
|
expect(compiled.querySelector('.tenant-name')?.textContent).toContain(
|
||||||
'Tenant Default'
|
'Tenant Default'
|
||||||
);
|
);
|
||||||
expect(compiled.querySelector('dd')?.textContent).toContain('Console User');
|
expect(compiled.querySelector('dd')?.textContent).toContain('Console User');
|
||||||
expect(service.loadConsoleContext).not.toHaveBeenCalled();
|
expect(service.loadConsoleContext).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('invokes refresh on demand', async () => {
|
it('invokes refresh on demand', async () => {
|
||||||
store.clear();
|
store.clear();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
|
||||||
const button = fixture.nativeElement.querySelector(
|
const button = fixture.nativeElement.querySelector(
|
||||||
'button[type="button"]'
|
'button[type="button"]'
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
button.click();
|
button.click();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
|
||||||
expect(service.refresh).toHaveBeenCalled();
|
expect(service.refresh).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,70 +1,70 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
OnInit,
|
OnInit,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-console-profile',
|
selector: 'app-console-profile',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './console-profile.component.html',
|
templateUrl: './console-profile.component.html',
|
||||||
styleUrls: ['./console-profile.component.scss'],
|
styleUrls: ['./console-profile.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class ConsoleProfileComponent implements OnInit {
|
export class ConsoleProfileComponent implements OnInit {
|
||||||
private readonly store = inject(ConsoleSessionStore);
|
private readonly store = inject(ConsoleSessionStore);
|
||||||
private readonly service = inject(ConsoleSessionService);
|
private readonly service = inject(ConsoleSessionService);
|
||||||
|
|
||||||
readonly loading = this.store.loading;
|
readonly loading = this.store.loading;
|
||||||
readonly error = this.store.error;
|
readonly error = this.store.error;
|
||||||
readonly profile = this.store.profile;
|
readonly profile = this.store.profile;
|
||||||
readonly tokenInfo = this.store.tokenInfo;
|
readonly tokenInfo = this.store.tokenInfo;
|
||||||
readonly tenants = this.store.tenants;
|
readonly tenants = this.store.tenants;
|
||||||
readonly selectedTenantId = this.store.selectedTenantId;
|
readonly selectedTenantId = this.store.selectedTenantId;
|
||||||
|
|
||||||
readonly hasProfile = computed(() => this.profile() !== null);
|
readonly hasProfile = computed(() => this.profile() !== null);
|
||||||
readonly tenantCount = computed(() => this.tenants().length);
|
readonly tenantCount = computed(() => this.tenants().length);
|
||||||
readonly freshAuthState = computed(() => {
|
readonly freshAuthState = computed(() => {
|
||||||
const token = this.tokenInfo();
|
const token = this.tokenInfo();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
active: token.freshAuthActive,
|
active: token.freshAuthActive,
|
||||||
expiresAt: token.freshAuthExpiresAt,
|
expiresAt: token.freshAuthExpiresAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
if (!this.store.hasContext()) {
|
if (!this.store.hasContext()) {
|
||||||
try {
|
try {
|
||||||
await this.service.loadConsoleContext();
|
await this.service.loadConsoleContext();
|
||||||
} catch {
|
} catch {
|
||||||
// error surfaced via store
|
// error surfaced via store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh(): Promise<void> {
|
async refresh(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.service.refresh();
|
await this.service.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
// error surfaced via store
|
// error surfaced via store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async selectTenant(tenantId: string): Promise<void> {
|
async selectTenant(tenantId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.service.switchTenant(tenantId);
|
await this.service.switchTenant(tenantId);
|
||||||
} catch {
|
} catch {
|
||||||
// error surfaced via store
|
// error surfaced via store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ export interface DashboardAiData {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
background: #4f46e5;
|
background: #F5A623;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -293,7 +293,7 @@ export interface DashboardAiData {
|
|||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -302,7 +302,7 @@ export interface DashboardAiData {
|
|||||||
.ai-risk-drivers__evidence-link:hover,
|
.ai-risk-drivers__evidence-link:hover,
|
||||||
.ai-risk-drivers__action:hover {
|
.ai-risk-drivers__action:hover {
|
||||||
background: #eef2ff;
|
background: #eef2ff;
|
||||||
border-color: #a5b4fc;
|
border-color: #FFCF70;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-risk-drivers__empty {
|
.ai-risk-drivers__empty {
|
||||||
|
|||||||
@@ -1,350 +1,350 @@
|
|||||||
@use 'tokens/breakpoints' as *;
|
@use 'tokens/breakpoints' as *;
|
||||||
|
|
||||||
.sources-dashboard {
|
.sources-dashboard {
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-header {
|
.dashboard-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--font-size-2xl);
|
font-size: var(--font-size-2xl);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
|
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||||
border-color var(--motion-duration-fast) var(--motion-ease-default);
|
border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-primary {
|
&-primary {
|
||||||
background-color: var(--color-brand-primary);
|
background-color: var(--color-brand-primary);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background-color: var(--color-brand-primary-hover);
|
background-color: var(--color-brand-primary-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-secondary {
|
&-secondary {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-color: var(--color-border-primary);
|
border-color: var(--color-border-primary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background-color: var(--color-surface-secondary);
|
background-color: var(--color-surface-secondary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-state,
|
.loading-state,
|
||||||
.error-state {
|
.error-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--space-12);
|
padding: var(--space-12);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
border: 3px solid var(--color-border-primary);
|
border: 3px solid var(--color-border-primary);
|
||||||
border-top-color: var(--color-brand-primary);
|
border-top-color: var(--color-brand-primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
color: var(--color-status-error);
|
color: var(--color-status-error);
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics-grid {
|
.metrics-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile {
|
.tile {
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&-title {
|
&-title {
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--color-surface-secondary);
|
background: var(--color-surface-secondary);
|
||||||
border-bottom: 1px solid var(--color-border-primary);
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&-content {
|
&-content {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-pass-fail {
|
.tile-pass-fail {
|
||||||
&.excellent .metric-large .value { color: var(--color-status-success); }
|
&.excellent .metric-large .value { color: var(--color-status-success); }
|
||||||
&.good .metric-large .value { color: var(--color-status-success); }
|
&.good .metric-large .value { color: var(--color-status-success); }
|
||||||
&.warning .metric-large .value { color: var(--color-status-warning); }
|
&.warning .metric-large .value { color: var(--color-status-warning); }
|
||||||
&.critical .metric-large .value { color: var(--color-status-error); }
|
&.critical .metric-large .value { color: var(--color-status-error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-large {
|
.metric-large {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
font-size: var(--font-size-3xl);
|
font-size: var(--font-size-3xl);
|
||||||
font-weight: var(--font-weight-bold);
|
font-weight: var(--font-weight-bold);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-details {
|
.metric-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
padding-top: var(--space-4);
|
padding-top: var(--space-4);
|
||||||
border-top: 1px solid var(--color-border-primary);
|
border-top: 1px solid var(--color-border-primary);
|
||||||
|
|
||||||
.detail {
|
.detail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-xl);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
|
|
||||||
&.pass { color: var(--color-status-success); }
|
&.pass { color: var(--color-status-success); }
|
||||||
&.fail { color: var(--color-status-error); }
|
&.fail { color: var(--color-status-error); }
|
||||||
&.total { color: var(--color-text-primary); }
|
&.total { color: var(--color-text-primary); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.violations-list {
|
.violations-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.violation-item {
|
.violation-item {
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
background: var(--color-surface-secondary);
|
background: var(--color-surface-secondary);
|
||||||
|
|
||||||
&.severity-critical { border-left: 3px solid var(--color-severity-critical); }
|
&.severity-critical { border-left: 3px solid var(--color-severity-critical); }
|
||||||
&.severity-high { border-left: 3px solid var(--color-severity-high); }
|
&.severity-high { border-left: 3px solid var(--color-severity-high); }
|
||||||
&.severity-medium { border-left: 3px solid var(--color-severity-medium); }
|
&.severity-medium { border-left: 3px solid var(--color-severity-medium); }
|
||||||
&.severity-low { border-left: 3px solid var(--color-severity-low); }
|
&.severity-low { border-left: 3px solid var(--color-severity-low); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.violation-header {
|
.violation-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.violation-code {
|
.violation-code {
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.violation-count {
|
.violation-count {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.violation-desc {
|
.violation-desc {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
margin: 0 0 var(--space-1);
|
margin: 0 0 var(--space-1);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.violation-time {
|
.violation-time {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.throughput-grid {
|
.throughput-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.throughput-item {
|
.throughput-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
font-size: var(--font-size-2xl);
|
font-size: var(--font-size-2xl);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-throughput {
|
.tile-throughput {
|
||||||
&.critical .throughput-item .value { color: var(--color-status-error); }
|
&.critical .throughput-item .value { color: var(--color-status-error); }
|
||||||
&.warning .throughput-item .value { color: var(--color-status-warning); }
|
&.warning .throughput-item .value { color: var(--color-status-warning); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.verification-result {
|
.verification-result {
|
||||||
margin-top: var(--space-6);
|
margin-top: var(--space-6);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
|
|
||||||
&.status-passed { background: var(--color-status-success-bg); border-color: var(--color-status-success); }
|
&.status-passed { background: var(--color-status-success-bg); border-color: var(--color-status-success); }
|
||||||
&.status-failed { background: var(--color-status-error-bg); border-color: var(--color-status-error); }
|
&.status-failed { background: var(--color-status-error-bg); border-color: var(--color-status-error); }
|
||||||
&.status-partial { background: var(--color-status-warning-bg); border-color: var(--color-status-warning); }
|
&.status-partial { background: var(--color-status-warning-bg); border-color: var(--color-status-warning); }
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 0 var(--space-2);
|
margin: 0 0 var(--space-2);
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-summary {
|
.result-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
padding: var(--space-1) var(--space-2);
|
padding: var(--space-1) var(--space-2);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.status-passed .status-badge { background: var(--color-status-success); color: var(--color-text-inverse); }
|
&.status-passed .status-badge { background: var(--color-status-success); color: var(--color-text-inverse); }
|
||||||
&.status-failed .status-badge { background: var(--color-status-error); color: var(--color-text-inverse); }
|
&.status-failed .status-badge { background: var(--color-status-error); color: var(--color-text-inverse); }
|
||||||
&.status-partial .status-badge { background: var(--color-status-warning); color: var(--color-text-inverse); }
|
&.status-partial .status-badge { background: var(--color-status-warning); color: var(--color-text-inverse); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.violations-details {
|
.violations-details {
|
||||||
margin: var(--space-3) 0;
|
margin: var(--space-3) 0;
|
||||||
|
|
||||||
summary {
|
summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-brand-primary);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.violation-list {
|
.violation-list {
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
padding-left: var(--space-5);
|
padding-left: var(--space-5);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
|
||||||
li {
|
li {
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cli-hint {
|
.cli-hint {
|
||||||
margin: var(--space-3) 0 0;
|
margin: var(--space-3) 0 0;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|
||||||
code {
|
code {
|
||||||
background: var(--color-surface-tertiary);
|
background: var(--color-surface-tertiary);
|
||||||
padding: var(--space-0-5) var(--space-1-5);
|
padding: var(--space-0-5) var(--space-1-5);
|
||||||
border-radius: var(--radius-xs);
|
border-radius: var(--radius-xs);
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-window {
|
.time-window {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Responsive
|
// Responsive
|
||||||
@include screen-below-md {
|
@include screen-below-md {
|
||||||
.sources-dashboard {
|
.sources-dashboard {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-header {
|
.dashboard-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics-grid {
|
.metrics-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +1,111 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
OnInit,
|
OnInit,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { AocClient } from '../../core/api/aoc.client';
|
import { AocClient } from '../../core/api/aoc.client';
|
||||||
import {
|
import {
|
||||||
AocMetrics,
|
AocMetrics,
|
||||||
AocViolationSummary,
|
AocViolationSummary,
|
||||||
AocVerificationResult,
|
AocVerificationResult,
|
||||||
} from '../../core/api/aoc.models';
|
} from '../../core/api/aoc.models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-sources-dashboard',
|
selector: 'app-sources-dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './sources-dashboard.component.html',
|
templateUrl: './sources-dashboard.component.html',
|
||||||
styleUrls: ['./sources-dashboard.component.scss'],
|
styleUrls: ['./sources-dashboard.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class SourcesDashboardComponent implements OnInit {
|
export class SourcesDashboardComponent implements OnInit {
|
||||||
private readonly aocClient = inject(AocClient);
|
private readonly aocClient = inject(AocClient);
|
||||||
|
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
readonly metrics = signal<AocMetrics | null>(null);
|
readonly metrics = signal<AocMetrics | null>(null);
|
||||||
readonly verifying = signal(false);
|
readonly verifying = signal(false);
|
||||||
readonly verificationResult = signal<AocVerificationResult | null>(null);
|
readonly verificationResult = signal<AocVerificationResult | null>(null);
|
||||||
|
|
||||||
readonly passRate = computed(() => {
|
readonly passRate = computed(() => {
|
||||||
const m = this.metrics();
|
const m = this.metrics();
|
||||||
return m ? m.passRate.toFixed(2) : '0.00';
|
return m ? m.passRate.toFixed(2) : '0.00';
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly passRateClass = computed(() => {
|
readonly passRateClass = computed(() => {
|
||||||
const m = this.metrics();
|
const m = this.metrics();
|
||||||
if (!m) return 'neutral';
|
if (!m) return 'neutral';
|
||||||
if (m.passRate >= 99.5) return 'excellent';
|
if (m.passRate >= 99.5) return 'excellent';
|
||||||
if (m.passRate >= 95) return 'good';
|
if (m.passRate >= 95) return 'good';
|
||||||
if (m.passRate >= 90) return 'warning';
|
if (m.passRate >= 90) return 'warning';
|
||||||
return 'critical';
|
return 'critical';
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly throughputStatus = computed(() => {
|
readonly throughputStatus = computed(() => {
|
||||||
const m = this.metrics();
|
const m = this.metrics();
|
||||||
if (!m) return 'neutral';
|
if (!m) return 'neutral';
|
||||||
if (m.ingestThroughput.queueDepth > 100) return 'critical';
|
if (m.ingestThroughput.queueDepth > 100) return 'critical';
|
||||||
if (m.ingestThroughput.queueDepth > 50) return 'warning';
|
if (m.ingestThroughput.queueDepth > 50) return 'warning';
|
||||||
return 'good';
|
return 'good';
|
||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadMetrics();
|
this.loadMetrics();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadMetrics(): void {
|
loadMetrics(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
|
|
||||||
this.aocClient.getMetrics('default').subscribe({
|
this.aocClient.getMetrics('default').subscribe({
|
||||||
next: (metrics) => {
|
next: (metrics) => {
|
||||||
this.metrics.set(metrics);
|
this.metrics.set(metrics);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.error.set('Failed to load AOC metrics');
|
this.error.set('Failed to load AOC metrics');
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
console.error('AOC metrics error:', err);
|
console.error('AOC metrics error:', err);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onVerifyLast24h(): void {
|
onVerifyLast24h(): void {
|
||||||
this.verifying.set(true);
|
this.verifying.set(true);
|
||||||
this.verificationResult.set(null);
|
this.verificationResult.set(null);
|
||||||
|
|
||||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||||
this.aocClient.verify({ tenantId: 'default', since }).subscribe({
|
this.aocClient.verify({ tenantId: 'default', since }).subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.verificationResult.set(result);
|
this.verificationResult.set(result);
|
||||||
this.verifying.set(false);
|
this.verifying.set(false);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.verifying.set(false);
|
this.verifying.set(false);
|
||||||
console.error('AOC verification error:', err);
|
console.error('AOC verification error:', err);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getSeverityClass(severity: AocViolationSummary['severity']): string {
|
getSeverityClass(severity: AocViolationSummary['severity']): string {
|
||||||
return 'severity-' + severity;
|
return 'severity-' + severity;
|
||||||
}
|
}
|
||||||
|
|
||||||
formatRelativeTime(isoDate: string): string {
|
formatRelativeTime(isoDate: string): string {
|
||||||
const date = new Date(isoDate);
|
const date = new Date(isoDate);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffMs = now.getTime() - date.getTime();
|
const diffMs = now.getTime() - date.getTime();
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
|
||||||
if (diffMins < 1) return 'just now';
|
if (diffMins < 1) return 'just now';
|
||||||
if (diffMins < 60) return diffMins + 'm ago';
|
if (diffMins < 60) return diffMins + 'm ago';
|
||||||
const diffHours = Math.floor(diffMins / 60);
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
if (diffHours < 24) return diffHours + 'h ago';
|
if (diffHours < 24) return diffHours + 'h ago';
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
return diffDays + 'd ago';
|
return diffDays + 'd ago';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,200 +1,200 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
|
||||||
import { EvidenceData } from '../../core/api/evidence.models';
|
import { EvidenceData } from '../../core/api/evidence.models';
|
||||||
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
|
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
|
||||||
import { EvidencePanelComponent } from './evidence-panel.component';
|
import { EvidencePanelComponent } from './evidence-panel.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-evidence-page',
|
selector: 'app-evidence-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, EvidencePanelComponent],
|
imports: [CommonModule, EvidencePanelComponent],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
|
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="evidence-page">
|
<div class="evidence-page">
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="evidence-page__loading">
|
<div class="evidence-page__loading">
|
||||||
<div class="spinner" aria-label="Loading evidence"></div>
|
<div class="spinner" aria-label="Loading evidence"></div>
|
||||||
<p>Loading evidence for {{ advisoryId() }}...</p>
|
<p>Loading evidence for {{ advisoryId() }}...</p>
|
||||||
</div>
|
</div>
|
||||||
} @else if (error()) {
|
} @else if (error()) {
|
||||||
<div class="evidence-page__error" role="alert">
|
<div class="evidence-page__error" role="alert">
|
||||||
<h2>Error Loading Evidence</h2>
|
<h2>Error Loading Evidence</h2>
|
||||||
<p>{{ error() }}</p>
|
<p>{{ error() }}</p>
|
||||||
<button type="button" (click)="reload()">Retry</button>
|
<button type="button" (click)="reload()">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
} @else if (evidenceData()) {
|
} @else if (evidenceData()) {
|
||||||
<app-evidence-panel
|
<app-evidence-panel
|
||||||
[advisoryId]="advisoryId()"
|
[advisoryId]="advisoryId()"
|
||||||
[evidenceData]="evidenceData()"
|
[evidenceData]="evidenceData()"
|
||||||
(close)="onClose()"
|
(close)="onClose()"
|
||||||
(downloadDocument)="onDownload($event)"
|
(downloadDocument)="onDownload($event)"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="evidence-page__empty">
|
<div class="evidence-page__empty">
|
||||||
<h2>No Advisory ID</h2>
|
<h2>No Advisory ID</h2>
|
||||||
<p>Please provide an advisory ID to view evidence.</p>
|
<p>Please provide an advisory ID to view evidence.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
.evidence-page {
|
.evidence-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-page__loading,
|
.evidence-page__loading,
|
||||||
.evidence-page__error,
|
.evidence-page__error,
|
||||||
.evidence-page__empty {
|
.evidence-page__empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 4rem 2rem;
|
padding: 4rem 2rem;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-page__loading .spinner {
|
.evidence-page__loading .spinner {
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
border: 3px solid #e5e7eb;
|
border: 3px solid #e5e7eb;
|
||||||
border-top-color: #3b82f6;
|
border-top-color: #3b82f6;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-page__loading p {
|
.evidence-page__loading p {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-page__error {
|
.evidence-page__error {
|
||||||
border: 1px solid #fca5a5;
|
border: 1px solid #fca5a5;
|
||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-page__error h2 {
|
.evidence-page__error h2 {
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-page__error p {
|
.evidence-page__error p {
|
||||||
color: #991b1b;
|
color: #991b1b;
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-page__error button {
|
.evidence-page__error button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: 1px solid #dc2626;
|
border: 1px solid #dc2626;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-page__error button:hover {
|
.evidence-page__error button:hover {
|
||||||
background: #fee2e2;
|
background: #fee2e2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-page__empty h2 {
|
.evidence-page__empty h2 {
|
||||||
color: #374151;
|
color: #374151;
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-page__empty p {
|
.evidence-page__empty p {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`],
|
`],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class EvidencePageComponent {
|
export class EvidencePageComponent {
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly evidenceApi = inject(EVIDENCE_API);
|
private readonly evidenceApi = inject(EVIDENCE_API);
|
||||||
|
|
||||||
readonly advisoryId = signal<string>('');
|
readonly advisoryId = signal<string>('');
|
||||||
readonly evidenceData = signal<EvidenceData | null>(null);
|
readonly evidenceData = signal<EvidenceData | null>(null);
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// React to route param changes
|
// React to route param changes
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const params = this.route.snapshot.paramMap;
|
const params = this.route.snapshot.paramMap;
|
||||||
const id = params.get('advisoryId');
|
const id = params.get('advisoryId');
|
||||||
if (id) {
|
if (id) {
|
||||||
this.advisoryId.set(id);
|
this.advisoryId.set(id);
|
||||||
this.loadEvidence(id);
|
this.loadEvidence(id);
|
||||||
}
|
}
|
||||||
}, { allowSignalWrites: true });
|
}, { allowSignalWrites: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadEvidence(advisoryId: string): void {
|
private loadEvidence(advisoryId: string): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
|
|
||||||
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
|
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
|
||||||
next: (data) => {
|
next: (data) => {
|
||||||
this.evidenceData.set(data);
|
this.evidenceData.set(data);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.error.set(err.message ?? 'Failed to load evidence');
|
this.error.set(err.message ?? 'Failed to load evidence');
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reload(): void {
|
reload(): void {
|
||||||
const id = this.advisoryId();
|
const id = this.advisoryId();
|
||||||
if (id) {
|
if (id) {
|
||||||
this.loadEvidence(id);
|
this.loadEvidence(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose(): void {
|
onClose(): void {
|
||||||
this.router.navigate(['/vulnerabilities']);
|
this.router.navigate(['/vulnerabilities']);
|
||||||
}
|
}
|
||||||
|
|
||||||
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
|
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
|
||||||
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
|
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
|
||||||
next: (blob) => {
|
next: (blob) => {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${event.type}-${event.id}.json`;
|
a.download = `${event.type}-${event.id}.json`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Download failed:', err);
|
console.error('Download failed:', err);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { EvidencePanelComponent } from './evidence-panel.component';
|
||||||
export { EvidencePageComponent } from './evidence-page.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 { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
Exception,
|
Exception,
|
||||||
ExceptionStatus,
|
ExceptionStatus,
|
||||||
ExceptionType,
|
ExceptionType,
|
||||||
ExceptionFilter,
|
ExceptionFilter,
|
||||||
ExceptionSortOption,
|
ExceptionSortOption,
|
||||||
ExceptionTransition,
|
ExceptionTransition,
|
||||||
EXCEPTION_TRANSITIONS,
|
EXCEPTION_TRANSITIONS,
|
||||||
KANBAN_COLUMNS,
|
KANBAN_COLUMNS,
|
||||||
} from '../../core/api/exception.models';
|
} from '../../core/api/exception.models';
|
||||||
|
|
||||||
type ViewMode = 'list' | 'kanban';
|
type ViewMode = 'list' | 'kanban';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-exception-center',
|
selector: 'app-exception-center',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './exception-center.component.html',
|
templateUrl: './exception-center.component.html',
|
||||||
styleUrls: ['./exception-center.component.scss'],
|
styleUrls: ['./exception-center.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class ExceptionCenterComponent {
|
export class ExceptionCenterComponent {
|
||||||
/** All exceptions */
|
/** All exceptions */
|
||||||
readonly exceptions = input.required<Exception[]>();
|
readonly exceptions = input.required<Exception[]>();
|
||||||
|
|
||||||
/** Current user role for transition permissions */
|
/** Current user role for transition permissions */
|
||||||
readonly userRole = input<string>('user');
|
readonly userRole = input<string>('user');
|
||||||
|
|
||||||
/** Emits when creating new exception */
|
/** Emits when creating new exception */
|
||||||
readonly create = output<void>();
|
readonly create = output<void>();
|
||||||
|
|
||||||
/** Emits when selecting an exception */
|
/** Emits when selecting an exception */
|
||||||
readonly select = output<Exception>();
|
readonly select = output<Exception>();
|
||||||
|
|
||||||
/** Emits when performing a workflow transition */
|
/** Emits when performing a workflow transition */
|
||||||
readonly transition = output<{ exception: Exception; to: ExceptionStatus }>();
|
readonly transition = output<{ exception: Exception; to: ExceptionStatus }>();
|
||||||
|
|
||||||
/** Emits when viewing audit log */
|
/** Emits when viewing audit log */
|
||||||
readonly viewAudit = output<Exception>();
|
readonly viewAudit = output<Exception>();
|
||||||
|
|
||||||
readonly viewMode = signal<ViewMode>('list');
|
readonly viewMode = signal<ViewMode>('list');
|
||||||
readonly filter = signal<ExceptionFilter>({});
|
readonly filter = signal<ExceptionFilter>({});
|
||||||
readonly sort = signal<ExceptionSortOption>({ field: 'updatedAt', direction: 'desc' });
|
readonly sort = signal<ExceptionSortOption>({ field: 'updatedAt', direction: 'desc' });
|
||||||
readonly expandedId = signal<string | null>(null);
|
readonly expandedId = signal<string | null>(null);
|
||||||
readonly showFilters = signal(false);
|
readonly showFilters = signal(false);
|
||||||
|
|
||||||
readonly kanbanColumns = KANBAN_COLUMNS;
|
readonly kanbanColumns = KANBAN_COLUMNS;
|
||||||
|
|
||||||
readonly filteredExceptions = computed(() => {
|
readonly filteredExceptions = computed(() => {
|
||||||
let result = [...this.exceptions()];
|
let result = [...this.exceptions()];
|
||||||
const f = this.filter();
|
const f = this.filter();
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
if (f.status && f.status.length > 0) {
|
if (f.status && f.status.length > 0) {
|
||||||
result = result.filter((e) => f.status!.includes(e.status));
|
result = result.filter((e) => f.status!.includes(e.status));
|
||||||
}
|
}
|
||||||
if (f.type && f.type.length > 0) {
|
if (f.type && f.type.length > 0) {
|
||||||
result = result.filter((e) => f.type!.includes(e.type));
|
result = result.filter((e) => f.type!.includes(e.type));
|
||||||
}
|
}
|
||||||
if (f.severity && f.severity.length > 0) {
|
if (f.severity && f.severity.length > 0) {
|
||||||
result = result.filter((e) => f.severity!.includes(e.severity));
|
result = result.filter((e) => f.severity!.includes(e.severity));
|
||||||
}
|
}
|
||||||
if (f.search) {
|
if (f.search) {
|
||||||
const search = f.search.toLowerCase();
|
const search = f.search.toLowerCase();
|
||||||
result = result.filter(
|
result = result.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
e.title.toLowerCase().includes(search) ||
|
e.title.toLowerCase().includes(search) ||
|
||||||
e.justification.toLowerCase().includes(search) ||
|
e.justification.toLowerCase().includes(search) ||
|
||||||
e.id.toLowerCase().includes(search)
|
e.id.toLowerCase().includes(search)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (f.tags && f.tags.length > 0) {
|
if (f.tags && f.tags.length > 0) {
|
||||||
result = result.filter((e) => f.tags!.some((t) => e.tags.includes(t)));
|
result = result.filter((e) => f.tags!.some((t) => e.tags.includes(t)));
|
||||||
}
|
}
|
||||||
if (f.expiringSoon) {
|
if (f.expiringSoon) {
|
||||||
result = result.filter((e) => e.timebox.isWarning && !e.timebox.isExpired);
|
result = result.filter((e) => e.timebox.isWarning && !e.timebox.isExpired);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply sort
|
// Apply sort
|
||||||
const s = this.sort();
|
const s = this.sort();
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
let cmp = 0;
|
let cmp = 0;
|
||||||
switch (s.field) {
|
switch (s.field) {
|
||||||
case 'createdAt':
|
case 'createdAt':
|
||||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||||
break;
|
break;
|
||||||
case 'updatedAt':
|
case 'updatedAt':
|
||||||
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||||
break;
|
break;
|
||||||
case 'expiresAt':
|
case 'expiresAt':
|
||||||
cmp = new Date(a.timebox.expiresAt).getTime() - new Date(b.timebox.expiresAt).getTime();
|
cmp = new Date(a.timebox.expiresAt).getTime() - new Date(b.timebox.expiresAt).getTime();
|
||||||
break;
|
break;
|
||||||
case 'severity':
|
case 'severity':
|
||||||
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||||
cmp = sevOrder[a.severity] - sevOrder[b.severity];
|
cmp = sevOrder[a.severity] - sevOrder[b.severity];
|
||||||
break;
|
break;
|
||||||
case 'title':
|
case 'title':
|
||||||
cmp = a.title.localeCompare(b.title);
|
cmp = a.title.localeCompare(b.title);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return s.direction === 'asc' ? cmp : -cmp;
|
return s.direction === 'asc' ? cmp : -cmp;
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly exceptionsByStatus = computed(() => {
|
readonly exceptionsByStatus = computed(() => {
|
||||||
const byStatus = new Map<ExceptionStatus, Exception[]>();
|
const byStatus = new Map<ExceptionStatus, Exception[]>();
|
||||||
for (const col of KANBAN_COLUMNS) {
|
for (const col of KANBAN_COLUMNS) {
|
||||||
byStatus.set(col.status, []);
|
byStatus.set(col.status, []);
|
||||||
}
|
}
|
||||||
for (const exc of this.filteredExceptions()) {
|
for (const exc of this.filteredExceptions()) {
|
||||||
const list = byStatus.get(exc.status) || [];
|
const list = byStatus.get(exc.status) || [];
|
||||||
list.push(exc);
|
list.push(exc);
|
||||||
byStatus.set(exc.status, list);
|
byStatus.set(exc.status, list);
|
||||||
}
|
}
|
||||||
return byStatus;
|
return byStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly statusCounts = computed(() => {
|
readonly statusCounts = computed(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const exc of this.exceptions()) {
|
for (const exc of this.exceptions()) {
|
||||||
counts[exc.status] = (counts[exc.status] || 0) + 1;
|
counts[exc.status] = (counts[exc.status] || 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly allTags = computed(() => {
|
readonly allTags = computed(() => {
|
||||||
const tags = new Set<string>();
|
const tags = new Set<string>();
|
||||||
for (const exc of this.exceptions()) {
|
for (const exc of this.exceptions()) {
|
||||||
for (const tag of exc.tags) {
|
for (const tag of exc.tags) {
|
||||||
tags.add(tag);
|
tags.add(tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(tags).sort();
|
return Array.from(tags).sort();
|
||||||
});
|
});
|
||||||
|
|
||||||
setViewMode(mode: ViewMode): void {
|
setViewMode(mode: ViewMode): void {
|
||||||
this.viewMode.set(mode);
|
this.viewMode.set(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleFilters(): void {
|
toggleFilters(): void {
|
||||||
this.showFilters.update((v) => !v);
|
this.showFilters.update((v) => !v);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleStatusFilter(status: ExceptionStatus): void {
|
toggleStatusFilter(status: ExceptionStatus): void {
|
||||||
const current = this.filter().status || [];
|
const current = this.filter().status || [];
|
||||||
const newStatuses = current.includes(status)
|
const newStatuses = current.includes(status)
|
||||||
? current.filter((s) => s !== status)
|
? current.filter((s) => s !== status)
|
||||||
: [...current, status];
|
: [...current, status];
|
||||||
this.updateFilter('status', newStatuses.length > 0 ? newStatuses : undefined);
|
this.updateFilter('status', newStatuses.length > 0 ? newStatuses : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleTypeFilter(type: ExceptionType): void {
|
toggleTypeFilter(type: ExceptionType): void {
|
||||||
const current = this.filter().type || [];
|
const current = this.filter().type || [];
|
||||||
const newTypes = current.includes(type)
|
const newTypes = current.includes(type)
|
||||||
? current.filter((t) => t !== type)
|
? current.filter((t) => t !== type)
|
||||||
: [...current, type];
|
: [...current, type];
|
||||||
this.updateFilter('type', newTypes.length > 0 ? newTypes : undefined);
|
this.updateFilter('type', newTypes.length > 0 ? newTypes : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSeverityFilter(severity: string): void {
|
toggleSeverityFilter(severity: string): void {
|
||||||
const current = this.filter().severity || [];
|
const current = this.filter().severity || [];
|
||||||
const newSeverities = current.includes(severity)
|
const newSeverities = current.includes(severity)
|
||||||
? current.filter((s) => s !== severity)
|
? current.filter((s) => s !== severity)
|
||||||
: [...current, severity];
|
: [...current, severity];
|
||||||
this.updateFilter('severity', newSeverities.length > 0 ? newSeverities : undefined);
|
this.updateFilter('severity', newSeverities.length > 0 ? newSeverities : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleTagFilter(tag: string): void {
|
toggleTagFilter(tag: string): void {
|
||||||
const current = this.filter().tags || [];
|
const current = this.filter().tags || [];
|
||||||
const newTags = current.includes(tag)
|
const newTags = current.includes(tag)
|
||||||
? current.filter((t) => t !== tag)
|
? current.filter((t) => t !== tag)
|
||||||
: [...current, tag];
|
: [...current, tag];
|
||||||
this.updateFilter('tags', newTags.length > 0 ? newTags : undefined);
|
this.updateFilter('tags', newTags.length > 0 ? newTags : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFilter(key: keyof ExceptionFilter, value: unknown): void {
|
updateFilter(key: keyof ExceptionFilter, value: unknown): void {
|
||||||
this.filter.update((f) => ({ ...f, [key]: value }));
|
this.filter.update((f) => ({ ...f, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
clearFilters(): void {
|
clearFilters(): void {
|
||||||
this.filter.set({});
|
this.filter.set({});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSort(field: ExceptionSortOption['field']): void {
|
setSort(field: ExceptionSortOption['field']): void {
|
||||||
this.sort.update((s) => ({
|
this.sort.update((s) => ({
|
||||||
field,
|
field,
|
||||||
direction: s.field === field && s.direction === 'desc' ? 'asc' : 'desc',
|
direction: s.field === field && s.direction === 'desc' ? 'asc' : 'desc',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleExpand(id: string): void {
|
toggleExpand(id: string): void {
|
||||||
this.expandedId.update((current) => (current === id ? null : id));
|
this.expandedId.update((current) => (current === id ? null : id));
|
||||||
}
|
}
|
||||||
|
|
||||||
onCreate(): void {
|
onCreate(): void {
|
||||||
this.create.emit();
|
this.create.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelect(exc: Exception): void {
|
onSelect(exc: Exception): void {
|
||||||
this.select.emit(exc);
|
this.select.emit(exc);
|
||||||
}
|
}
|
||||||
|
|
||||||
onTransition(exc: Exception, to: ExceptionStatus): void {
|
onTransition(exc: Exception, to: ExceptionStatus): void {
|
||||||
this.transition.emit({ exception: exc, to });
|
this.transition.emit({ exception: exc, to });
|
||||||
}
|
}
|
||||||
|
|
||||||
onViewAudit(exc: Exception): void {
|
onViewAudit(exc: Exception): void {
|
||||||
this.viewAudit.emit(exc);
|
this.viewAudit.emit(exc);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAvailableTransitions(exc: Exception): ExceptionTransition[] {
|
getAvailableTransitions(exc: Exception): ExceptionTransition[] {
|
||||||
return EXCEPTION_TRANSITIONS.filter(
|
return EXCEPTION_TRANSITIONS.filter(
|
||||||
(t) => t.from === exc.status && t.allowedRoles.includes(this.userRole())
|
(t) => t.from === exc.status && t.allowedRoles.includes(this.userRole())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getStatusIcon(status: ExceptionStatus): string {
|
getStatusIcon(status: ExceptionStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'draft':
|
case 'draft':
|
||||||
return '[D]';
|
return '[D]';
|
||||||
case 'pending_review':
|
case 'pending_review':
|
||||||
return '[?]';
|
return '[?]';
|
||||||
case 'approved':
|
case 'approved':
|
||||||
return '[+]';
|
return '[+]';
|
||||||
case 'rejected':
|
case 'rejected':
|
||||||
return '[~]';
|
return '[~]';
|
||||||
case 'expired':
|
case 'expired':
|
||||||
return '[X]';
|
return '[X]';
|
||||||
case 'revoked':
|
case 'revoked':
|
||||||
return '[!]';
|
return '[!]';
|
||||||
default:
|
default:
|
||||||
return '[-]';
|
return '[-]';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getTypeIcon(type: ExceptionType): string {
|
getTypeIcon(type: ExceptionType): string {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'vulnerability':
|
case 'vulnerability':
|
||||||
return 'V';
|
return 'V';
|
||||||
case 'license':
|
case 'license':
|
||||||
return 'L';
|
return 'L';
|
||||||
case 'policy':
|
case 'policy':
|
||||||
return 'P';
|
return 'P';
|
||||||
case 'entropy':
|
case 'entropy':
|
||||||
return 'E';
|
return 'E';
|
||||||
case 'determinism':
|
case 'determinism':
|
||||||
return 'D';
|
return 'D';
|
||||||
default:
|
default:
|
||||||
return '?';
|
return '?';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getSeverityClass(severity: string): string {
|
getSeverityClass(severity: string): string {
|
||||||
return 'severity-' + severity;
|
return 'severity-' + severity;
|
||||||
}
|
}
|
||||||
|
|
||||||
formatDate(dateStr: string): string {
|
formatDate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString();
|
return new Date(dateStr).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
formatRemainingDays(days: number): string {
|
formatRemainingDays(days: number): string {
|
||||||
if (days < 0) return 'Expired';
|
if (days < 0) return 'Expired';
|
||||||
if (days === 0) return 'Expires today';
|
if (days === 0) return 'Expires today';
|
||||||
if (days === 1) return '1 day left';
|
if (days === 1) return '1 day left';
|
||||||
return days + ' days left';
|
return days + ' days left';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,441 +1,441 @@
|
|||||||
@use 'tokens/breakpoints' as *;
|
@use 'tokens/breakpoints' as *;
|
||||||
|
|
||||||
.draft-inline {
|
.draft-inline {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
.draft-inline__header {
|
.draft-inline__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding-bottom: var(--space-3);
|
padding-bottom: var(--space-3);
|
||||||
border-bottom: 1px solid var(--color-border-secondary);
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.draft-inline__source {
|
.draft-inline__source {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error
|
// Error
|
||||||
.draft-inline__error {
|
.draft-inline__error {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
background: var(--color-status-error-bg);
|
background: var(--color-status-error-bg);
|
||||||
color: var(--color-status-error);
|
color: var(--color-status-error);
|
||||||
border: 1px solid var(--color-status-error);
|
border: 1px solid var(--color-status-error);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope
|
// Scope
|
||||||
.draft-inline__scope {
|
.draft-inline__scope {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
background: var(--color-surface-secondary);
|
background: var(--color-surface-secondary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.draft-inline__scope-label {
|
.draft-inline__scope-label {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.draft-inline__scope-value {
|
.draft-inline__scope-value {
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.draft-inline__scope-type {
|
.draft-inline__scope-type {
|
||||||
padding: var(--space-0-5) var(--space-2);
|
padding: var(--space-0-5) var(--space-2);
|
||||||
background: var(--color-brand-light);
|
background: var(--color-brand-light);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-brand-primary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vulnerabilities preview
|
// Vulnerabilities preview
|
||||||
.draft-inline__vulns,
|
.draft-inline__vulns,
|
||||||
.draft-inline__components {
|
.draft-inline__components {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1-5);
|
gap: var(--space-1-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.draft-inline__vulns-label,
|
.draft-inline__vulns-label,
|
||||||
.draft-inline__components-label {
|
.draft-inline__components-label {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.draft-inline__vulns-list,
|
.draft-inline__vulns-list,
|
||||||
.draft-inline__components-list {
|
.draft-inline__components-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-1-5);
|
gap: var(--space-1-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vuln-chip {
|
.vuln-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: var(--space-0-5) var(--space-2);
|
padding: var(--space-0-5) var(--space-2);
|
||||||
background: var(--color-status-error-bg);
|
background: var(--color-status-error-bg);
|
||||||
color: var(--color-status-error);
|
color: var(--color-status-error);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
|
|
||||||
&--more {
|
&--more {
|
||||||
background: var(--color-surface-tertiary);
|
background: var(--color-surface-tertiary);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.component-chip {
|
.component-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: var(--space-0-5) var(--space-2);
|
padding: var(--space-0-5) var(--space-2);
|
||||||
background: var(--color-status-success-bg);
|
background: var(--color-status-success-bg);
|
||||||
color: var(--color-status-success);
|
color: var(--color-status-success);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&--more {
|
&--more {
|
||||||
background: var(--color-surface-tertiary);
|
background: var(--color-surface-tertiary);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form
|
// Form
|
||||||
.draft-inline__form {
|
.draft-inline__form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3-5);
|
gap: var(--space-3-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
|
|
||||||
&--inline {
|
&--inline {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.required {
|
.required {
|
||||||
color: var(--color-status-error);
|
color: var(--color-status-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
padding: var(--space-2) var(--space-2-5);
|
padding: var(--space-2) var(--space-2-5);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default);
|
transition: border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
box-shadow: 0 0 0 2px var(--color-brand-light);
|
box-shadow: 0 0 0 2px var(--color-brand-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--small {
|
&--small {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-textarea {
|
.form-textarea {
|
||||||
padding: var(--space-2) var(--space-2-5);
|
padding: var(--space-2) var(--space-2-5);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
box-shadow: 0 0 0 2px var(--color-brand-light);
|
box-shadow: 0 0 0 2px var(--color-brand-light);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-hint {
|
.form-hint {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Severity chips
|
// Severity chips
|
||||||
.severity-chips {
|
.severity-chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-1-5);
|
gap: var(--space-1-5);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.severity-option {
|
.severity-option {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--selected .severity-chip {
|
&--selected .severity-chip {
|
||||||
box-shadow: 0 0 0 2px currentColor;
|
box-shadow: 0 0 0 2px currentColor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.severity-chip {
|
.severity-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: var(--space-1) var(--space-2-5);
|
padding: var(--space-1) var(--space-2-5);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
transition: box-shadow var(--motion-duration-fast) var(--motion-ease-default);
|
transition: box-shadow var(--motion-duration-fast) var(--motion-ease-default);
|
||||||
|
|
||||||
&--critical {
|
&--critical {
|
||||||
background: var(--color-severity-critical-bg);
|
background: var(--color-severity-critical-bg);
|
||||||
color: var(--color-severity-critical);
|
color: var(--color-severity-critical);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--high {
|
&--high {
|
||||||
background: var(--color-severity-high-bg);
|
background: var(--color-severity-high-bg);
|
||||||
color: var(--color-severity-high);
|
color: var(--color-severity-high);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--medium {
|
&--medium {
|
||||||
background: var(--color-severity-medium-bg);
|
background: var(--color-severity-medium-bg);
|
||||||
color: var(--color-severity-medium);
|
color: var(--color-severity-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--low {
|
&--low {
|
||||||
background: var(--color-status-success-bg);
|
background: var(--color-status-success-bg);
|
||||||
color: var(--color-status-success);
|
color: var(--color-status-success);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Template chips
|
// Template chips
|
||||||
.template-chips {
|
.template-chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-1-5);
|
gap: var(--space-1-5);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-chip {
|
.template-chip {
|
||||||
padding: var(--space-1) var(--space-2-5);
|
padding: var(--space-1) var(--space-2-5);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--motion-duration-fast) var(--motion-ease-default);
|
transition: all var(--motion-duration-fast) var(--motion-ease-default);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-surface-secondary);
|
background: var(--color-surface-secondary);
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--selected {
|
&--selected {
|
||||||
background: var(--color-brand-light);
|
background: var(--color-brand-light);
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-brand-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timebox
|
// Timebox
|
||||||
.timebox-quick {
|
.timebox-quick {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-1-5);
|
gap: var(--space-1-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timebox-btn {
|
.timebox-btn {
|
||||||
padding: var(--space-1) var(--space-2);
|
padding: var(--space-1) var(--space-2);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-surface-tertiary);
|
background: var(--color-surface-tertiary);
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-brand-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timebox-label {
|
.timebox-label {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulation
|
// Simulation
|
||||||
.draft-inline__simulation {
|
.draft-inline__simulation {
|
||||||
padding-top: var(--space-3);
|
padding-top: var(--space-3);
|
||||||
border-top: 1px solid var(--color-border-secondary);
|
border-top: 1px solid var(--color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.simulation-toggle {
|
.simulation-toggle {
|
||||||
padding: var(--space-1-5) var(--space-3);
|
padding: var(--space-1-5) var(--space-3);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-brand-primary);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-brand-light);
|
background: var(--color-brand-light);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.simulation-result {
|
.simulation-result {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-top: var(--space-3);
|
margin-top: var(--space-3);
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
background: var(--color-surface-secondary);
|
background: var(--color-surface-secondary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.simulation-stat {
|
.simulation-stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-0-5);
|
gap: var(--space-0-5);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.simulation-stat__label {
|
.simulation-stat__label {
|
||||||
font-size: 0.625rem;
|
font-size: 0.625rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.simulation-stat__value {
|
.simulation-stat__value {
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
&--high {
|
&--high {
|
||||||
color: var(--color-status-error);
|
color: var(--color-status-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--moderate {
|
&--moderate {
|
||||||
color: var(--color-status-warning);
|
color: var(--color-status-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--low {
|
&--low {
|
||||||
color: var(--color-status-success);
|
color: var(--color-status-success);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
.draft-inline__footer {
|
.draft-inline__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding-top: var(--space-3);
|
padding-top: var(--space-3);
|
||||||
border-top: 1px solid var(--color-border-secondary);
|
border-top: 1px solid var(--color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--motion-duration-fast) var(--motion-ease-default);
|
transition: all var(--motion-duration-fast) var(--motion-ease-default);
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--primary {
|
&--primary {
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-brand-primary);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: var(--color-brand-primary-hover);
|
background: var(--color-brand-primary-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--secondary {
|
&--secondary {
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: var(--color-surface-secondary);
|
background: var(--color-surface-secondary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--text {
|
&--text {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Responsive
|
// Responsive
|
||||||
@include screen-below-sm {
|
@include screen-below-sm {
|
||||||
.simulation-result {
|
.simulation-result {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.draft-inline__footer {
|
.draft-inline__footer {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.severity-chips,
|
.severity-chips,
|
||||||
.template-chips {
|
.template-chips {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from '@angular/forms';
|
} from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EXCEPTION_API,
|
EXCEPTION_API,
|
||||||
ExceptionApi,
|
ExceptionApi,
|
||||||
@@ -27,36 +27,36 @@ import {
|
|||||||
ExceptionSeverity,
|
ExceptionSeverity,
|
||||||
ExceptionScopeType,
|
ExceptionScopeType,
|
||||||
} from '../../core/api/exception.contract.models';
|
} from '../../core/api/exception.contract.models';
|
||||||
|
|
||||||
export interface ExceptionDraftContext {
|
export interface ExceptionDraftContext {
|
||||||
readonly vulnIds?: readonly string[];
|
readonly vulnIds?: readonly string[];
|
||||||
readonly componentPurls?: readonly string[];
|
readonly componentPurls?: readonly string[];
|
||||||
readonly assetIds?: readonly string[];
|
readonly assetIds?: readonly string[];
|
||||||
readonly tenantId?: string;
|
readonly tenantId?: string;
|
||||||
readonly suggestedName?: string;
|
readonly suggestedName?: string;
|
||||||
readonly suggestedSeverity?: ExceptionSeverity;
|
readonly suggestedSeverity?: ExceptionSeverity;
|
||||||
readonly sourceType: 'vulnerability' | 'component' | 'asset' | 'graph';
|
readonly sourceType: 'vulnerability' | 'component' | 'asset' | 'graph';
|
||||||
readonly sourceLabel: string;
|
readonly sourceLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUICK_TEMPLATES = [
|
const QUICK_TEMPLATES = [
|
||||||
{ id: 'risk-accepted', label: 'Risk Accepted', text: 'Risk has been reviewed and formally accepted.' },
|
{ id: 'risk-accepted', label: 'Risk Accepted', text: 'Risk has been reviewed and formally accepted.' },
|
||||||
{ id: 'compensating-control', label: 'Compensating Control', text: 'Compensating controls in place: ' },
|
{ id: 'compensating-control', label: 'Compensating Control', text: 'Compensating controls in place: ' },
|
||||||
{ id: 'false-positive', label: 'False Positive', text: 'This finding is a false positive because: ' },
|
{ id: 'false-positive', label: 'False Positive', text: 'This finding is a false positive because: ' },
|
||||||
{ id: 'scheduled-fix', label: 'Scheduled Fix', text: 'Fix scheduled for deployment. Timeline: ' },
|
{ id: 'scheduled-fix', label: 'Scheduled Fix', text: 'Fix scheduled for deployment. Timeline: ' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
|
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
|
||||||
{ value: 'critical', label: 'Critical' },
|
{ value: 'critical', label: 'Critical' },
|
||||||
{ value: 'high', label: 'High' },
|
{ value: 'high', label: 'High' },
|
||||||
{ value: 'medium', label: 'Medium' },
|
{ value: 'medium', label: 'Medium' },
|
||||||
{ value: 'low', label: 'Low' },
|
{ value: 'low', label: 'Low' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-exception-draft-inline',
|
selector: 'app-exception-draft-inline',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule],
|
imports: [CommonModule, ReactiveFormsModule],
|
||||||
templateUrl: './exception-draft-inline.component.html',
|
templateUrl: './exception-draft-inline.component.html',
|
||||||
styleUrls: ['./exception-draft-inline.component.scss'],
|
styleUrls: ['./exception-draft-inline.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -65,148 +65,148 @@ export class ExceptionDraftInlineComponent implements OnInit {
|
|||||||
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
|
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
|
||||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
@Input() context!: ExceptionDraftContext;
|
@Input() context!: ExceptionDraftContext;
|
||||||
@Output() readonly created = new EventEmitter<Exception>();
|
@Output() readonly created = new EventEmitter<Exception>();
|
||||||
@Output() readonly cancelled = new EventEmitter<void>();
|
@Output() readonly cancelled = new EventEmitter<void>();
|
||||||
@Output() readonly openFullWizard = new EventEmitter<ExceptionDraftContext>();
|
@Output() readonly openFullWizard = new EventEmitter<ExceptionDraftContext>();
|
||||||
|
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
readonly showSimulation = signal(false);
|
readonly showSimulation = signal(false);
|
||||||
|
|
||||||
readonly quickTemplates = QUICK_TEMPLATES;
|
readonly quickTemplates = QUICK_TEMPLATES;
|
||||||
readonly severityOptions = SEVERITY_OPTIONS;
|
readonly severityOptions = SEVERITY_OPTIONS;
|
||||||
|
|
||||||
readonly draftForm = this.formBuilder.group({
|
readonly draftForm = this.formBuilder.group({
|
||||||
name: this.formBuilder.control('', {
|
name: this.formBuilder.control('', {
|
||||||
validators: [Validators.required, Validators.minLength(3)],
|
validators: [Validators.required, Validators.minLength(3)],
|
||||||
}),
|
}),
|
||||||
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
|
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
|
||||||
justificationTemplate: this.formBuilder.control('risk-accepted'),
|
justificationTemplate: this.formBuilder.control('risk-accepted'),
|
||||||
justificationText: this.formBuilder.control('', {
|
justificationText: this.formBuilder.control('', {
|
||||||
validators: [Validators.required, Validators.minLength(20)],
|
validators: [Validators.required, Validators.minLength(20)],
|
||||||
}),
|
}),
|
||||||
timeboxDays: this.formBuilder.control(30),
|
timeboxDays: this.formBuilder.control(30),
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly scopeType = computed<ExceptionScopeType>(() => {
|
readonly scopeType = computed<ExceptionScopeType>(() => {
|
||||||
if (this.context?.componentPurls?.length) return 'component';
|
if (this.context?.componentPurls?.length) return 'component';
|
||||||
if (this.context?.assetIds?.length) return 'asset';
|
if (this.context?.assetIds?.length) return 'asset';
|
||||||
if (this.context?.tenantId) return 'tenant';
|
if (this.context?.tenantId) return 'tenant';
|
||||||
return 'global';
|
return 'global';
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly scopeSummary = computed(() => {
|
readonly scopeSummary = computed(() => {
|
||||||
const ctx = this.context;
|
const ctx = this.context;
|
||||||
const items: string[] = [];
|
const items: string[] = [];
|
||||||
|
|
||||||
if (ctx?.vulnIds?.length) {
|
if (ctx?.vulnIds?.length) {
|
||||||
items.push(`${ctx.vulnIds.length} vulnerabilit${ctx.vulnIds.length === 1 ? 'y' : 'ies'}`);
|
items.push(`${ctx.vulnIds.length} vulnerabilit${ctx.vulnIds.length === 1 ? 'y' : 'ies'}`);
|
||||||
}
|
}
|
||||||
if (ctx?.componentPurls?.length) {
|
if (ctx?.componentPurls?.length) {
|
||||||
items.push(`${ctx.componentPurls.length} component${ctx.componentPurls.length === 1 ? '' : 's'}`);
|
items.push(`${ctx.componentPurls.length} component${ctx.componentPurls.length === 1 ? '' : 's'}`);
|
||||||
}
|
}
|
||||||
if (ctx?.assetIds?.length) {
|
if (ctx?.assetIds?.length) {
|
||||||
items.push(`${ctx.assetIds.length} asset${ctx.assetIds.length === 1 ? '' : 's'}`);
|
items.push(`${ctx.assetIds.length} asset${ctx.assetIds.length === 1 ? '' : 's'}`);
|
||||||
}
|
}
|
||||||
if (ctx?.tenantId) {
|
if (ctx?.tenantId) {
|
||||||
items.push(`Tenant: ${ctx.tenantId}`);
|
items.push(`Tenant: ${ctx.tenantId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return items.length > 0 ? items.join(', ') : 'Global scope';
|
return items.length > 0 ? items.join(', ') : 'Global scope';
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly simulationResult = computed(() => {
|
readonly simulationResult = computed(() => {
|
||||||
if (!this.showSimulation()) return null;
|
if (!this.showSimulation()) return null;
|
||||||
|
|
||||||
const vulnCount = this.context?.vulnIds?.length ?? 0;
|
const vulnCount = this.context?.vulnIds?.length ?? 0;
|
||||||
const componentCount = this.context?.componentPurls?.length ?? 0;
|
const componentCount = this.context?.componentPurls?.length ?? 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
affectedFindings: vulnCount * Math.max(1, componentCount),
|
affectedFindings: vulnCount * Math.max(1, componentCount),
|
||||||
policyImpact: this.draftForm.controls.severity.value === 'critical' ? 'high' : 'moderate',
|
policyImpact: this.draftForm.controls.severity.value === 'critical' ? 'high' : 'moderate',
|
||||||
coverageEstimate: `~${Math.min(100, vulnCount * 15 + componentCount * 10)}%`,
|
coverageEstimate: `~${Math.min(100, vulnCount * 15 + componentCount * 10)}%`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly canSubmit = computed(() => {
|
readonly canSubmit = computed(() => {
|
||||||
return this.draftForm.valid && !this.loading();
|
return this.draftForm.valid && !this.loading();
|
||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (this.context?.suggestedName) {
|
if (this.context?.suggestedName) {
|
||||||
this.draftForm.patchValue({ name: this.context.suggestedName });
|
this.draftForm.patchValue({ name: this.context.suggestedName });
|
||||||
}
|
}
|
||||||
if (this.context?.suggestedSeverity) {
|
if (this.context?.suggestedSeverity) {
|
||||||
this.draftForm.patchValue({ severity: this.context.suggestedSeverity });
|
this.draftForm.patchValue({ severity: this.context.suggestedSeverity });
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTemplate = this.quickTemplates[0];
|
const defaultTemplate = this.quickTemplates[0];
|
||||||
this.draftForm.patchValue({ justificationText: defaultTemplate.text });
|
this.draftForm.patchValue({ justificationText: defaultTemplate.text });
|
||||||
}
|
}
|
||||||
|
|
||||||
selectTemplate(templateId: string): void {
|
selectTemplate(templateId: string): void {
|
||||||
const template = this.quickTemplates.find((t) => t.id === templateId);
|
const template = this.quickTemplates.find((t) => t.id === templateId);
|
||||||
if (template) {
|
if (template) {
|
||||||
this.draftForm.patchValue({
|
this.draftForm.patchValue({
|
||||||
justificationTemplate: templateId,
|
justificationTemplate: templateId,
|
||||||
justificationText: template.text,
|
justificationText: template.text,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSimulation(): void {
|
toggleSimulation(): void {
|
||||||
this.showSimulation.set(!this.showSimulation());
|
this.showSimulation.set(!this.showSimulation());
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitDraft(): Promise<void> {
|
async submitDraft(): Promise<void> {
|
||||||
if (!this.canSubmit()) return;
|
if (!this.canSubmit()) return;
|
||||||
|
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formValue = this.draftForm.getRawValue();
|
const formValue = this.draftForm.getRawValue();
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
endDate.setDate(endDate.getDate() + formValue.timeboxDays);
|
endDate.setDate(endDate.getDate() + formValue.timeboxDays);
|
||||||
|
|
||||||
const exception: Partial<Exception> = {
|
const exception: Partial<Exception> = {
|
||||||
name: formValue.name,
|
name: formValue.name,
|
||||||
severity: formValue.severity,
|
severity: formValue.severity,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
scope: {
|
scope: {
|
||||||
type: this.scopeType(),
|
type: this.scopeType(),
|
||||||
tenantId: this.context?.tenantId,
|
tenantId: this.context?.tenantId,
|
||||||
assetIds: this.context?.assetIds ? [...this.context.assetIds] : undefined,
|
assetIds: this.context?.assetIds ? [...this.context.assetIds] : undefined,
|
||||||
componentPurls: this.context?.componentPurls ? [...this.context.componentPurls] : undefined,
|
componentPurls: this.context?.componentPurls ? [...this.context.componentPurls] : undefined,
|
||||||
vulnIds: this.context?.vulnIds ? [...this.context.vulnIds] : undefined,
|
vulnIds: this.context?.vulnIds ? [...this.context.vulnIds] : undefined,
|
||||||
},
|
},
|
||||||
justification: {
|
justification: {
|
||||||
template: formValue.justificationTemplate,
|
template: formValue.justificationTemplate,
|
||||||
text: formValue.justificationText,
|
text: formValue.justificationText,
|
||||||
},
|
},
|
||||||
timebox: {
|
timebox: {
|
||||||
startDate: new Date().toISOString(),
|
startDate: new Date().toISOString(),
|
||||||
endDate: endDate.toISOString(),
|
endDate: endDate.toISOString(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const created = await firstValueFrom(this.api.createException(exception));
|
const created = await firstValueFrom(this.api.createException(exception));
|
||||||
this.created.emit(created);
|
this.created.emit(created);
|
||||||
this.router.navigate(['/exceptions', created.exceptionId]);
|
this.router.navigate(['/exceptions', created.exceptionId]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
|
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.cancelled.emit();
|
this.cancelled.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
expandToFullWizard(): void {
|
expandToFullWizard(): void {
|
||||||
this.openFullWizard.emit(this.context);
|
this.openFullWizard.emit(this.context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -379,7 +379,7 @@ import {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
background: #EEF2FF;
|
background: #EEF2FF;
|
||||||
color: #4338CA;
|
color: #E09115;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,7 +507,7 @@ import {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
background: #EEF2FF;
|
background: #EEF2FF;
|
||||||
color: #4338CA;
|
color: #E09115;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -515,14 +515,14 @@ type SbomSourceType = 'file' | 'oci';
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
color: #4338CA;
|
color: #E09115;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-pattern {
|
.remove-pattern {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #6366F1;
|
color: #D4920A;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ const VIEWPORT_PADDING = 100;
|
|||||||
|
|
||||||
<!-- Selection filter -->
|
<!-- Selection filter -->
|
||||||
<filter id="selection-glow" x="-50%" y="-50%" width="200%" height="200%">
|
<filter id="selection-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#4f46e5" flood-opacity="0.5"/>
|
<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#F5A623" flood-opacity="0.5"/>
|
||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@ const VIEWPORT_PADDING = 100;
|
|||||||
[attr.width]="viewportBounds().maxX - viewportBounds().minX"
|
[attr.width]="viewportBounds().maxX - viewportBounds().minX"
|
||||||
[attr.height]="viewportBounds().maxY - viewportBounds().minY"
|
[attr.height]="viewportBounds().maxY - viewportBounds().minY"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#4f46e5"
|
stroke="#F5A623"
|
||||||
stroke-width="8"
|
stroke-width="8"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -413,7 +413,7 @@ const VIEWPORT_PADDING = 100;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: 2px solid #4f46e5;
|
outline: 2px solid #F5A623;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,7 +459,7 @@ const VIEWPORT_PADDING = 100;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid #4f46e5;
|
outline: 2px solid #F5A623;
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,16 +507,16 @@ const VIEWPORT_PADDING = 100;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
background: #4f46e5;
|
background: #F5A623;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #4338ca;
|
background: #E09115;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid #4f46e5;
|
outline: 2px solid #F5A623;
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -555,7 +555,7 @@ const VIEWPORT_PADDING = 100;
|
|||||||
|
|
||||||
&--highlighted {
|
&--highlighted {
|
||||||
stroke-width: 3;
|
stroke-width: 3;
|
||||||
stroke: #4f46e5;
|
stroke: #F5A623;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,14 +573,14 @@ const VIEWPORT_PADDING = 100;
|
|||||||
&--selected {
|
&--selected {
|
||||||
.node-bg {
|
.node-bg {
|
||||||
filter: url(#selection-glow);
|
filter: url(#selection-glow);
|
||||||
stroke: #4f46e5 !important;
|
stroke: #F5A623 !important;
|
||||||
stroke-width: 3 !important;
|
stroke-width: 3 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--highlighted:not(.node-group--selected) {
|
&--highlighted:not(.node-group--selected) {
|
||||||
.node-bg {
|
.node-bg {
|
||||||
stroke: #818cf8 !important;
|
stroke: #F5B84A !important;
|
||||||
stroke-width: 2 !important;
|
stroke-width: 2 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,7 +595,7 @@ const VIEWPORT_PADDING = 100;
|
|||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
.node-bg {
|
.node-bg {
|
||||||
stroke: #4f46e5 !important;
|
stroke: #F5A623 !important;
|
||||||
stroke-width: 3 !important;
|
stroke-width: 3 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,476 +1,476 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
HostListener,
|
HostListener,
|
||||||
OnInit,
|
OnInit,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ExceptionDraftContext,
|
ExceptionDraftContext,
|
||||||
ExceptionDraftInlineComponent,
|
ExceptionDraftInlineComponent,
|
||||||
} from '../exceptions/exception-draft-inline.component';
|
} from '../exceptions/exception-draft-inline.component';
|
||||||
import {
|
import {
|
||||||
ExceptionBadgeComponent,
|
ExceptionBadgeComponent,
|
||||||
ExceptionBadgeData,
|
ExceptionBadgeData,
|
||||||
ExceptionExplainComponent,
|
ExceptionExplainComponent,
|
||||||
ExceptionExplainData,
|
ExceptionExplainData,
|
||||||
} from '../../shared/components';
|
} from '../../shared/components';
|
||||||
import {
|
import {
|
||||||
AUTH_SERVICE,
|
AUTH_SERVICE,
|
||||||
AuthService,
|
AuthService,
|
||||||
MockAuthService,
|
MockAuthService,
|
||||||
StellaOpsScopes,
|
StellaOpsScopes,
|
||||||
} from '../../core/auth';
|
} from '../../core/auth';
|
||||||
import { GraphCanvasComponent, CanvasNode, CanvasEdge } from './graph-canvas.component';
|
import { GraphCanvasComponent, CanvasNode, CanvasEdge } from './graph-canvas.component';
|
||||||
import { GraphOverlaysComponent, GraphOverlayState } from './graph-overlays.component';
|
import { GraphOverlaysComponent, GraphOverlayState } from './graph-overlays.component';
|
||||||
|
|
||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly type: 'asset' | 'component' | 'vulnerability';
|
readonly type: 'asset' | 'component' | 'vulnerability';
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly purl?: string;
|
readonly purl?: string;
|
||||||
readonly version?: string;
|
readonly version?: string;
|
||||||
readonly severity?: 'critical' | 'high' | 'medium' | 'low';
|
readonly severity?: 'critical' | 'high' | 'medium' | 'low';
|
||||||
readonly vulnCount?: number;
|
readonly vulnCount?: number;
|
||||||
readonly hasException?: boolean;
|
readonly hasException?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphEdge {
|
export interface GraphEdge {
|
||||||
readonly source: string;
|
readonly source: string;
|
||||||
readonly target: string;
|
readonly target: string;
|
||||||
readonly type: 'depends_on' | 'has_vulnerability' | 'child_of';
|
readonly type: 'depends_on' | 'has_vulnerability' | 'child_of';
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_NODES: GraphNode[] = [
|
const MOCK_NODES: GraphNode[] = [
|
||||||
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
|
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
|
||||||
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
|
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
|
||||||
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
|
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
|
||||||
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1, hasException: true },
|
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1, hasException: true },
|
||||||
{ id: 'comp-curl', type: 'component', name: 'curl', purl: 'pkg:deb/debian/curl@7.88.1-10', version: '7.88.1-10', severity: 'high', vulnCount: 1 },
|
{ id: 'comp-curl', type: 'component', name: 'curl', purl: 'pkg:deb/debian/curl@7.88.1-10', version: '7.88.1-10', severity: 'high', vulnCount: 1 },
|
||||||
{ id: 'comp-nghttp2', type: 'component', name: 'nghttp2', purl: 'pkg:npm/nghttp2@1.55.0', version: '1.55.0', severity: 'high', vulnCount: 1 },
|
{ id: 'comp-nghttp2', type: 'component', name: 'nghttp2', purl: 'pkg:npm/nghttp2@1.55.0', version: '1.55.0', severity: 'high', vulnCount: 1 },
|
||||||
{ id: 'comp-golang-net', type: 'component', name: 'golang.org/x/net', purl: 'pkg:golang/golang.org/x/net@0.15.0', version: '0.15.0', severity: 'high', vulnCount: 1 },
|
{ id: 'comp-golang-net', type: 'component', name: 'golang.org/x/net', purl: 'pkg:golang/golang.org/x/net@0.15.0', version: '0.15.0', severity: 'high', vulnCount: 1 },
|
||||||
{ id: 'comp-zlib', type: 'component', name: 'zlib', purl: 'pkg:deb/debian/zlib@1.2.13', version: '1.2.13', severity: 'medium', vulnCount: 1 },
|
{ id: 'comp-zlib', type: 'component', name: 'zlib', purl: 'pkg:deb/debian/zlib@1.2.13', version: '1.2.13', severity: 'medium', vulnCount: 1 },
|
||||||
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
|
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
|
||||||
{ id: 'vuln-log4j-dos', type: 'vulnerability', name: 'CVE-2021-45046', severity: 'critical', hasException: true },
|
{ id: 'vuln-log4j-dos', type: 'vulnerability', name: 'CVE-2021-45046', severity: 'critical', hasException: true },
|
||||||
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical', hasException: true },
|
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical', hasException: true },
|
||||||
{ id: 'vuln-http2-reset', type: 'vulnerability', name: 'CVE-2023-44487', severity: 'high' },
|
{ id: 'vuln-http2-reset', type: 'vulnerability', name: 'CVE-2023-44487', severity: 'high' },
|
||||||
{ id: 'vuln-curl-heap', type: 'vulnerability', name: 'CVE-2023-38545', severity: 'high' },
|
{ id: 'vuln-curl-heap', type: 'vulnerability', name: 'CVE-2023-38545', severity: 'high' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MOCK_EDGES: GraphEdge[] = [
|
const MOCK_EDGES: GraphEdge[] = [
|
||||||
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
|
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||||
{ source: 'asset-web-prod', target: 'comp-curl', type: 'depends_on' },
|
{ source: 'asset-web-prod', target: 'comp-curl', type: 'depends_on' },
|
||||||
{ source: 'asset-web-prod', target: 'comp-nghttp2', type: 'depends_on' },
|
{ source: 'asset-web-prod', target: 'comp-nghttp2', type: 'depends_on' },
|
||||||
{ source: 'asset-web-prod', target: 'comp-zlib', type: 'depends_on' },
|
{ source: 'asset-web-prod', target: 'comp-zlib', type: 'depends_on' },
|
||||||
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
|
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||||
{ source: 'asset-api-prod', target: 'comp-curl', type: 'depends_on' },
|
{ source: 'asset-api-prod', target: 'comp-curl', type: 'depends_on' },
|
||||||
{ source: 'asset-api-prod', target: 'comp-golang-net', type: 'depends_on' },
|
{ source: 'asset-api-prod', target: 'comp-golang-net', type: 'depends_on' },
|
||||||
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
|
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
|
||||||
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
|
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
|
||||||
{ source: 'comp-log4j', target: 'vuln-log4j-dos', type: 'has_vulnerability' },
|
{ source: 'comp-log4j', target: 'vuln-log4j-dos', type: 'has_vulnerability' },
|
||||||
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
|
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
|
||||||
{ source: 'comp-nghttp2', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
{ source: 'comp-nghttp2', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||||
{ source: 'comp-golang-net', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
{ source: 'comp-golang-net', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||||
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
|
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
|
||||||
];
|
];
|
||||||
|
|
||||||
type ViewMode = 'hierarchy' | 'flat' | 'canvas';
|
type ViewMode = 'hierarchy' | 'flat' | 'canvas';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-graph-explorer',
|
selector: 'app-graph-explorer',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent, GraphCanvasComponent, GraphOverlaysComponent],
|
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent, GraphCanvasComponent, GraphOverlaysComponent],
|
||||||
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
|
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
|
||||||
templateUrl: './graph-explorer.component.html',
|
templateUrl: './graph-explorer.component.html',
|
||||||
styleUrls: ['./graph-explorer.component.scss'],
|
styleUrls: ['./graph-explorer.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class GraphExplorerComponent implements OnInit {
|
export class GraphExplorerComponent implements OnInit {
|
||||||
private readonly authService = inject(AUTH_SERVICE);
|
private readonly authService = inject(AUTH_SERVICE);
|
||||||
|
|
||||||
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
|
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
|
||||||
readonly canViewGraph = computed(() => this.authService.canViewGraph());
|
readonly canViewGraph = computed(() => this.authService.canViewGraph());
|
||||||
readonly canEditGraph = computed(() => this.authService.canEditGraph());
|
readonly canEditGraph = computed(() => this.authService.canEditGraph());
|
||||||
readonly canExportGraph = computed(() => this.authService.canExportGraph());
|
readonly canExportGraph = computed(() => this.authService.canExportGraph());
|
||||||
readonly canSimulate = computed(() => this.authService.canSimulate());
|
readonly canSimulate = computed(() => this.authService.canSimulate());
|
||||||
readonly canCreateException = computed(() =>
|
readonly canCreateException = computed(() =>
|
||||||
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
|
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Current user info
|
// Current user info
|
||||||
readonly currentUser = computed(() => this.authService.user());
|
readonly currentUser = computed(() => this.authService.user());
|
||||||
readonly userScopes = computed(() => this.authService.scopes());
|
readonly userScopes = computed(() => this.authService.scopes());
|
||||||
|
|
||||||
// View state
|
// View state
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
readonly message = signal<string | null>(null);
|
readonly message = signal<string | null>(null);
|
||||||
readonly messageType = signal<'success' | 'error' | 'info'>('info');
|
readonly messageType = signal<'success' | 'error' | 'info'>('info');
|
||||||
readonly viewMode = signal<ViewMode>('canvas'); // Default to canvas view
|
readonly viewMode = signal<ViewMode>('canvas'); // Default to canvas view
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
readonly nodes = signal<GraphNode[]>([]);
|
readonly nodes = signal<GraphNode[]>([]);
|
||||||
readonly edges = signal<GraphEdge[]>([]);
|
readonly edges = signal<GraphEdge[]>([]);
|
||||||
readonly selectedNodeId = signal<string | null>(null);
|
readonly selectedNodeId = signal<string | null>(null);
|
||||||
|
|
||||||
// Exception draft state
|
// Exception draft state
|
||||||
readonly showExceptionDraft = signal(false);
|
readonly showExceptionDraft = signal(false);
|
||||||
|
|
||||||
// Exception explain state
|
// Exception explain state
|
||||||
readonly showExceptionExplain = signal(false);
|
readonly showExceptionExplain = signal(false);
|
||||||
readonly explainNodeId = signal<string | null>(null);
|
readonly explainNodeId = signal<string | null>(null);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
readonly showVulnerabilities = signal(true);
|
readonly showVulnerabilities = signal(true);
|
||||||
readonly showComponents = signal(true);
|
readonly showComponents = signal(true);
|
||||||
readonly showAssets = signal(true);
|
readonly showAssets = signal(true);
|
||||||
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
|
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
|
||||||
|
|
||||||
// Overlay state
|
// Overlay state
|
||||||
readonly overlayState = signal<GraphOverlayState | null>(null);
|
readonly overlayState = signal<GraphOverlayState | null>(null);
|
||||||
readonly simulationMode = signal(false);
|
readonly simulationMode = signal(false);
|
||||||
readonly pathViewState = signal<{ enabled: boolean; type: string }>({ enabled: false, type: 'shortest' });
|
readonly pathViewState = signal<{ enabled: boolean; type: string }>({ enabled: false, type: 'shortest' });
|
||||||
readonly timeTravelState = signal<{ enabled: boolean; snapshot: string }>({ enabled: false, snapshot: 'current' });
|
readonly timeTravelState = signal<{ enabled: boolean; snapshot: string }>({ enabled: false, snapshot: 'current' });
|
||||||
|
|
||||||
// Computed: node IDs for overlay component
|
// Computed: node IDs for overlay component
|
||||||
readonly nodeIds = computed(() => this.filteredNodes().map(n => n.id));
|
readonly nodeIds = computed(() => this.filteredNodes().map(n => n.id));
|
||||||
|
|
||||||
// Computed: filtered nodes
|
// Computed: filtered nodes
|
||||||
readonly filteredNodes = computed(() => {
|
readonly filteredNodes = computed(() => {
|
||||||
let items = [...this.nodes()];
|
let items = [...this.nodes()];
|
||||||
const showVulns = this.showVulnerabilities();
|
const showVulns = this.showVulnerabilities();
|
||||||
const showComps = this.showComponents();
|
const showComps = this.showComponents();
|
||||||
const showAssetNodes = this.showAssets();
|
const showAssetNodes = this.showAssets();
|
||||||
const severity = this.filterSeverity();
|
const severity = this.filterSeverity();
|
||||||
|
|
||||||
items = items.filter((n) => {
|
items = items.filter((n) => {
|
||||||
if (n.type === 'vulnerability' && !showVulns) return false;
|
if (n.type === 'vulnerability' && !showVulns) return false;
|
||||||
if (n.type === 'component' && !showComps) return false;
|
if (n.type === 'component' && !showComps) return false;
|
||||||
if (n.type === 'asset' && !showAssetNodes) return false;
|
if (n.type === 'asset' && !showAssetNodes) return false;
|
||||||
if (severity !== 'all' && n.severity && n.severity !== severity) return false;
|
if (severity !== 'all' && n.severity && n.severity !== severity) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Computed: canvas nodes (filtered for canvas view)
|
// Computed: canvas nodes (filtered for canvas view)
|
||||||
readonly canvasNodes = computed<CanvasNode[]>(() => {
|
readonly canvasNodes = computed<CanvasNode[]>(() => {
|
||||||
return this.filteredNodes().map(n => ({
|
return this.filteredNodes().map(n => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: n.type,
|
type: n.type,
|
||||||
name: n.name,
|
name: n.name,
|
||||||
purl: n.purl,
|
purl: n.purl,
|
||||||
version: n.version,
|
version: n.version,
|
||||||
severity: n.severity,
|
severity: n.severity,
|
||||||
vulnCount: n.vulnCount,
|
vulnCount: n.vulnCount,
|
||||||
hasException: n.hasException,
|
hasException: n.hasException,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Computed: canvas edges (filtered based on visible nodes)
|
// Computed: canvas edges (filtered based on visible nodes)
|
||||||
readonly canvasEdges = computed<CanvasEdge[]>(() => {
|
readonly canvasEdges = computed<CanvasEdge[]>(() => {
|
||||||
const visibleIds = new Set(this.filteredNodes().map(n => n.id));
|
const visibleIds = new Set(this.filteredNodes().map(n => n.id));
|
||||||
return this.edges()
|
return this.edges()
|
||||||
.filter(e => visibleIds.has(e.source) && visibleIds.has(e.target))
|
.filter(e => visibleIds.has(e.source) && visibleIds.has(e.target))
|
||||||
.map(e => ({
|
.map(e => ({
|
||||||
source: e.source,
|
source: e.source,
|
||||||
target: e.target,
|
target: e.target,
|
||||||
type: e.type,
|
type: e.type,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Computed: assets
|
// Computed: assets
|
||||||
readonly assets = computed(() => {
|
readonly assets = computed(() => {
|
||||||
return this.filteredNodes().filter((n) => n.type === 'asset');
|
return this.filteredNodes().filter((n) => n.type === 'asset');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Computed: components
|
// Computed: components
|
||||||
readonly components = computed(() => {
|
readonly components = computed(() => {
|
||||||
return this.filteredNodes().filter((n) => n.type === 'component');
|
return this.filteredNodes().filter((n) => n.type === 'component');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Computed: vulnerabilities
|
// Computed: vulnerabilities
|
||||||
readonly vulnerabilities = computed(() => {
|
readonly vulnerabilities = computed(() => {
|
||||||
return this.filteredNodes().filter((n) => n.type === 'vulnerability');
|
return this.filteredNodes().filter((n) => n.type === 'vulnerability');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Computed: selected node
|
// Computed: selected node
|
||||||
readonly selectedNode = computed(() => {
|
readonly selectedNode = computed(() => {
|
||||||
const id = this.selectedNodeId();
|
const id = this.selectedNodeId();
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
return this.nodes().find((n) => n.id === id) ?? null;
|
return this.nodes().find((n) => n.id === id) ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Computed: related nodes for selected
|
// Computed: related nodes for selected
|
||||||
readonly relatedNodes = computed(() => {
|
readonly relatedNodes = computed(() => {
|
||||||
const selectedId = this.selectedNodeId();
|
const selectedId = this.selectedNodeId();
|
||||||
if (!selectedId) return [];
|
if (!selectedId) return [];
|
||||||
|
|
||||||
const edgeList = this.edges();
|
const edgeList = this.edges();
|
||||||
const relatedIds = new Set<string>();
|
const relatedIds = new Set<string>();
|
||||||
|
|
||||||
edgeList.forEach((e) => {
|
edgeList.forEach((e) => {
|
||||||
if (e.source === selectedId) relatedIds.add(e.target);
|
if (e.source === selectedId) relatedIds.add(e.target);
|
||||||
if (e.target === selectedId) relatedIds.add(e.source);
|
if (e.target === selectedId) relatedIds.add(e.source);
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.nodes().filter((n) => relatedIds.has(n.id));
|
return this.nodes().filter((n) => relatedIds.has(n.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get exception badge data for a node
|
// Get exception badge data for a node
|
||||||
getExceptionBadgeData(node: GraphNode): ExceptionBadgeData | null {
|
getExceptionBadgeData(node: GraphNode): ExceptionBadgeData | null {
|
||||||
if (!node.hasException) return null;
|
if (!node.hasException) return null;
|
||||||
return {
|
return {
|
||||||
exceptionId: `exc-${node.id}`,
|
exceptionId: `exc-${node.id}`,
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
severity: node.severity ?? 'medium',
|
severity: node.severity ?? 'medium',
|
||||||
name: `${node.name} Exception`,
|
name: `${node.name} Exception`,
|
||||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
justificationSummary: 'Risk accepted with compensating controls.',
|
justificationSummary: 'Risk accepted with compensating controls.',
|
||||||
approvedBy: 'Security Team',
|
approvedBy: 'Security Team',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed: explain data for selected node
|
// Computed: explain data for selected node
|
||||||
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
|
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
|
||||||
const nodeId = this.explainNodeId();
|
const nodeId = this.explainNodeId();
|
||||||
if (!nodeId) return null;
|
if (!nodeId) return null;
|
||||||
|
|
||||||
const node = this.nodes().find((n) => n.id === nodeId);
|
const node = this.nodes().find((n) => n.id === nodeId);
|
||||||
if (!node || !node.hasException) return null;
|
if (!node || !node.hasException) return null;
|
||||||
|
|
||||||
const relatedComps = this.edges()
|
const relatedComps = this.edges()
|
||||||
.filter((e) => e.source === nodeId || e.target === nodeId)
|
.filter((e) => e.source === nodeId || e.target === nodeId)
|
||||||
.map((e) => (e.source === nodeId ? e.target : e.source))
|
.map((e) => (e.source === nodeId ? e.target : e.source))
|
||||||
.map((id) => this.nodes().find((n) => n.id === id))
|
.map((id) => this.nodes().find((n) => n.id === id))
|
||||||
.filter((n): n is GraphNode => n !== undefined && n.type === 'component');
|
.filter((n): n is GraphNode => n !== undefined && n.type === 'component');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exceptionId: `exc-${node.id}`,
|
exceptionId: `exc-${node.id}`,
|
||||||
name: `${node.name} Exception`,
|
name: `${node.name} Exception`,
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
severity: node.severity ?? 'medium',
|
severity: node.severity ?? 'medium',
|
||||||
scope: {
|
scope: {
|
||||||
type: node.type === 'vulnerability' ? 'vulnerability' : 'component',
|
type: node.type === 'vulnerability' ? 'vulnerability' : 'component',
|
||||||
vulnIds: node.type === 'vulnerability' ? [node.name] : undefined,
|
vulnIds: node.type === 'vulnerability' ? [node.name] : undefined,
|
||||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||||
},
|
},
|
||||||
justification: {
|
justification: {
|
||||||
template: 'risk-accepted',
|
template: 'risk-accepted',
|
||||||
text: 'Risk accepted with compensating controls in place. The affected item is in a controlled environment.',
|
text: 'Risk accepted with compensating controls in place. The affected item is in a controlled environment.',
|
||||||
},
|
},
|
||||||
timebox: {
|
timebox: {
|
||||||
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
autoRenew: false,
|
autoRenew: false,
|
||||||
},
|
},
|
||||||
approvedBy: 'Security Team',
|
approvedBy: 'Security Team',
|
||||||
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
impact: {
|
impact: {
|
||||||
affectedFindings: 1,
|
affectedFindings: 1,
|
||||||
affectedAssets: 1,
|
affectedAssets: 1,
|
||||||
policyOverrides: 1,
|
policyOverrides: 1,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Computed: exception draft context
|
// Computed: exception draft context
|
||||||
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
|
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
|
||||||
const node = this.selectedNode();
|
const node = this.selectedNode();
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
|
||||||
if (node.type === 'component') {
|
if (node.type === 'component') {
|
||||||
const relatedVulns = this.relatedNodes().filter((n) => n.type === 'vulnerability');
|
const relatedVulns = this.relatedNodes().filter((n) => n.type === 'vulnerability');
|
||||||
return {
|
return {
|
||||||
componentPurls: node.purl ? [node.purl] : undefined,
|
componentPurls: node.purl ? [node.purl] : undefined,
|
||||||
vulnIds: relatedVulns.map((v) => v.name),
|
vulnIds: relatedVulns.map((v) => v.name),
|
||||||
suggestedName: `${node.name}-exception`,
|
suggestedName: `${node.name}-exception`,
|
||||||
suggestedSeverity: node.severity ?? 'medium',
|
suggestedSeverity: node.severity ?? 'medium',
|
||||||
sourceType: 'component',
|
sourceType: 'component',
|
||||||
sourceLabel: `${node.name}@${node.version}`,
|
sourceLabel: `${node.name}@${node.version}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'vulnerability') {
|
if (node.type === 'vulnerability') {
|
||||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||||
return {
|
return {
|
||||||
vulnIds: [node.name],
|
vulnIds: [node.name],
|
||||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||||
suggestedName: `${node.name.toLowerCase()}-exception`,
|
suggestedName: `${node.name.toLowerCase()}-exception`,
|
||||||
suggestedSeverity: node.severity ?? 'medium',
|
suggestedSeverity: node.severity ?? 'medium',
|
||||||
sourceType: 'vulnerability',
|
sourceType: 'vulnerability',
|
||||||
sourceLabel: node.name,
|
sourceLabel: node.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'asset') {
|
if (node.type === 'asset') {
|
||||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||||
return {
|
return {
|
||||||
assetIds: [node.name],
|
assetIds: [node.name],
|
||||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||||
suggestedName: `${node.name}-exception`,
|
suggestedName: `${node.name}-exception`,
|
||||||
sourceType: 'asset',
|
sourceType: 'asset',
|
||||||
sourceLabel: node.name,
|
sourceLabel: node.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadData();
|
this.loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData(): void {
|
loadData(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
// Simulate API call
|
// Simulate API call
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.nodes.set([...MOCK_NODES]);
|
this.nodes.set([...MOCK_NODES]);
|
||||||
this.edges.set([...MOCK_EDGES]);
|
this.edges.set([...MOCK_EDGES]);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
// View mode
|
// View mode
|
||||||
setViewMode(mode: ViewMode): void {
|
setViewMode(mode: ViewMode): void {
|
||||||
this.viewMode.set(mode);
|
this.viewMode.set(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
toggleVulnerabilities(): void {
|
toggleVulnerabilities(): void {
|
||||||
this.showVulnerabilities.set(!this.showVulnerabilities());
|
this.showVulnerabilities.set(!this.showVulnerabilities());
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleComponents(): void {
|
toggleComponents(): void {
|
||||||
this.showComponents.set(!this.showComponents());
|
this.showComponents.set(!this.showComponents());
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleAssets(): void {
|
toggleAssets(): void {
|
||||||
this.showAssets.set(!this.showAssets());
|
this.showAssets.set(!this.showAssets());
|
||||||
}
|
}
|
||||||
|
|
||||||
setSeverityFilter(severity: 'all' | 'critical' | 'high' | 'medium' | 'low'): void {
|
setSeverityFilter(severity: 'all' | 'critical' | 'high' | 'medium' | 'low'): void {
|
||||||
this.filterSeverity.set(severity);
|
this.filterSeverity.set(severity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selection
|
// Selection
|
||||||
selectNode(nodeId: string): void {
|
selectNode(nodeId: string): void {
|
||||||
this.selectedNodeId.set(nodeId);
|
this.selectedNodeId.set(nodeId);
|
||||||
this.showExceptionDraft.set(false);
|
this.showExceptionDraft.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelection(): void {
|
clearSelection(): void {
|
||||||
this.selectedNodeId.set(null);
|
this.selectedNodeId.set(null);
|
||||||
this.showExceptionDraft.set(false);
|
this.showExceptionDraft.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exception drafting
|
// Exception drafting
|
||||||
startExceptionDraft(): void {
|
startExceptionDraft(): void {
|
||||||
this.showExceptionDraft.set(true);
|
this.showExceptionDraft.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelExceptionDraft(): void {
|
cancelExceptionDraft(): void {
|
||||||
this.showExceptionDraft.set(false);
|
this.showExceptionDraft.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
onExceptionCreated(): void {
|
onExceptionCreated(): void {
|
||||||
this.showExceptionDraft.set(false);
|
this.showExceptionDraft.set(false);
|
||||||
this.showMessage('Exception draft created successfully', 'success');
|
this.showMessage('Exception draft created successfully', 'success');
|
||||||
this.loadData();
|
this.loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
openFullWizard(): void {
|
openFullWizard(): void {
|
||||||
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
|
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exception explain
|
// Exception explain
|
||||||
onViewExceptionDetails(exceptionId: string): void {
|
onViewExceptionDetails(exceptionId: string): void {
|
||||||
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
|
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
onExplainException(exceptionId: string): void {
|
onExplainException(exceptionId: string): void {
|
||||||
// Find the node with this exception ID
|
// Find the node with this exception ID
|
||||||
const node = this.nodes().find((n) => `exc-${n.id}` === exceptionId);
|
const node = this.nodes().find((n) => `exc-${n.id}` === exceptionId);
|
||||||
if (node) {
|
if (node) {
|
||||||
this.explainNodeId.set(node.id);
|
this.explainNodeId.set(node.id);
|
||||||
this.showExceptionExplain.set(true);
|
this.showExceptionExplain.set(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeExplain(): void {
|
closeExplain(): void {
|
||||||
this.showExceptionExplain.set(false);
|
this.showExceptionExplain.set(false);
|
||||||
this.explainNodeId.set(null);
|
this.explainNodeId.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
viewExceptionFromExplain(exceptionId: string): void {
|
viewExceptionFromExplain(exceptionId: string): void {
|
||||||
this.closeExplain();
|
this.closeExplain();
|
||||||
this.onViewExceptionDetails(exceptionId);
|
this.onViewExceptionDetails(exceptionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
getNodeTypeIcon(type: GraphNode['type']): string {
|
getNodeTypeIcon(type: GraphNode['type']): string {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'asset':
|
case 'asset':
|
||||||
return '📦';
|
return '📦';
|
||||||
case 'component':
|
case 'component':
|
||||||
return '🧩';
|
return '🧩';
|
||||||
case 'vulnerability':
|
case 'vulnerability':
|
||||||
return '⚠️';
|
return '⚠️';
|
||||||
default:
|
default:
|
||||||
return '•';
|
return '•';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getSeverityClass(severity: string | undefined): string {
|
getSeverityClass(severity: string | undefined): string {
|
||||||
if (!severity) return '';
|
if (!severity) return '';
|
||||||
return `severity--${severity}`;
|
return `severity--${severity}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getNodeClass(node: GraphNode): string {
|
getNodeClass(node: GraphNode): string {
|
||||||
const classes = [`node--${node.type}`];
|
const classes = [`node--${node.type}`];
|
||||||
if (node.severity) classes.push(`node--${node.severity}`);
|
if (node.severity) classes.push(`node--${node.severity}`);
|
||||||
if (node.hasException) classes.push('node--excepted');
|
if (node.hasException) classes.push('node--excepted');
|
||||||
if (this.selectedNodeId() === node.id) classes.push('node--selected');
|
if (this.selectedNodeId() === node.id) classes.push('node--selected');
|
||||||
return classes.join(' ');
|
return classes.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
trackByNode = (_: number, item: GraphNode) => item.id;
|
trackByNode = (_: number, item: GraphNode) => item.id;
|
||||||
|
|
||||||
// Overlay handlers
|
// Overlay handlers
|
||||||
onOverlayStateChange(state: GraphOverlayState): void {
|
onOverlayStateChange(state: GraphOverlayState): void {
|
||||||
this.overlayState.set(state);
|
this.overlayState.set(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSimulationModeChange(enabled: boolean): void {
|
onSimulationModeChange(enabled: boolean): void {
|
||||||
this.simulationMode.set(enabled);
|
this.simulationMode.set(enabled);
|
||||||
this.showMessage(enabled ? 'Simulation mode enabled' : 'Simulation mode disabled', 'info');
|
this.showMessage(enabled ? 'Simulation mode enabled' : 'Simulation mode disabled', 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
onPathViewChange(state: { enabled: boolean; type: string }): void {
|
onPathViewChange(state: { enabled: boolean; type: string }): void {
|
||||||
this.pathViewState.set(state);
|
this.pathViewState.set(state);
|
||||||
if (state.enabled) {
|
if (state.enabled) {
|
||||||
this.showMessage(`Path view enabled: ${state.type}`, 'info');
|
this.showMessage(`Path view enabled: ${state.type}`, 'info');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onTimeTravelChange(state: { enabled: boolean; snapshot: string }): void {
|
onTimeTravelChange(state: { enabled: boolean; snapshot: string }): void {
|
||||||
this.timeTravelState.set(state);
|
this.timeTravelState.set(state);
|
||||||
if (state.enabled && state.snapshot !== 'current') {
|
if (state.enabled && state.snapshot !== 'current') {
|
||||||
this.showMessage(`Viewing snapshot: ${state.snapshot}`, 'info');
|
this.showMessage(`Viewing snapshot: ${state.snapshot}`, 'info');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onShowDiffRequest(snapshot: string): void {
|
onShowDiffRequest(snapshot: string): void {
|
||||||
this.showMessage(`Loading diff for ${snapshot}...`, 'info');
|
this.showMessage(`Loading diff for ${snapshot}...`, 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
|
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
|
||||||
this.message.set(text);
|
this.message.set(text);
|
||||||
this.messageType.set(type);
|
this.messageType.set(type);
|
||||||
setTimeout(() => this.message.set(null), 5000);
|
setTimeout(() => this.message.set(null), 5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
|||||||
transition: border-color 0.15s ease;
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,18 +494,18 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
background: #4f46e5;
|
background: #F5A623;
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #4338ca;
|
background: #E09115;
|
||||||
border-color: #4338ca;
|
border-color: #E09115;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -649,7 +649,7 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
|||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
@@ -681,8 +681,8 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
@@ -769,8 +769,8 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
@@ -844,7 +844,7 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
|||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,11 +872,11 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--primary {
|
&--primary {
|
||||||
background: #4f46e5;
|
background: #F5A623;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: #4338ca;
|
background: #E09115;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ import { GraphAccessibilityService, HotkeyBinding } from './graph-accessibility.
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid #4f46e5;
|
outline: 2px solid #F5A623;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -606,18 +606,18 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
|
|||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--overlay-color, #4f46e5);
|
border-color: var(--overlay-color, #F5A623);
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
border-color: var(--overlay-color, #4f46e5);
|
border-color: var(--overlay-color, #F5A623);
|
||||||
background: color-mix(in srgb, var(--overlay-color, #4f46e5) 10%, white);
|
background: color-mix(in srgb, var(--overlay-color, #F5A623) 10%, white);
|
||||||
color: var(--overlay-color, #4f46e5);
|
color: var(--overlay-color, #F5A623);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid var(--overlay-color, #4f46e5);
|
outline: 2px solid var(--overlay-color, #F5A623);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -634,7 +634,7 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--overlay-color, #4f46e5);
|
background: var(--overlay-color, #F5A623);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Simulation toggle */
|
/* Simulation toggle */
|
||||||
@@ -869,14 +869,14 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
background: #eef2ff;
|
background: #eef2ff;
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,7 +1023,7 @@ export class GraphOverlaysComponent implements OnChanges {
|
|||||||
|
|
||||||
// Overlay configurations
|
// Overlay configurations
|
||||||
readonly overlayConfigs = signal<OverlayConfig[]>([
|
readonly overlayConfigs = signal<OverlayConfig[]>([
|
||||||
{ type: 'policy', enabled: false, label: 'Policy', icon: '📋', color: '#4f46e5' },
|
{ type: 'policy', enabled: false, label: 'Policy', icon: '📋', color: '#F5A623' },
|
||||||
{ type: 'evidence', enabled: false, label: 'Evidence', icon: '🔍', color: '#0ea5e9' },
|
{ type: 'evidence', enabled: false, label: 'Evidence', icon: '🔍', color: '#0ea5e9' },
|
||||||
{ type: 'license', enabled: false, label: 'License', icon: '📜', color: '#22c55e' },
|
{ type: 'license', enabled: false, label: 'License', icon: '📜', color: '#22c55e' },
|
||||||
{ type: 'exposure', enabled: false, label: 'Exposure', icon: '🌐', color: '#ef4444' },
|
{ type: 'exposure', enabled: false, label: 'Exposure', icon: '🌐', color: '#ef4444' },
|
||||||
|
|||||||
@@ -610,7 +610,7 @@ function generateMockDiff(): SbomDiff {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
background: white;
|
background: white;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
@@ -620,7 +620,7 @@ function generateMockDiff(): SbomDiff {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: #4f46e5;
|
background: #F5A623;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -704,8 +704,8 @@ function generateMockDiff(): SbomDiff {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,7 +748,7 @@ function generateMockDiff(): SbomDiff {
|
|||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -776,7 +776,7 @@ function generateMockDiff(): SbomDiff {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -866,11 +866,11 @@ function generateMockDiff(): SbomDiff {
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--selected {
|
&--selected {
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -989,11 +989,11 @@ function generateMockDiff(): SbomDiff {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&--primary {
|
&--primary {
|
||||||
background: #4f46e5;
|
background: #F5A623;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #4338ca;
|
background: #E09115;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1003,8 +1003,8 @@ function generateMockDiff(): SbomDiff {
|
|||||||
color: #475569;
|
color: #475569;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1151,8 +1151,8 @@ function generateMockDiff(): SbomDiff {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import { GateDisplay, GateChangeDisplay, GateType } from '../models/reachability
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-chip.auth { border-left-color: #6366f1; }
|
.gate-chip.auth { border-left-color: #D4920A; }
|
||||||
.gate-chip.feature-flag { border-left-color: #f59e0b; }
|
.gate-chip.feature-flag { border-left-color: #f59e0b; }
|
||||||
.gate-chip.config { border-left-color: #8b5cf6; }
|
.gate-chip.config { border-left-color: #8b5cf6; }
|
||||||
.gate-chip.runtime { border-left-color: #ec4899; }
|
.gate-chip.runtime { border-left-color: #ec4899; }
|
||||||
|
|||||||
@@ -1,394 +1,394 @@
|
|||||||
@use 'tokens/breakpoints' as *;
|
@use 'tokens/breakpoints' as *;
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-panel {
|
.notify-panel {
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: var(--shadow-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-panel__header {
|
.notify-panel__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: var(--space-1) 0;
|
margin: var(--space-1) 0;
|
||||||
font-size: var(--font-size-2xl);
|
font-size: var(--font-size-2xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-grid {
|
.notify-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-card {
|
.notify-card {
|
||||||
background: var(--color-surface-secondary);
|
background: var(--color-surface-secondary);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-card__header {
|
.notify-card__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost-button {
|
.ghost-button {
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
padding: var(--space-1) var(--space-3);
|
padding: var(--space-1) var(--space-3);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
|
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||||
border-color var(--motion-duration-fast) var(--motion-ease-default);
|
border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
background: var(--color-brand-light);
|
background: var(--color-brand-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-message {
|
.notify-message {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--space-2) var(--space-2-5);
|
padding: var(--space-2) var(--space-2-5);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--color-status-info-bg);
|
background: var(--color-status-info-bg);
|
||||||
color: var(--color-status-info);
|
color: var(--color-status-info);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-list,
|
.channel-list,
|
||||||
.rule-list {
|
.rule-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-item,
|
.channel-item,
|
||||||
.rule-item {
|
.rule-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
|
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||||
transform var(--motion-duration-fast) var(--motion-ease-default);
|
transform var(--motion-duration-fast) var(--motion-ease-default);
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
background: var(--color-brand-light);
|
background: var(--color-brand-light);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-meta,
|
.channel-meta,
|
||||||
.rule-meta {
|
.rule-meta {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-status {
|
.channel-status {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
padding: var(--space-0-5) var(--space-2);
|
padding: var(--space-0-5) var(--space-2);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-status--enabled {
|
.channel-status--enabled {
|
||||||
border-color: var(--color-status-success);
|
border-color: var(--color-status-success);
|
||||||
color: var(--color-status-success);
|
color: var(--color-status-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
label span {
|
label span {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid var(--color-brand-primary);
|
outline: 2px solid var(--color-brand-primary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: auto;
|
width: auto;
|
||||||
accent-color: var(--color-brand-primary);
|
accent-color: var(--color-brand-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-width {
|
.full-width {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-actions {
|
.notify-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-actions button {
|
.notify-actions button {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
padding: var(--space-1-5) var(--space-4);
|
padding: var(--space-1-5) var(--space-4);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-brand-primary);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
transition: opacity var(--motion-duration-fast) var(--motion-ease-default);
|
transition: opacity var(--motion-duration-fast) var(--motion-ease-default);
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: var(--color-brand-primary-hover);
|
background: var(--color-brand-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-actions .ghost-button {
|
.notify-actions .ghost-button {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-health {
|
.channel-health {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--color-surface-tertiary);
|
background: var(--color-surface-tertiary);
|
||||||
border: 1px solid var(--color-border-secondary);
|
border: 1px solid var(--color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
padding: var(--space-1) var(--space-2-5);
|
padding: var(--space-1) var(--space-2-5);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill--healthy {
|
.status-pill--healthy {
|
||||||
border-color: var(--color-status-success);
|
border-color: var(--color-status-success);
|
||||||
color: var(--color-status-success);
|
color: var(--color-status-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill--warning {
|
.status-pill--warning {
|
||||||
border-color: var(--color-status-warning);
|
border-color: var(--color-status-warning);
|
||||||
color: var(--color-status-warning);
|
color: var(--color-status-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill--error {
|
.status-pill--error {
|
||||||
border-color: var(--color-status-error);
|
border-color: var(--color-status-error);
|
||||||
color: var(--color-status-error);
|
color: var(--color-status-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-health__details p {
|
.channel-health__details p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-health__details small {
|
.channel-health__details small {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-form h3 {
|
.test-form h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-preview {
|
.test-preview {
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
background: var(--color-surface-tertiary);
|
background: var(--color-surface-tertiary);
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: var(--space-1) 0;
|
margin: var(--space-1) 0;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-body {
|
.preview-body {
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: var(--space-2-5);
|
padding: var(--space-2-5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.deliveries-controls {
|
.deliveries-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.deliveries-controls label {
|
.deliveries-controls label {
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deliveries-table {
|
.deliveries-table {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
thead th {
|
thead th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
padding-bottom: var(--space-2);
|
padding-bottom: var(--space-2);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody td {
|
tbody td {
|
||||||
padding: var(--space-2) var(--space-1);
|
padding: var(--space-2) var(--space-1);
|
||||||
border-top: 1px solid var(--color-border-primary);
|
border-top: 1px solid var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-row {
|
.empty-row {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
padding: var(--space-3) 0;
|
padding: var(--space-3) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: var(--space-0-5) var(--space-2);
|
padding: var(--space-0-5) var(--space-2);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge--sent {
|
.status-badge--sent {
|
||||||
border-color: var(--color-status-success);
|
border-color: var(--color-status-success);
|
||||||
color: var(--color-status-success);
|
color: var(--color-status-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge--failed {
|
.status-badge--failed {
|
||||||
border-color: var(--color-status-error);
|
border-color: var(--color-status-error);
|
||||||
color: var(--color-status-error);
|
color: var(--color-status-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge--throttled {
|
.status-badge--throttled {
|
||||||
border-color: var(--color-status-warning);
|
border-color: var(--color-status-warning);
|
||||||
color: var(--color-status-warning);
|
color: var(--color-status-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
@include screen-below-md {
|
@include screen-below-md {
|
||||||
.notify-panel {
|
.notify-panel {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-panel__header {
|
.notify-panel__header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,66 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { NOTIFY_API } from '../../core/api/notify.client';
|
import { NOTIFY_API } from '../../core/api/notify.client';
|
||||||
import { MockNotifyApiService } from '../../testing/mock-notify-api.service';
|
import { MockNotifyApiService } from '../../testing/mock-notify-api.service';
|
||||||
import { NotifyPanelComponent } from './notify-panel.component';
|
import { NotifyPanelComponent } from './notify-panel.component';
|
||||||
|
|
||||||
describe('NotifyPanelComponent', () => {
|
describe('NotifyPanelComponent', () => {
|
||||||
let fixture: ComponentFixture<NotifyPanelComponent>;
|
let fixture: ComponentFixture<NotifyPanelComponent>;
|
||||||
let component: NotifyPanelComponent;
|
let component: NotifyPanelComponent;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [NotifyPanelComponent],
|
imports: [NotifyPanelComponent],
|
||||||
providers: [
|
providers: [
|
||||||
MockNotifyApiService,
|
MockNotifyApiService,
|
||||||
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
|
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(NotifyPanelComponent);
|
fixture = TestBed.createComponent(NotifyPanelComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders channels from the mocked API', async () => {
|
it('renders channels from the mocked API', async () => {
|
||||||
await component.refreshAll();
|
await component.refreshAll();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const items: NodeListOf<HTMLButtonElement> =
|
const items: NodeListOf<HTMLButtonElement> =
|
||||||
fixture.nativeElement.querySelectorAll('[data-testid="channel-item"]');
|
fixture.nativeElement.querySelectorAll('[data-testid="channel-item"]');
|
||||||
expect(items.length).toBeGreaterThan(0);
|
expect(items.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists a new rule via the mocked API', async () => {
|
it('persists a new rule via the mocked API', async () => {
|
||||||
await component.refreshAll();
|
await component.refreshAll();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
component.createRuleDraft();
|
component.createRuleDraft();
|
||||||
component.ruleForm.patchValue({
|
component.ruleForm.patchValue({
|
||||||
name: 'Notify preview rule',
|
name: 'Notify preview rule',
|
||||||
channel: component.channels()[0]?.channelId ?? '',
|
channel: component.channels()[0]?.channelId ?? '',
|
||||||
eventKindsText: 'scanner.report.ready',
|
eventKindsText: 'scanner.report.ready',
|
||||||
labelsText: 'kev',
|
labelsText: 'kev',
|
||||||
});
|
});
|
||||||
|
|
||||||
await component.saveRule();
|
await component.saveRule();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const ruleButtons: HTMLElement[] = Array.from(
|
const ruleButtons: HTMLElement[] = Array.from(
|
||||||
fixture.nativeElement.querySelectorAll('[data-testid="rule-item"]')
|
fixture.nativeElement.querySelectorAll('[data-testid="rule-item"]')
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
ruleButtons.some((el) => el.textContent?.includes('Notify preview rule'))
|
ruleButtons.some((el) => el.textContent?.includes('Notify preview rule'))
|
||||||
).toBeTrue();
|
).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a test preview after sending', async () => {
|
it('shows a test preview after sending', async () => {
|
||||||
await component.refreshAll();
|
await component.refreshAll();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
await component.sendTestPreview();
|
await component.sendTestPreview();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const preview = fixture.nativeElement.querySelector('[data-testid="test-preview"]');
|
const preview = fixture.nativeElement.querySelector('[data-testid="test-preview"]');
|
||||||
expect(preview).toBeTruthy();
|
expect(preview).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -109,7 +109,7 @@ import { ShadowModeConfig } from '../../core/api/policy-simulation.models';
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #a5b4fc;
|
color: #FFCF70;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow-indicator--enabled .shadow-indicator__icon {
|
.shadow-indicator--enabled .shadow-indicator__icon {
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ import { PolicyApiService } from '../services/policy-api.service';
|
|||||||
|
|
||||||
.approvals__eyebrow {
|
.approvals__eyebrow {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #a5b4fc;
|
color: #FFCF70;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ import type { PolicyParseResult, PolicyIntent } from '../../../core/api/advisory
|
|||||||
}
|
}
|
||||||
|
|
||||||
.intent-badge.type-ScopeRestriction {
|
.intent-badge.type-ScopeRestriction {
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
background: #e0e7ff;
|
background: #e0e7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ import { PolicyApiService } from '../services/policy-api.service';
|
|||||||
|
|
||||||
.sim__eyebrow {
|
.sim__eyebrow {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #a5b4fc;
|
color: #FFCF70;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ import { PolicyPackStore } from '../services/policy-pack.store';
|
|||||||
.workspace__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
|
.workspace__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
|
||||||
.pack-card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; box-shadow: 0 12px 30px rgba(0,0,0,0.28); display: grid; gap: 0.6rem; }
|
.pack-card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; box-shadow: 0 12px 30px rgba(0,0,0,0.28); display: grid; gap: 0.6rem; }
|
||||||
.pack-card__head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
|
.pack-card__head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
|
||||||
.pack-card__eyebrow { margin: 0; color: #a5b4fc; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
|
.pack-card__eyebrow { margin: 0; color: #FFCF70; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
|
||||||
.pack-card__desc { margin: 0.2rem 0 0; color: #cbd5e1; }
|
.pack-card__desc { margin: 0.2rem 0 0; color: #cbd5e1; }
|
||||||
.pack-card__meta { display: grid; justify-items: end; gap: 0.2rem; color: #94a3b8; font-size: 0.9rem; }
|
.pack-card__meta { display: grid; justify-items: end; gap: 0.2rem; color: #94a3b8; font-size: 0.9rem; }
|
||||||
.pack-card__tags { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
.pack-card__tags { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||||
|
|||||||
@@ -563,8 +563,8 @@ type SortOrder = 'asc' | 'desc';
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
border-bottom-color: #4f46e5;
|
border-bottom-color: #F5A623;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,7 +582,7 @@ type SortOrder = 'asc' | 'desc';
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border: 2px solid #e2e8f0;
|
border: 2px solid #e2e8f0;
|
||||||
border-top-color: #4f46e5;
|
border-top-color: #F5A623;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.6s linear infinite;
|
animation: spin 0.6s linear infinite;
|
||||||
}
|
}
|
||||||
@@ -675,7 +675,7 @@ type SortOrder = 'asc' | 'desc';
|
|||||||
}
|
}
|
||||||
|
|
||||||
.policy-studio__link {
|
.policy-studio__link {
|
||||||
color: #4f46e5;
|
color: #F5A623;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
@@ -720,13 +720,13 @@ type SortOrder = 'asc' | 'desc';
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--primary {
|
&--primary {
|
||||||
background: #4f46e5;
|
background: #F5A623;
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: #4338ca;
|
background: #E09115;
|
||||||
border-color: #4338ca;
|
border-color: #E09115;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,8 +762,8 @@ type SortOrder = 'asc' | 'desc';
|
|||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #4f46e5;
|
border-color: #F5A623;
|
||||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
box-shadow: 0 0 0 3px rgba(245, 166, 35, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--sm {
|
&--sm {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Releases Feature Module
|
* Releases Feature Module
|
||||||
* Sprint: SPRINT_20260118_004_FE_releases_feature
|
* Sprint: SPRINT_20260118_004_FE_releases_feature
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './releases.routes';
|
export * from './releases.routes';
|
||||||
export { ReleaseFlowComponent } from './release-flow.component';
|
export { ReleaseFlowComponent } from './release-flow.component';
|
||||||
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
||||||
export { RemediationHintsComponent } from './remediation-hints.component';
|
export { RemediationHintsComponent } from './remediation-hints.component';
|
||||||
|
|||||||
@@ -1,328 +1,328 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
input,
|
input,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
PolicyGateResult,
|
PolicyGateResult,
|
||||||
PolicyGateStatus,
|
PolicyGateStatus,
|
||||||
DeterminismFeatureFlags,
|
DeterminismFeatureFlags,
|
||||||
} from '../../core/api/release.models';
|
} from '../../core/api/release.models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-policy-gate-indicator',
|
selector: 'app-policy-gate-indicator',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
template: `
|
template: `
|
||||||
<div
|
<div
|
||||||
class="gate-indicator"
|
class="gate-indicator"
|
||||||
[class.gate-indicator--expanded]="expanded()"
|
[class.gate-indicator--expanded]="expanded()"
|
||||||
[class]="'gate-indicator--' + gate().status"
|
[class]="'gate-indicator--' + gate().status"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="gate-header"
|
class="gate-header"
|
||||||
(click)="toggleExpanded()"
|
(click)="toggleExpanded()"
|
||||||
[attr.aria-expanded]="expanded()"
|
[attr.aria-expanded]="expanded()"
|
||||||
[attr.aria-controls]="'gate-details-' + gate().gateId"
|
[attr.aria-controls]="'gate-details-' + gate().gateId"
|
||||||
>
|
>
|
||||||
<div class="gate-status">
|
<div class="gate-status">
|
||||||
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
|
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
|
||||||
@switch (gate().status) {
|
@switch (gate().status) {
|
||||||
@case ('passed') { <span>✓</span> }
|
@case ('passed') { <span>✓</span> }
|
||||||
@case ('failed') { <span>✗</span> }
|
@case ('failed') { <span>✗</span> }
|
||||||
@case ('warning') { <span>!</span> }
|
@case ('warning') { <span>!</span> }
|
||||||
@case ('pending') { <span>⌛</span> }
|
@case ('pending') { <span>⌛</span> }
|
||||||
@case ('skipped') { <span>-</span> }
|
@case ('skipped') { <span>-</span> }
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span class="status-text">{{ getStatusLabel() }}</span>
|
<span class="status-text">{{ getStatusLabel() }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gate-info">
|
<div class="gate-info">
|
||||||
<span class="gate-name">{{ gate().name }}</span>
|
<span class="gate-name">{{ gate().name }}</span>
|
||||||
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
|
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
|
||||||
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
|
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
|
||||||
}
|
}
|
||||||
@if (gate().blockingPublish) {
|
@if (gate().blockingPublish) {
|
||||||
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
|
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '▲' : '▼' }}</span>
|
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '▲' : '▼' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (expanded()) {
|
@if (expanded()) {
|
||||||
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
|
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
|
||||||
<p class="gate-message">{{ gate().message }}</p>
|
<p class="gate-message">{{ gate().message }}</p>
|
||||||
<div class="gate-meta">
|
<div class="gate-meta">
|
||||||
<span class="meta-item">
|
<span class="meta-item">
|
||||||
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
|
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
|
||||||
</span>
|
</span>
|
||||||
@if (gate().evidence?.url) {
|
@if (gate().evidence?.url) {
|
||||||
<a
|
<a
|
||||||
[href]="gate().evidence?.url"
|
[href]="gate().evidence?.url"
|
||||||
class="evidence-link"
|
class="evidence-link"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
>
|
>
|
||||||
View Evidence
|
View Evidence
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Determinism-specific info when feature flag shows it -->
|
<!-- Determinism-specific info when feature flag shows it -->
|
||||||
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
|
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
|
||||||
<div class="feature-flag-info">
|
<div class="feature-flag-info">
|
||||||
@if (featureFlags()?.blockOnFailure) {
|
@if (featureFlags()?.blockOnFailure) {
|
||||||
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
|
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
|
||||||
} @else if (featureFlags()?.warnOnly) {
|
} @else if (featureFlags()?.warnOnly) {
|
||||||
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
|
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
.gate-indicator {
|
.gate-indicator {
|
||||||
background: #1e293b;
|
background: #1e293b;
|
||||||
border: 1px solid #334155;
|
border: 1px solid #334155;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
&--passed {
|
&--passed {
|
||||||
border-left: 3px solid #22c55e;
|
border-left: 3px solid #22c55e;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--failed {
|
&--failed {
|
||||||
border-left: 3px solid #ef4444;
|
border-left: 3px solid #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--warning {
|
&--warning {
|
||||||
border-left: 3px solid #f97316;
|
border-left: 3px solid #f97316;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--pending {
|
&--pending {
|
||||||
border-left: 3px solid #eab308;
|
border-left: 3px solid #eab308;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--skipped {
|
&--skipped {
|
||||||
border-left: 3px solid #64748b;
|
border-left: 3px solid #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--expanded {
|
&--expanded {
|
||||||
border-color: #475569;
|
border-color: #475569;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-header {
|
.gate-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-status {
|
.gate-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-icon {
|
.status-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-indicator--passed .status-icon {
|
.gate-indicator--passed .status-icon {
|
||||||
background: rgba(34, 197, 94, 0.2);
|
background: rgba(34, 197, 94, 0.2);
|
||||||
color: #22c55e;
|
color: #22c55e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-indicator--failed .status-icon {
|
.gate-indicator--failed .status-icon {
|
||||||
background: rgba(239, 68, 68, 0.2);
|
background: rgba(239, 68, 68, 0.2);
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-indicator--warning .status-icon {
|
.gate-indicator--warning .status-icon {
|
||||||
background: rgba(249, 115, 22, 0.2);
|
background: rgba(249, 115, 22, 0.2);
|
||||||
color: #f97316;
|
color: #f97316;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-indicator--pending .status-icon {
|
.gate-indicator--pending .status-icon {
|
||||||
background: rgba(234, 179, 8, 0.2);
|
background: rgba(234, 179, 8, 0.2);
|
||||||
color: #eab308;
|
color: #eab308;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-indicator--skipped .status-icon {
|
.gate-indicator--skipped .status-icon {
|
||||||
background: rgba(100, 116, 139, 0.2);
|
background: rgba(100, 116, 139, 0.2);
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-text {
|
.status-text {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-indicator--passed .status-text { color: #22c55e; }
|
.gate-indicator--passed .status-text { color: #22c55e; }
|
||||||
.gate-indicator--failed .status-text { color: #ef4444; }
|
.gate-indicator--failed .status-text { color: #ef4444; }
|
||||||
.gate-indicator--warning .status-text { color: #f97316; }
|
.gate-indicator--warning .status-text { color: #f97316; }
|
||||||
.gate-indicator--pending .status-text { color: #eab308; }
|
.gate-indicator--pending .status-text { color: #eab308; }
|
||||||
.gate-indicator--skipped .status-text { color: #64748b; }
|
.gate-indicator--skipped .status-text { color: #64748b; }
|
||||||
|
|
||||||
.gate-info {
|
.gate-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-name {
|
.gate-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-type-badge {
|
.gate-type-badge {
|
||||||
padding: 0.125rem 0.5rem;
|
padding: 0.125rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.625rem;
|
font-size: 0.625rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
|
||||||
&--determinism {
|
&--determinism {
|
||||||
background: rgba(147, 51, 234, 0.2);
|
background: rgba(147, 51, 234, 0.2);
|
||||||
color: #a855f7;
|
color: #a855f7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.blocking-badge {
|
.blocking-badge {
|
||||||
padding: 0.125rem 0.5rem;
|
padding: 0.125rem 0.5rem;
|
||||||
background: rgba(239, 68, 68, 0.2);
|
background: rgba(239, 68, 68, 0.2);
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.625rem;
|
font-size: 0.625rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expand-icon {
|
.expand-icon {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 0.625rem;
|
font-size: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-details {
|
.gate-details {
|
||||||
padding: 0 1rem 1rem 1rem;
|
padding: 0 1rem 1rem 1rem;
|
||||||
border-top: 1px solid #334155;
|
border-top: 1px solid #334155;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-message {
|
.gate-message {
|
||||||
margin: 0.75rem 0;
|
margin: 0.75rem 0;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-meta {
|
.gate-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-link {
|
.evidence-link {
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-flag-info {
|
.feature-flag-info {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flag-badge {
|
.flag-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.25rem 0.625rem;
|
padding: 0.25rem 0.625rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
background: rgba(147, 51, 234, 0.2);
|
background: rgba(147, 51, 234, 0.2);
|
||||||
color: #a855f7;
|
color: #a855f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--warn {
|
&--warn {
|
||||||
background: rgba(234, 179, 8, 0.2);
|
background: rgba(234, 179, 8, 0.2);
|
||||||
color: #eab308;
|
color: #eab308;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`],
|
`],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class PolicyGateIndicatorComponent {
|
export class PolicyGateIndicatorComponent {
|
||||||
readonly gate = input.required<PolicyGateResult>();
|
readonly gate = input.required<PolicyGateResult>();
|
||||||
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
|
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
|
||||||
|
|
||||||
readonly expanded = signal(false);
|
readonly expanded = signal(false);
|
||||||
|
|
||||||
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
|
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
|
||||||
|
|
||||||
toggleExpanded(): void {
|
toggleExpanded(): void {
|
||||||
this.expanded.update((v) => !v);
|
this.expanded.update((v) => !v);
|
||||||
}
|
}
|
||||||
|
|
||||||
getStatusLabel(): string {
|
getStatusLabel(): string {
|
||||||
const labels: Record<PolicyGateStatus, string> = {
|
const labels: Record<PolicyGateStatus, string> = {
|
||||||
passed: 'Passed',
|
passed: 'Passed',
|
||||||
failed: 'Failed',
|
failed: 'Failed',
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
warning: 'Warning',
|
warning: 'Warning',
|
||||||
skipped: 'Skipped',
|
skipped: 'Skipped',
|
||||||
};
|
};
|
||||||
return labels[this.gate().status] ?? 'Unknown';
|
return labels[this.gate().status] ?? 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
getStatusIconClass(): string {
|
getStatusIconClass(): string {
|
||||||
return `status-icon--${this.gate().status}`;
|
return `status-icon--${this.gate().status}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
formatDate(isoString: string): string {
|
formatDate(isoString: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(isoString).toLocaleString();
|
return new Date(isoString).toLocaleString();
|
||||||
} catch {
|
} catch {
|
||||||
return isoString;
|
return isoString;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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