up tests and theme
This commit is contained in:
@@ -1,174 +1,175 @@
|
||||
/**
|
||||
* App Component Styles
|
||||
* Migrated to design system tokens
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-family-base);
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.quickstart-banner {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
font-size: var(--font-size-sm);
|
||||
border-bottom: 1px solid var(--color-status-warning-border);
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
background: var(--color-header-bg);
|
||||
color: var(--color-header-text);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
// Navigation takes remaining space
|
||||
app-navigation-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-left: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.02em;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.app-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
|
||||
.app-tenant {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--color-header-text-muted);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app-fresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-0-5) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.7rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.03em;
|
||||
background-color: var(--color-fresh-active-bg);
|
||||
color: var(--color-fresh-active-text);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.app-fresh--stale {
|
||||
background-color: var(--color-fresh-stale-bg);
|
||||
color: var(--color-fresh-stale-text);
|
||||
}
|
||||
}
|
||||
|
||||
&__signin {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
color: var(--color-surface-inverse);
|
||||
background-color: rgba(248, 250, 252, 0.9);
|
||||
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-accent-yellow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
padding: var(--space-6) var(--space-6) var(--space-8);
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
// Breadcrumb styling
|
||||
app-breadcrumb {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
// Page container with transition animations
|
||||
.page-container {
|
||||
animation: page-fade-in var(--motion-duration-slow) var(--motion-ease-spring);
|
||||
}
|
||||
|
||||
@keyframes page-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Respect reduced motion preference
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-container {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.app-auth__signin {
|
||||
transition: none;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* App Component Styles
|
||||
* Migrated to design system tokens
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-family-base);
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.quickstart-banner {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
font-size: var(--font-size-sm);
|
||||
border-bottom: 1px solid var(--color-status-warning-border);
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
background: var(--color-header-bg);
|
||||
color: var(--color-header-text);
|
||||
box-shadow: var(--shadow-md);
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
|
||||
// Navigation takes remaining space
|
||||
app-navigation-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-left: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.02em;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.app-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
|
||||
.app-tenant {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--color-header-text-muted);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app-fresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-0-5) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.7rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.03em;
|
||||
background-color: var(--color-fresh-active-bg);
|
||||
color: var(--color-fresh-active-text);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.app-fresh--stale {
|
||||
background-color: var(--color-fresh-stale-bg);
|
||||
color: var(--color-fresh-stale-text);
|
||||
}
|
||||
}
|
||||
|
||||
&__signin {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
color: var(--color-surface-inverse);
|
||||
background-color: rgba(248, 250, 252, 0.9);
|
||||
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-accent-yellow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
padding: var(--space-6) var(--space-6) var(--space-8);
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
// Breadcrumb styling
|
||||
app-breadcrumb {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
// Page container with transition animations
|
||||
.page-container {
|
||||
animation: page-fade-in var(--motion-duration-slow) var(--motion-ease-spring);
|
||||
}
|
||||
|
||||
@keyframes page-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Respect reduced motion preference
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-container {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.app-auth__signin {
|
||||
transition: none;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { AuthorityAuthService } from './core/auth/authority-auth.service';
|
||||
import { AuthSessionStore } from './core/auth/auth-session.store';
|
||||
import { AUTH_SERVICE, AuthService } from './core/auth';
|
||||
import { ConsoleSessionStore } from './core/console/console-session.store';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
import { PolicyPackStore } from './features/policy-studio/services/policy-pack.store';
|
||||
|
||||
class AuthorityAuthServiceStub {
|
||||
beginLogin = jasmine.createSpy('beginLogin');
|
||||
logout = jasmine.createSpy('logout');
|
||||
}
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent, RouterTestingModule, HttpClientTestingModule],
|
||||
providers: [
|
||||
AuthSessionStore,
|
||||
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
|
||||
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
|
||||
ConsoleSessionStore,
|
||||
{ provide: AppConfigService, useValue: { config: { quickstartMode: false, apiBaseUrls: { authority: '', policy: '' } } } },
|
||||
{
|
||||
provide: PolicyPackStore,
|
||||
useValue: {
|
||||
getPacks: () =>
|
||||
of([
|
||||
{ id: 'pack-1', name: 'Pack One', description: '', version: '1.0', status: 'active', createdAt: '', modifiedAt: '', createdBy: '', modifiedBy: '', tags: [] },
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('creates the root component', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a router outlet for child routes', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('router-outlet')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { AuthorityAuthService } from './core/auth/authority-auth.service';
|
||||
import { AuthSessionStore } from './core/auth/auth-session.store';
|
||||
import { AUTH_SERVICE, AuthService } from './core/auth';
|
||||
import { ConsoleSessionStore } from './core/console/console-session.store';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
import { PolicyPackStore } from './features/policy-studio/services/policy-pack.store';
|
||||
|
||||
class AuthorityAuthServiceStub {
|
||||
beginLogin = jasmine.createSpy('beginLogin');
|
||||
logout = jasmine.createSpy('logout');
|
||||
}
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent, RouterTestingModule, HttpClientTestingModule],
|
||||
providers: [
|
||||
AuthSessionStore,
|
||||
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
|
||||
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
|
||||
ConsoleSessionStore,
|
||||
{ provide: AppConfigService, useValue: { config: { quickstartMode: false, apiBaseUrls: { authority: '', policy: '' } } } },
|
||||
{
|
||||
provide: PolicyPackStore,
|
||||
useValue: {
|
||||
getPacks: () =>
|
||||
of([
|
||||
{ id: 'pack-1', name: 'Pack One', description: '', version: '1.0', status: 'active', createdAt: '', modifiedAt: '', createdBy: '', modifiedBy: '', tags: [] },
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('creates the root component', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a router outlet for child routes', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('router-outlet')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,382 +1,382 @@
|
||||
/**
|
||||
* AOC (Authorization of Containers) models for dashboard metrics.
|
||||
*/
|
||||
|
||||
export interface AocMetrics {
|
||||
/** Pass/fail counts for the time window */
|
||||
passCount: number;
|
||||
failCount: number;
|
||||
totalCount: number;
|
||||
passRate: number;
|
||||
|
||||
/** Recent violations grouped by code */
|
||||
recentViolations: AocViolationSummary[];
|
||||
|
||||
/** Ingest throughput metrics */
|
||||
ingestThroughput: AocIngestThroughput;
|
||||
|
||||
/** Time window for these metrics */
|
||||
timeWindow: {
|
||||
start: string;
|
||||
end: string;
|
||||
durationMinutes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AocViolationSummary {
|
||||
code: string;
|
||||
description: string;
|
||||
count: number;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface AocIngestThroughput {
|
||||
/** Documents processed per minute */
|
||||
docsPerMinute: number;
|
||||
/** Average processing latency in milliseconds */
|
||||
avgLatencyMs: number;
|
||||
/** P95 latency in milliseconds */
|
||||
p95LatencyMs: number;
|
||||
/** Current queue depth */
|
||||
queueDepth: number;
|
||||
/** Error rate percentage */
|
||||
errorRate: number;
|
||||
}
|
||||
|
||||
export interface AocVerificationRequest {
|
||||
tenantId: string;
|
||||
since?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AocVerificationResult {
|
||||
verificationId: string;
|
||||
status: 'passed' | 'failed' | 'partial';
|
||||
checkedCount: number;
|
||||
passedCount: number;
|
||||
failedCount: number;
|
||||
violations: AocViolationDetail[];
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
export interface AocViolationDetail {
|
||||
documentId: string;
|
||||
violationCode: string;
|
||||
field?: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
provenance?: AocProvenance;
|
||||
}
|
||||
|
||||
export interface AocProvenance {
|
||||
sourceId: string;
|
||||
ingestedAt: string;
|
||||
digest: string;
|
||||
sourceType?: 'registry' | 'git' | 'upload' | 'api';
|
||||
sourceUrl?: string;
|
||||
submitter?: string;
|
||||
}
|
||||
|
||||
export interface AocViolationGroup {
|
||||
code: string;
|
||||
description: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
violations: AocViolationDetail[];
|
||||
affectedDocuments: number;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
export interface AocDocumentView {
|
||||
documentId: string;
|
||||
documentType: string;
|
||||
violations: AocViolationDetail[];
|
||||
provenance: AocProvenance;
|
||||
rawContent?: Record<string, unknown>;
|
||||
highlightedFields: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Violation severity levels.
|
||||
*/
|
||||
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/**
|
||||
* AOC source configuration.
|
||||
*/
|
||||
export interface AocSource {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
name: string;
|
||||
type: 'registry' | 'git' | 'upload' | 'api';
|
||||
url?: string;
|
||||
enabled: boolean;
|
||||
lastSync?: string;
|
||||
status: 'healthy' | 'degraded' | 'offline';
|
||||
}
|
||||
|
||||
/**
|
||||
* Violation code definition.
|
||||
*/
|
||||
export interface AocViolationCode {
|
||||
code: string;
|
||||
description: string;
|
||||
severity: ViolationSeverity;
|
||||
category: string;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard summary data.
|
||||
*/
|
||||
export interface AocDashboardSummary {
|
||||
/** Pass/fail metrics */
|
||||
passFail: {
|
||||
passCount: number;
|
||||
failCount: number;
|
||||
totalCount: number;
|
||||
passRate: number;
|
||||
trend?: 'improving' | 'degrading' | 'stable';
|
||||
history?: { timestamp: string; value: number }[];
|
||||
};
|
||||
/** Recent violations */
|
||||
recentViolations: AocViolationSummary[];
|
||||
/** Ingest throughput */
|
||||
throughput: AocIngestThroughput;
|
||||
/** Throughput by tenant */
|
||||
throughputByTenant: TenantThroughput[];
|
||||
/** Configured sources */
|
||||
sources: AocSource[];
|
||||
/** Time window */
|
||||
timeWindow: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant-level throughput metrics.
|
||||
*/
|
||||
export interface TenantThroughput {
|
||||
tenantId: string;
|
||||
tenantName?: string;
|
||||
documentsIngested: number;
|
||||
bytesIngested: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field that caused a violation.
|
||||
*/
|
||||
export interface OffendingField {
|
||||
path: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
expectedValue?: string;
|
||||
actualValue?: string;
|
||||
reason: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed violation record for display.
|
||||
*/
|
||||
export interface ViolationDetail {
|
||||
violationId: string;
|
||||
documentType: string;
|
||||
documentId: string;
|
||||
severity: ViolationSeverity;
|
||||
detectedAt: string;
|
||||
offendingFields: OffendingField[];
|
||||
provenance: ViolationProvenance;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance metadata for a violation.
|
||||
*/
|
||||
export interface ViolationProvenance {
|
||||
sourceType: string;
|
||||
sourceUri: string;
|
||||
ingestedAt: string;
|
||||
ingestedBy: string;
|
||||
buildId?: string;
|
||||
commitSha?: string;
|
||||
pipelineUrl?: string;
|
||||
}
|
||||
|
||||
// Type aliases for backwards compatibility
|
||||
export type IngestThroughput = AocIngestThroughput;
|
||||
export type VerificationRequest = AocVerificationRequest;
|
||||
|
||||
// =============================================================================
|
||||
// Sprint 027: AOC Compliance Dashboard Extensions
|
||||
// =============================================================================
|
||||
|
||||
// Guard violation types for AOC ingestion
|
||||
export type GuardViolationReason =
|
||||
| 'schema_invalid'
|
||||
| 'untrusted_source'
|
||||
| 'duplicate'
|
||||
| 'malformed_timestamp'
|
||||
| 'missing_required_fields'
|
||||
| 'hash_mismatch'
|
||||
| 'unknown';
|
||||
|
||||
export interface GuardViolation {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
source: string;
|
||||
reason: GuardViolationReason;
|
||||
message: string;
|
||||
payloadSample?: string;
|
||||
module: 'concelier' | 'excititor';
|
||||
canRetry: boolean;
|
||||
}
|
||||
|
||||
// Ingestion flow metrics
|
||||
export interface IngestionSourceMetrics {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
module: 'concelier' | 'excititor';
|
||||
throughputPerMinute: number;
|
||||
latencyP50Ms: number;
|
||||
latencyP95Ms: number;
|
||||
latencyP99Ms: number;
|
||||
errorRate: number;
|
||||
backlogDepth: number;
|
||||
lastIngestionAt: string;
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
}
|
||||
|
||||
export interface IngestionFlowSummary {
|
||||
sources: IngestionSourceMetrics[];
|
||||
totalThroughput: number;
|
||||
avgLatencyP95Ms: number;
|
||||
overallErrorRate: number;
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
// Provenance chain types
|
||||
export type ProvenanceStepType =
|
||||
| 'source'
|
||||
| 'advisory_raw'
|
||||
| 'normalized'
|
||||
| 'vex_decision'
|
||||
| 'finding'
|
||||
| 'policy_verdict'
|
||||
| 'attestation';
|
||||
|
||||
export interface ProvenanceStep {
|
||||
stepType: ProvenanceStepType;
|
||||
label: string;
|
||||
timestamp: string;
|
||||
hash?: string;
|
||||
linkedFromHash?: string;
|
||||
status: 'valid' | 'warning' | 'error' | 'pending';
|
||||
details: Record<string, unknown>;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface ProvenanceChain {
|
||||
inputType: 'advisory_id' | 'finding_id' | 'cve_id';
|
||||
inputValue: string;
|
||||
steps: ProvenanceStep[];
|
||||
isComplete: boolean;
|
||||
validationErrors: string[];
|
||||
validatedAt: string;
|
||||
}
|
||||
|
||||
// AOC compliance metrics
|
||||
export interface AocComplianceMetrics {
|
||||
guardViolations: {
|
||||
count: number;
|
||||
percentage: number;
|
||||
byReason: Record<string, number>;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
provenanceCompleteness: {
|
||||
percentage: number;
|
||||
recordsWithValidHash: number;
|
||||
totalRecords: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
deduplicationRate: {
|
||||
percentage: number;
|
||||
duplicatesDetected: number;
|
||||
totalIngested: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
ingestionLatency: {
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
p99Ms: number;
|
||||
meetsSla: boolean;
|
||||
slaTargetP95Ms: number;
|
||||
};
|
||||
supersedesDepth: {
|
||||
maxDepth: number;
|
||||
avgDepth: number;
|
||||
distribution: { depth: number; count: number }[];
|
||||
};
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
// Compliance report
|
||||
export type ComplianceReportFormat = 'csv' | 'json';
|
||||
|
||||
export interface ComplianceReportRequest {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
sources?: string[];
|
||||
format: ComplianceReportFormat;
|
||||
includeViolationDetails: boolean;
|
||||
}
|
||||
|
||||
export interface ComplianceReportSummary {
|
||||
reportId: string;
|
||||
generatedAt: string;
|
||||
period: { start: string; end: string };
|
||||
guardViolationSummary: {
|
||||
total: number;
|
||||
bySource: Record<string, number>;
|
||||
byReason: Record<string, number>;
|
||||
};
|
||||
provenanceCompliance: {
|
||||
percentage: number;
|
||||
bySource: Record<string, number>;
|
||||
};
|
||||
deduplicationMetrics: {
|
||||
rate: number;
|
||||
bySource: Record<string, number>;
|
||||
};
|
||||
latencyMetrics: {
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
p99Ms: number;
|
||||
bySource: Record<string, { p50: number; p95: number; p99: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
// API response wrappers
|
||||
export interface AocComplianceDashboardData {
|
||||
metrics: AocComplianceMetrics;
|
||||
recentViolations: GuardViolation[];
|
||||
ingestionFlow: IngestionFlowSummary;
|
||||
}
|
||||
|
||||
export interface GuardViolationsPagedResponse {
|
||||
items: GuardViolation[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
// Filter options
|
||||
export interface AocDashboardFilters {
|
||||
dateRange: { start: string; end: string };
|
||||
sources?: string[];
|
||||
modules?: ('concelier' | 'excititor')[];
|
||||
violationReasons?: GuardViolationReason[];
|
||||
}
|
||||
/**
|
||||
* AOC (Authorization of Containers) models for dashboard metrics.
|
||||
*/
|
||||
|
||||
export interface AocMetrics {
|
||||
/** Pass/fail counts for the time window */
|
||||
passCount: number;
|
||||
failCount: number;
|
||||
totalCount: number;
|
||||
passRate: number;
|
||||
|
||||
/** Recent violations grouped by code */
|
||||
recentViolations: AocViolationSummary[];
|
||||
|
||||
/** Ingest throughput metrics */
|
||||
ingestThroughput: AocIngestThroughput;
|
||||
|
||||
/** Time window for these metrics */
|
||||
timeWindow: {
|
||||
start: string;
|
||||
end: string;
|
||||
durationMinutes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AocViolationSummary {
|
||||
code: string;
|
||||
description: string;
|
||||
count: number;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface AocIngestThroughput {
|
||||
/** Documents processed per minute */
|
||||
docsPerMinute: number;
|
||||
/** Average processing latency in milliseconds */
|
||||
avgLatencyMs: number;
|
||||
/** P95 latency in milliseconds */
|
||||
p95LatencyMs: number;
|
||||
/** Current queue depth */
|
||||
queueDepth: number;
|
||||
/** Error rate percentage */
|
||||
errorRate: number;
|
||||
}
|
||||
|
||||
export interface AocVerificationRequest {
|
||||
tenantId: string;
|
||||
since?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AocVerificationResult {
|
||||
verificationId: string;
|
||||
status: 'passed' | 'failed' | 'partial';
|
||||
checkedCount: number;
|
||||
passedCount: number;
|
||||
failedCount: number;
|
||||
violations: AocViolationDetail[];
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
export interface AocViolationDetail {
|
||||
documentId: string;
|
||||
violationCode: string;
|
||||
field?: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
provenance?: AocProvenance;
|
||||
}
|
||||
|
||||
export interface AocProvenance {
|
||||
sourceId: string;
|
||||
ingestedAt: string;
|
||||
digest: string;
|
||||
sourceType?: 'registry' | 'git' | 'upload' | 'api';
|
||||
sourceUrl?: string;
|
||||
submitter?: string;
|
||||
}
|
||||
|
||||
export interface AocViolationGroup {
|
||||
code: string;
|
||||
description: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
violations: AocViolationDetail[];
|
||||
affectedDocuments: number;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
export interface AocDocumentView {
|
||||
documentId: string;
|
||||
documentType: string;
|
||||
violations: AocViolationDetail[];
|
||||
provenance: AocProvenance;
|
||||
rawContent?: Record<string, unknown>;
|
||||
highlightedFields: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Violation severity levels.
|
||||
*/
|
||||
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/**
|
||||
* AOC source configuration.
|
||||
*/
|
||||
export interface AocSource {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
name: string;
|
||||
type: 'registry' | 'git' | 'upload' | 'api';
|
||||
url?: string;
|
||||
enabled: boolean;
|
||||
lastSync?: string;
|
||||
status: 'healthy' | 'degraded' | 'offline';
|
||||
}
|
||||
|
||||
/**
|
||||
* Violation code definition.
|
||||
*/
|
||||
export interface AocViolationCode {
|
||||
code: string;
|
||||
description: string;
|
||||
severity: ViolationSeverity;
|
||||
category: string;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard summary data.
|
||||
*/
|
||||
export interface AocDashboardSummary {
|
||||
/** Pass/fail metrics */
|
||||
passFail: {
|
||||
passCount: number;
|
||||
failCount: number;
|
||||
totalCount: number;
|
||||
passRate: number;
|
||||
trend?: 'improving' | 'degrading' | 'stable';
|
||||
history?: { timestamp: string; value: number }[];
|
||||
};
|
||||
/** Recent violations */
|
||||
recentViolations: AocViolationSummary[];
|
||||
/** Ingest throughput */
|
||||
throughput: AocIngestThroughput;
|
||||
/** Throughput by tenant */
|
||||
throughputByTenant: TenantThroughput[];
|
||||
/** Configured sources */
|
||||
sources: AocSource[];
|
||||
/** Time window */
|
||||
timeWindow: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant-level throughput metrics.
|
||||
*/
|
||||
export interface TenantThroughput {
|
||||
tenantId: string;
|
||||
tenantName?: string;
|
||||
documentsIngested: number;
|
||||
bytesIngested: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field that caused a violation.
|
||||
*/
|
||||
export interface OffendingField {
|
||||
path: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
expectedValue?: string;
|
||||
actualValue?: string;
|
||||
reason: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed violation record for display.
|
||||
*/
|
||||
export interface ViolationDetail {
|
||||
violationId: string;
|
||||
documentType: string;
|
||||
documentId: string;
|
||||
severity: ViolationSeverity;
|
||||
detectedAt: string;
|
||||
offendingFields: OffendingField[];
|
||||
provenance: ViolationProvenance;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance metadata for a violation.
|
||||
*/
|
||||
export interface ViolationProvenance {
|
||||
sourceType: string;
|
||||
sourceUri: string;
|
||||
ingestedAt: string;
|
||||
ingestedBy: string;
|
||||
buildId?: string;
|
||||
commitSha?: string;
|
||||
pipelineUrl?: string;
|
||||
}
|
||||
|
||||
// Type aliases for backwards compatibility
|
||||
export type IngestThroughput = AocIngestThroughput;
|
||||
export type VerificationRequest = AocVerificationRequest;
|
||||
|
||||
// =============================================================================
|
||||
// Sprint 027: AOC Compliance Dashboard Extensions
|
||||
// =============================================================================
|
||||
|
||||
// Guard violation types for AOC ingestion
|
||||
export type GuardViolationReason =
|
||||
| 'schema_invalid'
|
||||
| 'untrusted_source'
|
||||
| 'duplicate'
|
||||
| 'malformed_timestamp'
|
||||
| 'missing_required_fields'
|
||||
| 'hash_mismatch'
|
||||
| 'unknown';
|
||||
|
||||
export interface GuardViolation {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
source: string;
|
||||
reason: GuardViolationReason;
|
||||
message: string;
|
||||
payloadSample?: string;
|
||||
module: 'concelier' | 'excititor';
|
||||
canRetry: boolean;
|
||||
}
|
||||
|
||||
// Ingestion flow metrics
|
||||
export interface IngestionSourceMetrics {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
module: 'concelier' | 'excititor';
|
||||
throughputPerMinute: number;
|
||||
latencyP50Ms: number;
|
||||
latencyP95Ms: number;
|
||||
latencyP99Ms: number;
|
||||
errorRate: number;
|
||||
backlogDepth: number;
|
||||
lastIngestionAt: string;
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
}
|
||||
|
||||
export interface IngestionFlowSummary {
|
||||
sources: IngestionSourceMetrics[];
|
||||
totalThroughput: number;
|
||||
avgLatencyP95Ms: number;
|
||||
overallErrorRate: number;
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
// Provenance chain types
|
||||
export type ProvenanceStepType =
|
||||
| 'source'
|
||||
| 'advisory_raw'
|
||||
| 'normalized'
|
||||
| 'vex_decision'
|
||||
| 'finding'
|
||||
| 'policy_verdict'
|
||||
| 'attestation';
|
||||
|
||||
export interface ProvenanceStep {
|
||||
stepType: ProvenanceStepType;
|
||||
label: string;
|
||||
timestamp: string;
|
||||
hash?: string;
|
||||
linkedFromHash?: string;
|
||||
status: 'valid' | 'warning' | 'error' | 'pending';
|
||||
details: Record<string, unknown>;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface ProvenanceChain {
|
||||
inputType: 'advisory_id' | 'finding_id' | 'cve_id';
|
||||
inputValue: string;
|
||||
steps: ProvenanceStep[];
|
||||
isComplete: boolean;
|
||||
validationErrors: string[];
|
||||
validatedAt: string;
|
||||
}
|
||||
|
||||
// AOC compliance metrics
|
||||
export interface AocComplianceMetrics {
|
||||
guardViolations: {
|
||||
count: number;
|
||||
percentage: number;
|
||||
byReason: Record<string, number>;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
provenanceCompleteness: {
|
||||
percentage: number;
|
||||
recordsWithValidHash: number;
|
||||
totalRecords: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
deduplicationRate: {
|
||||
percentage: number;
|
||||
duplicatesDetected: number;
|
||||
totalIngested: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
ingestionLatency: {
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
p99Ms: number;
|
||||
meetsSla: boolean;
|
||||
slaTargetP95Ms: number;
|
||||
};
|
||||
supersedesDepth: {
|
||||
maxDepth: number;
|
||||
avgDepth: number;
|
||||
distribution: { depth: number; count: number }[];
|
||||
};
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
// Compliance report
|
||||
export type ComplianceReportFormat = 'csv' | 'json';
|
||||
|
||||
export interface ComplianceReportRequest {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
sources?: string[];
|
||||
format: ComplianceReportFormat;
|
||||
includeViolationDetails: boolean;
|
||||
}
|
||||
|
||||
export interface ComplianceReportSummary {
|
||||
reportId: string;
|
||||
generatedAt: string;
|
||||
period: { start: string; end: string };
|
||||
guardViolationSummary: {
|
||||
total: number;
|
||||
bySource: Record<string, number>;
|
||||
byReason: Record<string, number>;
|
||||
};
|
||||
provenanceCompliance: {
|
||||
percentage: number;
|
||||
bySource: Record<string, number>;
|
||||
};
|
||||
deduplicationMetrics: {
|
||||
rate: number;
|
||||
bySource: Record<string, number>;
|
||||
};
|
||||
latencyMetrics: {
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
p99Ms: number;
|
||||
bySource: Record<string, { p50: number; p95: number; p99: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
// API response wrappers
|
||||
export interface AocComplianceDashboardData {
|
||||
metrics: AocComplianceMetrics;
|
||||
recentViolations: GuardViolation[];
|
||||
ingestionFlow: IngestionFlowSummary;
|
||||
}
|
||||
|
||||
export interface GuardViolationsPagedResponse {
|
||||
items: GuardViolation[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
// Filter options
|
||||
export interface AocDashboardFilters {
|
||||
dateRange: { start: string; end: string };
|
||||
sources?: string[];
|
||||
modules?: ('concelier' | 'excititor')[];
|
||||
violationReasons?: GuardViolationReason[];
|
||||
}
|
||||
|
||||
@@ -1,113 +1,113 @@
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
|
||||
export interface AuthorityTenantViewDto {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly status: string;
|
||||
readonly isolationMode: string;
|
||||
readonly defaultRoles: readonly string[];
|
||||
}
|
||||
|
||||
export interface TenantCatalogResponseDto {
|
||||
readonly tenants: readonly AuthorityTenantViewDto[];
|
||||
}
|
||||
|
||||
export interface ConsoleProfileDto {
|
||||
readonly subjectId: string | null;
|
||||
readonly username: string | null;
|
||||
readonly displayName: string | null;
|
||||
readonly tenant: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationMethods: readonly string[];
|
||||
readonly issuedAt: string | null;
|
||||
readonly authenticationTime: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleTokenIntrospectionDto {
|
||||
readonly active: boolean;
|
||||
readonly tenant: string;
|
||||
readonly subject: string | null;
|
||||
readonly clientId: string | null;
|
||||
readonly tokenId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly issuedAt: string | null;
|
||||
readonly authenticationTime: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface AuthorityConsoleApi {
|
||||
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto>;
|
||||
getProfile(tenantId?: string): Observable<ConsoleProfileDto>;
|
||||
introspectToken(
|
||||
tenantId?: string
|
||||
): Observable<ConsoleTokenIntrospectionDto>;
|
||||
}
|
||||
|
||||
export const AUTHORITY_CONSOLE_API = new InjectionToken<AuthorityConsoleApi>(
|
||||
'AUTHORITY_CONSOLE_API'
|
||||
);
|
||||
|
||||
export const AUTHORITY_CONSOLE_API_BASE_URL = new InjectionToken<string>(
|
||||
'AUTHORITY_CONSOLE_API_BASE_URL'
|
||||
);
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthorityConsoleApiHttpClient implements AuthorityConsoleApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
@Inject(AUTHORITY_CONSOLE_API_BASE_URL) private readonly baseUrl: string,
|
||||
private readonly authSession: AuthSessionStore
|
||||
) {}
|
||||
|
||||
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto> {
|
||||
return this.http.get<TenantCatalogResponseDto>(`${this.baseUrl}/tenants`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
getProfile(tenantId?: string): Observable<ConsoleProfileDto> {
|
||||
return this.http.get<ConsoleProfileDto>(`${this.baseUrl}/profile`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
introspectToken(
|
||||
tenantId?: string
|
||||
): Observable<ConsoleTokenIntrospectionDto> {
|
||||
return this.http.post<ConsoleTokenIntrospectionDto>(
|
||||
`${this.baseUrl}/token/introspect`,
|
||||
{},
|
||||
{
|
||||
headers: this.buildHeaders(tenantId),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(tenantOverride?: string): HttpHeaders {
|
||||
const tenantId =
|
||||
(tenantOverride && tenantOverride.trim()) ||
|
||||
this.authSession.getActiveTenantId();
|
||||
if (!tenantId) {
|
||||
throw new Error(
|
||||
'AuthorityConsoleApiHttpClient requires an active tenant identifier.'
|
||||
);
|
||||
}
|
||||
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
|
||||
export interface AuthorityTenantViewDto {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly status: string;
|
||||
readonly isolationMode: string;
|
||||
readonly defaultRoles: readonly string[];
|
||||
}
|
||||
|
||||
export interface TenantCatalogResponseDto {
|
||||
readonly tenants: readonly AuthorityTenantViewDto[];
|
||||
}
|
||||
|
||||
export interface ConsoleProfileDto {
|
||||
readonly subjectId: string | null;
|
||||
readonly username: string | null;
|
||||
readonly displayName: string | null;
|
||||
readonly tenant: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationMethods: readonly string[];
|
||||
readonly issuedAt: string | null;
|
||||
readonly authenticationTime: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleTokenIntrospectionDto {
|
||||
readonly active: boolean;
|
||||
readonly tenant: string;
|
||||
readonly subject: string | null;
|
||||
readonly clientId: string | null;
|
||||
readonly tokenId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly issuedAt: string | null;
|
||||
readonly authenticationTime: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface AuthorityConsoleApi {
|
||||
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto>;
|
||||
getProfile(tenantId?: string): Observable<ConsoleProfileDto>;
|
||||
introspectToken(
|
||||
tenantId?: string
|
||||
): Observable<ConsoleTokenIntrospectionDto>;
|
||||
}
|
||||
|
||||
export const AUTHORITY_CONSOLE_API = new InjectionToken<AuthorityConsoleApi>(
|
||||
'AUTHORITY_CONSOLE_API'
|
||||
);
|
||||
|
||||
export const AUTHORITY_CONSOLE_API_BASE_URL = new InjectionToken<string>(
|
||||
'AUTHORITY_CONSOLE_API_BASE_URL'
|
||||
);
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthorityConsoleApiHttpClient implements AuthorityConsoleApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
@Inject(AUTHORITY_CONSOLE_API_BASE_URL) private readonly baseUrl: string,
|
||||
private readonly authSession: AuthSessionStore
|
||||
) {}
|
||||
|
||||
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto> {
|
||||
return this.http.get<TenantCatalogResponseDto>(`${this.baseUrl}/tenants`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
getProfile(tenantId?: string): Observable<ConsoleProfileDto> {
|
||||
return this.http.get<ConsoleProfileDto>(`${this.baseUrl}/profile`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
introspectToken(
|
||||
tenantId?: string
|
||||
): Observable<ConsoleTokenIntrospectionDto> {
|
||||
return this.http.post<ConsoleTokenIntrospectionDto>(
|
||||
`${this.baseUrl}/token/introspect`,
|
||||
{},
|
||||
{
|
||||
headers: this.buildHeaders(tenantId),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(tenantOverride?: string): HttpHeaders {
|
||||
const tenantId =
|
||||
(tenantOverride && tenantOverride.trim()) ||
|
||||
this.authSession.getActiveTenantId();
|
||||
if (!tenantId) {
|
||||
throw new Error(
|
||||
'AuthorityConsoleApiHttpClient requires an active tenant identifier.'
|
||||
);
|
||||
}
|
||||
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
Injectable,
|
||||
InjectionToken,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface TrivyDbSettingsDto {
|
||||
publishFull: boolean;
|
||||
publishDelta: boolean;
|
||||
includeFull: boolean;
|
||||
includeDelta: boolean;
|
||||
}
|
||||
|
||||
export interface TrivyDbRunResponseDto {
|
||||
exportId: string;
|
||||
triggeredAt: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export const CONCELIER_EXPORTER_API_BASE_URL = new InjectionToken<string>(
|
||||
'CONCELIER_EXPORTER_API_BASE_URL'
|
||||
);
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConcelierExporterClient {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = inject(CONCELIER_EXPORTER_API_BASE_URL);
|
||||
|
||||
getTrivyDbSettings(): Observable<TrivyDbSettingsDto> {
|
||||
return this.http.get<TrivyDbSettingsDto>(`${this.baseUrl}/settings`);
|
||||
}
|
||||
|
||||
updateTrivyDbSettings(
|
||||
settings: TrivyDbSettingsDto
|
||||
): Observable<TrivyDbSettingsDto> {
|
||||
return this.http.put<TrivyDbSettingsDto>(`${this.baseUrl}/settings`, settings);
|
||||
}
|
||||
|
||||
runTrivyDbExport(
|
||||
settings: TrivyDbSettingsDto
|
||||
): Observable<TrivyDbRunResponseDto> {
|
||||
return this.http.post<TrivyDbRunResponseDto>(`${this.baseUrl}/run`, {
|
||||
trigger: 'ui',
|
||||
parameters: settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
Injectable,
|
||||
InjectionToken,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface TrivyDbSettingsDto {
|
||||
publishFull: boolean;
|
||||
publishDelta: boolean;
|
||||
includeFull: boolean;
|
||||
includeDelta: boolean;
|
||||
}
|
||||
|
||||
export interface TrivyDbRunResponseDto {
|
||||
exportId: string;
|
||||
triggeredAt: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export const CONCELIER_EXPORTER_API_BASE_URL = new InjectionToken<string>(
|
||||
'CONCELIER_EXPORTER_API_BASE_URL'
|
||||
);
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConcelierExporterClient {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = inject(CONCELIER_EXPORTER_API_BASE_URL);
|
||||
|
||||
getTrivyDbSettings(): Observable<TrivyDbSettingsDto> {
|
||||
return this.http.get<TrivyDbSettingsDto>(`${this.baseUrl}/settings`);
|
||||
}
|
||||
|
||||
updateTrivyDbSettings(
|
||||
settings: TrivyDbSettingsDto
|
||||
): Observable<TrivyDbSettingsDto> {
|
||||
return this.http.put<TrivyDbSettingsDto>(`${this.baseUrl}/settings`, settings);
|
||||
}
|
||||
|
||||
runTrivyDbExport(
|
||||
settings: TrivyDbSettingsDto
|
||||
): Observable<TrivyDbRunResponseDto> {
|
||||
return this.http.post<TrivyDbRunResponseDto>(`${this.baseUrl}/run`, {
|
||||
trigger: 'ui',
|
||||
parameters: settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,77 @@
|
||||
/**
|
||||
* Determinism verification models for SBOM scan details.
|
||||
*/
|
||||
|
||||
export interface DeterminismStatus {
|
||||
/** Overall determinism status */
|
||||
status: 'verified' | 'warning' | 'failed' | 'unknown';
|
||||
|
||||
/** Merkle root from _composition.json */
|
||||
merkleRoot: string | null;
|
||||
|
||||
/** Whether Merkle root matches computed hash */
|
||||
merkleConsistent: boolean;
|
||||
|
||||
/** Fragment hashes with verification status */
|
||||
fragments: DeterminismFragment[];
|
||||
|
||||
/** Composition metadata */
|
||||
composition: CompositionMeta | null;
|
||||
|
||||
/** Timestamp of verification */
|
||||
verifiedAt: string;
|
||||
|
||||
/** Any issues found */
|
||||
issues: DeterminismIssue[];
|
||||
}
|
||||
|
||||
export interface DeterminismFragment {
|
||||
/** Fragment identifier (e.g., layer digest) */
|
||||
id: string;
|
||||
|
||||
/** Fragment type */
|
||||
type: 'layer' | 'metadata' | 'attestation' | 'sbom';
|
||||
|
||||
/** Expected hash from composition */
|
||||
expectedHash: string;
|
||||
|
||||
/** Computed hash */
|
||||
computedHash: string;
|
||||
|
||||
/** Whether hashes match */
|
||||
matches: boolean;
|
||||
|
||||
/** Size in bytes */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface CompositionMeta {
|
||||
/** Composition schema version */
|
||||
schemaVersion: string;
|
||||
|
||||
/** Scanner version that produced this */
|
||||
scannerVersion: string;
|
||||
|
||||
/** Build timestamp */
|
||||
buildTimestamp: string;
|
||||
|
||||
/** Total fragments */
|
||||
fragmentCount: number;
|
||||
|
||||
/** Composition file hash */
|
||||
compositionHash: string;
|
||||
}
|
||||
|
||||
export interface DeterminismIssue {
|
||||
/** Issue severity */
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
|
||||
/** Issue code */
|
||||
code: string;
|
||||
|
||||
/** Human-readable message */
|
||||
message: string;
|
||||
|
||||
/** Affected fragment ID if applicable */
|
||||
fragmentId?: string;
|
||||
}
|
||||
/**
|
||||
* Determinism verification models for SBOM scan details.
|
||||
*/
|
||||
|
||||
export interface DeterminismStatus {
|
||||
/** Overall determinism status */
|
||||
status: 'verified' | 'warning' | 'failed' | 'unknown';
|
||||
|
||||
/** Merkle root from _composition.json */
|
||||
merkleRoot: string | null;
|
||||
|
||||
/** Whether Merkle root matches computed hash */
|
||||
merkleConsistent: boolean;
|
||||
|
||||
/** Fragment hashes with verification status */
|
||||
fragments: DeterminismFragment[];
|
||||
|
||||
/** Composition metadata */
|
||||
composition: CompositionMeta | null;
|
||||
|
||||
/** Timestamp of verification */
|
||||
verifiedAt: string;
|
||||
|
||||
/** Any issues found */
|
||||
issues: DeterminismIssue[];
|
||||
}
|
||||
|
||||
export interface DeterminismFragment {
|
||||
/** Fragment identifier (e.g., layer digest) */
|
||||
id: string;
|
||||
|
||||
/** Fragment type */
|
||||
type: 'layer' | 'metadata' | 'attestation' | 'sbom';
|
||||
|
||||
/** Expected hash from composition */
|
||||
expectedHash: string;
|
||||
|
||||
/** Computed hash */
|
||||
computedHash: string;
|
||||
|
||||
/** Whether hashes match */
|
||||
matches: boolean;
|
||||
|
||||
/** Size in bytes */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface CompositionMeta {
|
||||
/** Composition schema version */
|
||||
schemaVersion: string;
|
||||
|
||||
/** Scanner version that produced this */
|
||||
scannerVersion: string;
|
||||
|
||||
/** Build timestamp */
|
||||
buildTimestamp: string;
|
||||
|
||||
/** Total fragments */
|
||||
fragmentCount: number;
|
||||
|
||||
/** Composition file hash */
|
||||
compositionHash: string;
|
||||
}
|
||||
|
||||
export interface DeterminismIssue {
|
||||
/** Issue severity */
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
|
||||
/** Issue code */
|
||||
code: string;
|
||||
|
||||
/** Human-readable message */
|
||||
message: string;
|
||||
|
||||
/** Affected fragment ID if applicable */
|
||||
fragmentId?: string;
|
||||
}
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
/**
|
||||
* Entropy analysis models for image security visualization.
|
||||
*/
|
||||
|
||||
export interface EntropyAnalysis {
|
||||
/** Image digest */
|
||||
imageDigest: string;
|
||||
|
||||
/** Overall entropy score (0-10, higher = more suspicious) */
|
||||
overallScore: number;
|
||||
|
||||
/** Risk level classification */
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
/** Per-layer entropy breakdown */
|
||||
layers: LayerEntropy[];
|
||||
|
||||
/** Files with high entropy (potential secrets/malware) */
|
||||
highEntropyFiles: HighEntropyFile[];
|
||||
|
||||
/** Detector hints for suspicious patterns */
|
||||
detectorHints: DetectorHint[];
|
||||
|
||||
/** Analysis timestamp */
|
||||
analyzedAt: string;
|
||||
|
||||
/** Link to raw entropy report */
|
||||
reportUrl: string;
|
||||
}
|
||||
|
||||
export interface LayerEntropy {
|
||||
/** Layer digest */
|
||||
digest: string;
|
||||
|
||||
/** Layer command (e.g., COPY, RUN) */
|
||||
command: string;
|
||||
|
||||
/** Layer size in bytes */
|
||||
size: number;
|
||||
|
||||
/** Average entropy for this layer (0-8 bits) */
|
||||
avgEntropy: number;
|
||||
|
||||
/** Percentage of opaque bytes (high entropy) */
|
||||
opaqueByteRatio: number;
|
||||
|
||||
/** Number of high-entropy files */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Risk contribution to overall score */
|
||||
riskContribution: number;
|
||||
}
|
||||
|
||||
export interface HighEntropyFile {
|
||||
/** File path in container */
|
||||
path: string;
|
||||
|
||||
/** Layer where file was added */
|
||||
layerDigest: string;
|
||||
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
|
||||
/** File entropy (0-8 bits) */
|
||||
entropy: number;
|
||||
|
||||
/** Classification */
|
||||
classification: 'encrypted' | 'compressed' | 'binary' | 'suspicious' | 'unknown';
|
||||
|
||||
/** Why this file is flagged */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DetectorHint {
|
||||
/** Hint ID */
|
||||
id: string;
|
||||
|
||||
/** Severity */
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/** Pattern type */
|
||||
type: 'credential' | 'key' | 'token' | 'obfuscated' | 'packed' | 'crypto';
|
||||
|
||||
/** Human-readable description */
|
||||
description: string;
|
||||
|
||||
/** Affected file paths */
|
||||
affectedPaths: string[];
|
||||
|
||||
/** Confidence (0-100) */
|
||||
confidence: number;
|
||||
|
||||
/** Remediation suggestion */
|
||||
remediation: string;
|
||||
}
|
||||
/**
|
||||
* Entropy analysis models for image security visualization.
|
||||
*/
|
||||
|
||||
export interface EntropyAnalysis {
|
||||
/** Image digest */
|
||||
imageDigest: string;
|
||||
|
||||
/** Overall entropy score (0-10, higher = more suspicious) */
|
||||
overallScore: number;
|
||||
|
||||
/** Risk level classification */
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
/** Per-layer entropy breakdown */
|
||||
layers: LayerEntropy[];
|
||||
|
||||
/** Files with high entropy (potential secrets/malware) */
|
||||
highEntropyFiles: HighEntropyFile[];
|
||||
|
||||
/** Detector hints for suspicious patterns */
|
||||
detectorHints: DetectorHint[];
|
||||
|
||||
/** Analysis timestamp */
|
||||
analyzedAt: string;
|
||||
|
||||
/** Link to raw entropy report */
|
||||
reportUrl: string;
|
||||
}
|
||||
|
||||
export interface LayerEntropy {
|
||||
/** Layer digest */
|
||||
digest: string;
|
||||
|
||||
/** Layer command (e.g., COPY, RUN) */
|
||||
command: string;
|
||||
|
||||
/** Layer size in bytes */
|
||||
size: number;
|
||||
|
||||
/** Average entropy for this layer (0-8 bits) */
|
||||
avgEntropy: number;
|
||||
|
||||
/** Percentage of opaque bytes (high entropy) */
|
||||
opaqueByteRatio: number;
|
||||
|
||||
/** Number of high-entropy files */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Risk contribution to overall score */
|
||||
riskContribution: number;
|
||||
}
|
||||
|
||||
export interface HighEntropyFile {
|
||||
/** File path in container */
|
||||
path: string;
|
||||
|
||||
/** Layer where file was added */
|
||||
layerDigest: string;
|
||||
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
|
||||
/** File entropy (0-8 bits) */
|
||||
entropy: number;
|
||||
|
||||
/** Classification */
|
||||
classification: 'encrypted' | 'compressed' | 'binary' | 'suspicious' | 'unknown';
|
||||
|
||||
/** Why this file is flagged */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DetectorHint {
|
||||
/** Hint ID */
|
||||
id: string;
|
||||
|
||||
/** Severity */
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/** Pattern type */
|
||||
type: 'credential' | 'key' | 'token' | 'obfuscated' | 'packed' | 'crypto';
|
||||
|
||||
/** Human-readable description */
|
||||
description: string;
|
||||
|
||||
/** Affected file paths */
|
||||
affectedPaths: string[];
|
||||
|
||||
/** Confidence (0-100) */
|
||||
confidence: number;
|
||||
|
||||
/** Remediation suggestion */
|
||||
remediation: string;
|
||||
}
|
||||
|
||||
@@ -1,352 +1,352 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
EvidenceData,
|
||||
Linkset,
|
||||
Observation,
|
||||
PolicyEvidence,
|
||||
} from './evidence.models';
|
||||
|
||||
export interface EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
|
||||
getObservation(observationId: string): Observable<Observation>;
|
||||
getLinkset(linksetId: string): Observable<Linkset>;
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
|
||||
/**
|
||||
* Export full evidence bundle as tar.gz or zip
|
||||
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
||||
*/
|
||||
exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob>;
|
||||
}
|
||||
|
||||
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
|
||||
|
||||
// Mock data for development
|
||||
const MOCK_OBSERVATIONS: Observation[] = [
|
||||
{
|
||||
observationId: 'obs-ghsa-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'ghsa',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Log4j Remote Code Execution (Log4Shell)',
|
||||
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
|
||||
'https://logging.apache.org/log4j/2.x/security.html',
|
||||
],
|
||||
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2024-01-15T10:30:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:abc123def456...',
|
||||
fetchedAt: '2024-11-20T08:00:00Z',
|
||||
ingestJobId: 'job-ghsa-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:05:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-nvd-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'nvd',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
|
||||
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
|
||||
],
|
||||
relationships: [
|
||||
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
|
||||
],
|
||||
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
|
||||
published: '2021-12-10T10:15:00Z',
|
||||
modified: '2024-02-20T15:45:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:def789ghi012...',
|
||||
fetchedAt: '2024-11-20T08:10:00Z',
|
||||
ingestJobId: 'job-nvd-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:15:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-osv-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'osv',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Remote code injection in Log4j',
|
||||
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'Maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.3.1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.4' },
|
||||
{ fixed: '2.12.2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.13.0' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
|
||||
],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2023-06-15T09:00:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:ghi345jkl678...',
|
||||
fetchedAt: '2024-11-20T08:20:00Z',
|
||||
ingestJobId: 'job-osv-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:25:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_LINKSET: Linkset = {
|
||||
linksetId: 'ls-log4shell-001',
|
||||
tenantId: 'tenant-1',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
source: 'aggregated',
|
||||
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
|
||||
normalized: {
|
||||
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
},
|
||||
confidence: 0.95,
|
||||
conflicts: [
|
||||
{
|
||||
field: 'affected.ranges',
|
||||
reason: 'Different fixed version ranges reported by sources',
|
||||
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
|
||||
sourceIds: ['ghsa', 'osv'],
|
||||
},
|
||||
{
|
||||
field: 'weaknesses',
|
||||
reason: 'Different CWE identifiers reported',
|
||||
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
|
||||
sourceIds: ['ghsa', 'nvd'],
|
||||
},
|
||||
],
|
||||
createdAt: '2024-11-20T08:30:00Z',
|
||||
builtByJobId: 'linkset-build-2024-1120',
|
||||
provenance: {
|
||||
observationHashes: [
|
||||
'sha256:abc123...',
|
||||
'sha256:def789...',
|
||||
'sha256:ghi345...',
|
||||
],
|
||||
toolVersion: 'concelier-lnm-1.2.0',
|
||||
policyHash: 'sha256:policy-hash-001',
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
|
||||
policyId: 'pol-critical-vuln-001',
|
||||
policyName: 'Critical Vulnerability Policy',
|
||||
decision: 'block',
|
||||
decidedAt: '2024-11-20T08:35:00Z',
|
||||
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'rule-cvss-critical',
|
||||
ruleName: 'Block Critical CVSS',
|
||||
passed: false,
|
||||
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
|
||||
matchedItems: ['CVE-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-known-exploit',
|
||||
ruleName: 'Known Exploit Check',
|
||||
passed: false,
|
||||
reason: 'Active exploitation reported by CISA',
|
||||
matchedItems: ['KEV-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-fix-available',
|
||||
ruleName: 'Fix Available',
|
||||
passed: true,
|
||||
reason: 'Fixed version 2.15.0+ available',
|
||||
},
|
||||
],
|
||||
linksetIds: ['ls-log4shell-001'],
|
||||
aocChain: [
|
||||
{
|
||||
attestationId: 'aoc-obs-ghsa-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:abc123def456...',
|
||||
timestamp: '2024-11-20T08:05:00Z',
|
||||
parentHash: undefined,
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-nvd-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:def789ghi012...',
|
||||
timestamp: '2024-11-20T08:15:00Z',
|
||||
parentHash: 'sha256:abc123def456...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-osv-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:ghi345jkl678...',
|
||||
timestamp: '2024-11-20T08:25:00Z',
|
||||
parentHash: 'sha256:def789ghi012...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-ls-001',
|
||||
type: 'linkset',
|
||||
hash: 'sha256:linkset-hash-001...',
|
||||
timestamp: '2024-11-20T08:30:00Z',
|
||||
parentHash: 'sha256:ghi345jkl678...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-policy-001',
|
||||
type: 'policy',
|
||||
hash: 'sha256:policy-decision-hash...',
|
||||
timestamp: '2024-11-20T08:35:00Z',
|
||||
signer: 'policy-engine-v1',
|
||||
parentHash: 'sha256:linkset-hash-001...',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockEvidenceApiService implements EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
|
||||
// Filter observations related to the advisory
|
||||
const observations = MOCK_OBSERVATIONS.filter(
|
||||
(o) =>
|
||||
o.advisoryId === advisoryId ||
|
||||
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
|
||||
);
|
||||
|
||||
const linkset = MOCK_LINKSET;
|
||||
const policyEvidence = MOCK_POLICY_EVIDENCE;
|
||||
|
||||
const data: EvidenceData = {
|
||||
advisoryId,
|
||||
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
|
||||
observations,
|
||||
linkset,
|
||||
policyEvidence,
|
||||
hasConflicts: linkset.conflicts.length > 0,
|
||||
conflictCount: linkset.conflicts.length,
|
||||
};
|
||||
|
||||
return of(data).pipe(delay(300));
|
||||
}
|
||||
|
||||
getObservation(observationId: string): Observable<Observation> {
|
||||
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
|
||||
if (!observation) {
|
||||
throw new Error(`Observation not found: ${observationId}`);
|
||||
}
|
||||
return of(observation).pipe(delay(100));
|
||||
}
|
||||
|
||||
getLinkset(linksetId: string): Observable<Linkset> {
|
||||
if (linksetId === MOCK_LINKSET.linksetId) {
|
||||
return of(MOCK_LINKSET).pipe(delay(100));
|
||||
}
|
||||
throw new Error(`Linkset not found: ${linksetId}`);
|
||||
}
|
||||
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
|
||||
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
|
||||
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
|
||||
}
|
||||
return of(null).pipe(delay(100));
|
||||
}
|
||||
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
|
||||
let data: object;
|
||||
if (type === 'observation') {
|
||||
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
|
||||
} else {
|
||||
data = MOCK_LINKSET;
|
||||
}
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
return of(blob).pipe(delay(100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export full evidence bundle as tar.gz or zip
|
||||
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
||||
*/
|
||||
async exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob> {
|
||||
// In mock implementation, return a JSON blob with all evidence data
|
||||
const data = {
|
||||
advisoryId,
|
||||
exportedAt: new Date().toISOString(),
|
||||
format,
|
||||
observations: MOCK_OBSERVATIONS,
|
||||
linkset: MOCK_LINKSET,
|
||||
policyEvidence: MOCK_POLICY_EVIDENCE,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const mimeType = format === 'tar.gz' ? 'application/gzip' : 'application/zip';
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
return new Blob([json], { type: mimeType });
|
||||
}
|
||||
}
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
EvidenceData,
|
||||
Linkset,
|
||||
Observation,
|
||||
PolicyEvidence,
|
||||
} from './evidence.models';
|
||||
|
||||
export interface EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
|
||||
getObservation(observationId: string): Observable<Observation>;
|
||||
getLinkset(linksetId: string): Observable<Linkset>;
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
|
||||
/**
|
||||
* Export full evidence bundle as tar.gz or zip
|
||||
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
||||
*/
|
||||
exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob>;
|
||||
}
|
||||
|
||||
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
|
||||
|
||||
// Mock data for development
|
||||
const MOCK_OBSERVATIONS: Observation[] = [
|
||||
{
|
||||
observationId: 'obs-ghsa-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'ghsa',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Log4j Remote Code Execution (Log4Shell)',
|
||||
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
|
||||
'https://logging.apache.org/log4j/2.x/security.html',
|
||||
],
|
||||
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2024-01-15T10:30:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:abc123def456...',
|
||||
fetchedAt: '2024-11-20T08:00:00Z',
|
||||
ingestJobId: 'job-ghsa-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:05:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-nvd-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'nvd',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
|
||||
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
|
||||
],
|
||||
relationships: [
|
||||
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
|
||||
],
|
||||
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
|
||||
published: '2021-12-10T10:15:00Z',
|
||||
modified: '2024-02-20T15:45:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:def789ghi012...',
|
||||
fetchedAt: '2024-11-20T08:10:00Z',
|
||||
ingestJobId: 'job-nvd-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:15:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-osv-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'osv',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Remote code injection in Log4j',
|
||||
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'Maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.3.1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.4' },
|
||||
{ fixed: '2.12.2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.13.0' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
|
||||
],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2023-06-15T09:00:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:ghi345jkl678...',
|
||||
fetchedAt: '2024-11-20T08:20:00Z',
|
||||
ingestJobId: 'job-osv-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:25:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_LINKSET: Linkset = {
|
||||
linksetId: 'ls-log4shell-001',
|
||||
tenantId: 'tenant-1',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
source: 'aggregated',
|
||||
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
|
||||
normalized: {
|
||||
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
},
|
||||
confidence: 0.95,
|
||||
conflicts: [
|
||||
{
|
||||
field: 'affected.ranges',
|
||||
reason: 'Different fixed version ranges reported by sources',
|
||||
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
|
||||
sourceIds: ['ghsa', 'osv'],
|
||||
},
|
||||
{
|
||||
field: 'weaknesses',
|
||||
reason: 'Different CWE identifiers reported',
|
||||
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
|
||||
sourceIds: ['ghsa', 'nvd'],
|
||||
},
|
||||
],
|
||||
createdAt: '2024-11-20T08:30:00Z',
|
||||
builtByJobId: 'linkset-build-2024-1120',
|
||||
provenance: {
|
||||
observationHashes: [
|
||||
'sha256:abc123...',
|
||||
'sha256:def789...',
|
||||
'sha256:ghi345...',
|
||||
],
|
||||
toolVersion: 'concelier-lnm-1.2.0',
|
||||
policyHash: 'sha256:policy-hash-001',
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
|
||||
policyId: 'pol-critical-vuln-001',
|
||||
policyName: 'Critical Vulnerability Policy',
|
||||
decision: 'block',
|
||||
decidedAt: '2024-11-20T08:35:00Z',
|
||||
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'rule-cvss-critical',
|
||||
ruleName: 'Block Critical CVSS',
|
||||
passed: false,
|
||||
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
|
||||
matchedItems: ['CVE-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-known-exploit',
|
||||
ruleName: 'Known Exploit Check',
|
||||
passed: false,
|
||||
reason: 'Active exploitation reported by CISA',
|
||||
matchedItems: ['KEV-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-fix-available',
|
||||
ruleName: 'Fix Available',
|
||||
passed: true,
|
||||
reason: 'Fixed version 2.15.0+ available',
|
||||
},
|
||||
],
|
||||
linksetIds: ['ls-log4shell-001'],
|
||||
aocChain: [
|
||||
{
|
||||
attestationId: 'aoc-obs-ghsa-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:abc123def456...',
|
||||
timestamp: '2024-11-20T08:05:00Z',
|
||||
parentHash: undefined,
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-nvd-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:def789ghi012...',
|
||||
timestamp: '2024-11-20T08:15:00Z',
|
||||
parentHash: 'sha256:abc123def456...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-osv-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:ghi345jkl678...',
|
||||
timestamp: '2024-11-20T08:25:00Z',
|
||||
parentHash: 'sha256:def789ghi012...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-ls-001',
|
||||
type: 'linkset',
|
||||
hash: 'sha256:linkset-hash-001...',
|
||||
timestamp: '2024-11-20T08:30:00Z',
|
||||
parentHash: 'sha256:ghi345jkl678...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-policy-001',
|
||||
type: 'policy',
|
||||
hash: 'sha256:policy-decision-hash...',
|
||||
timestamp: '2024-11-20T08:35:00Z',
|
||||
signer: 'policy-engine-v1',
|
||||
parentHash: 'sha256:linkset-hash-001...',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockEvidenceApiService implements EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
|
||||
// Filter observations related to the advisory
|
||||
const observations = MOCK_OBSERVATIONS.filter(
|
||||
(o) =>
|
||||
o.advisoryId === advisoryId ||
|
||||
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
|
||||
);
|
||||
|
||||
const linkset = MOCK_LINKSET;
|
||||
const policyEvidence = MOCK_POLICY_EVIDENCE;
|
||||
|
||||
const data: EvidenceData = {
|
||||
advisoryId,
|
||||
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
|
||||
observations,
|
||||
linkset,
|
||||
policyEvidence,
|
||||
hasConflicts: linkset.conflicts.length > 0,
|
||||
conflictCount: linkset.conflicts.length,
|
||||
};
|
||||
|
||||
return of(data).pipe(delay(300));
|
||||
}
|
||||
|
||||
getObservation(observationId: string): Observable<Observation> {
|
||||
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
|
||||
if (!observation) {
|
||||
throw new Error(`Observation not found: ${observationId}`);
|
||||
}
|
||||
return of(observation).pipe(delay(100));
|
||||
}
|
||||
|
||||
getLinkset(linksetId: string): Observable<Linkset> {
|
||||
if (linksetId === MOCK_LINKSET.linksetId) {
|
||||
return of(MOCK_LINKSET).pipe(delay(100));
|
||||
}
|
||||
throw new Error(`Linkset not found: ${linksetId}`);
|
||||
}
|
||||
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
|
||||
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
|
||||
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
|
||||
}
|
||||
return of(null).pipe(delay(100));
|
||||
}
|
||||
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
|
||||
let data: object;
|
||||
if (type === 'observation') {
|
||||
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
|
||||
} else {
|
||||
data = MOCK_LINKSET;
|
||||
}
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
return of(blob).pipe(delay(100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export full evidence bundle as tar.gz or zip
|
||||
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
||||
*/
|
||||
async exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob> {
|
||||
// In mock implementation, return a JSON blob with all evidence data
|
||||
const data = {
|
||||
advisoryId,
|
||||
exportedAt: new Date().toISOString(),
|
||||
format,
|
||||
observations: MOCK_OBSERVATIONS,
|
||||
linkset: MOCK_LINKSET,
|
||||
policyEvidence: MOCK_POLICY_EVIDENCE,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const mimeType = format === 'tar.gz' ? 'application/gzip' : 'application/zip';
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
return new Blob([json], { type: mimeType });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,355 +1,355 @@
|
||||
/**
|
||||
* Link-Not-Merge Evidence Models
|
||||
* Based on docs/modules/concelier/link-not-merge-schema.md
|
||||
*/
|
||||
|
||||
// Severity from advisory sources
|
||||
export interface AdvisorySeverity {
|
||||
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
|
||||
readonly score: number;
|
||||
readonly vector?: string;
|
||||
}
|
||||
|
||||
// Affected package information
|
||||
export interface AffectedPackage {
|
||||
readonly purl: string;
|
||||
readonly package?: string;
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly ecosystem?: string;
|
||||
readonly cpe?: readonly string[];
|
||||
}
|
||||
|
||||
export interface VersionRange {
|
||||
readonly type: string;
|
||||
readonly events: readonly VersionEvent[];
|
||||
}
|
||||
|
||||
export interface VersionEvent {
|
||||
readonly introduced?: string;
|
||||
readonly fixed?: string;
|
||||
readonly last_affected?: string;
|
||||
}
|
||||
|
||||
// Relationship between advisories
|
||||
export interface AdvisoryRelationship {
|
||||
readonly type: string;
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly provenance?: string;
|
||||
}
|
||||
|
||||
// Provenance tracking
|
||||
export interface ObservationProvenance {
|
||||
readonly sourceArtifactSha: string;
|
||||
readonly fetchedAt: string;
|
||||
readonly ingestJobId?: string;
|
||||
readonly signature?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Raw observation from a single source
|
||||
export interface Observation {
|
||||
readonly observationId: string;
|
||||
readonly tenantId: string;
|
||||
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly summary?: string;
|
||||
readonly severities: readonly AdvisorySeverity[];
|
||||
readonly affected: readonly AffectedPackage[];
|
||||
readonly references?: readonly string[];
|
||||
readonly scopes?: readonly string[];
|
||||
readonly relationships?: readonly AdvisoryRelationship[];
|
||||
readonly weaknesses?: readonly string[];
|
||||
readonly published?: string;
|
||||
readonly modified?: string;
|
||||
readonly provenance: ObservationProvenance;
|
||||
readonly ingestedAt: string;
|
||||
}
|
||||
|
||||
// Conflict when sources disagree
|
||||
export interface LinksetConflict {
|
||||
readonly field: string;
|
||||
readonly reason: string;
|
||||
readonly values?: readonly string[];
|
||||
readonly sourceIds?: readonly string[];
|
||||
}
|
||||
|
||||
// Linkset provenance
|
||||
export interface LinksetProvenance {
|
||||
readonly observationHashes: readonly string[];
|
||||
readonly toolVersion?: string;
|
||||
readonly policyHash?: string;
|
||||
}
|
||||
|
||||
// Normalized linkset aggregating multiple observations
|
||||
export interface Linkset {
|
||||
readonly linksetId: string;
|
||||
readonly tenantId: string;
|
||||
readonly advisoryId: string;
|
||||
readonly source: string;
|
||||
readonly observations: readonly string[]; // observation IDs
|
||||
readonly normalized?: {
|
||||
readonly purls?: readonly string[];
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly severities?: readonly AdvisorySeverity[];
|
||||
};
|
||||
readonly confidence?: number; // 0-1
|
||||
readonly conflicts: readonly LinksetConflict[];
|
||||
readonly createdAt: string;
|
||||
readonly builtByJobId?: string;
|
||||
readonly provenance?: LinksetProvenance;
|
||||
// Artifact and verification fields (SPRINT_0341_0001_0001)
|
||||
readonly artifactRef?: string; // e.g., registry.example.com/image:tag
|
||||
readonly artifactDigest?: string; // e.g., sha256:abc123...
|
||||
readonly sbomDigest?: string; // SBOM attestation digest
|
||||
readonly rekorLogIndex?: number; // Rekor transparency log index
|
||||
}
|
||||
|
||||
// Policy decision result
|
||||
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
|
||||
|
||||
// Policy decision with evidence
|
||||
export interface PolicyEvidence {
|
||||
readonly policyId: string;
|
||||
readonly policyName: string;
|
||||
readonly decision: PolicyDecision;
|
||||
readonly decidedAt: string;
|
||||
readonly reason?: string;
|
||||
readonly rules: readonly PolicyRuleResult[];
|
||||
readonly linksetIds: readonly string[];
|
||||
readonly aocChain?: AocChainEntry[];
|
||||
// Decision verification fields (SPRINT_0341_0001_0001)
|
||||
readonly decisionDigest?: string; // Hash of the decision for verification
|
||||
readonly rekorLogIndex?: number; // Rekor log index if logged
|
||||
}
|
||||
|
||||
export interface PolicyRuleResult {
|
||||
readonly ruleId: string;
|
||||
readonly ruleName: string;
|
||||
readonly passed: boolean;
|
||||
readonly reason?: string;
|
||||
readonly matchedItems?: readonly string[];
|
||||
// Confidence metadata (UI-POLICY-13-007)
|
||||
readonly unknownConfidence?: number | null;
|
||||
readonly confidenceBand?: string | null;
|
||||
readonly unknownAgeDays?: number | null;
|
||||
readonly sourceTrust?: string | null;
|
||||
readonly reachability?: string | null;
|
||||
readonly quietedBy?: string | null;
|
||||
readonly quiet?: boolean | null;
|
||||
}
|
||||
|
||||
// AOC (Attestation of Compliance) chain entry
|
||||
export interface AocChainEntry {
|
||||
readonly attestationId: string;
|
||||
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
|
||||
readonly hash: string;
|
||||
readonly timestamp: string;
|
||||
readonly signer?: string;
|
||||
readonly parentHash?: string;
|
||||
}
|
||||
|
||||
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
|
||||
export type VexStatus =
|
||||
| 'NOT_AFFECTED'
|
||||
| 'UNDER_INVESTIGATION'
|
||||
| 'AFFECTED_MITIGATED'
|
||||
| 'AFFECTED_UNMITIGATED'
|
||||
| 'FIXED';
|
||||
|
||||
export type VexJustificationType =
|
||||
| 'CODE_NOT_PRESENT'
|
||||
| 'CODE_NOT_REACHABLE'
|
||||
| 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH'
|
||||
| 'CONFIGURATION_NOT_AFFECTED'
|
||||
| 'OS_NOT_AFFECTED'
|
||||
| 'RUNTIME_MITIGATION_PRESENT'
|
||||
| 'COMPENSATING_CONTROLS'
|
||||
| 'ACCEPTED_BUSINESS_RISK'
|
||||
| 'OTHER';
|
||||
|
||||
export interface VexSubjectRef {
|
||||
readonly type: 'IMAGE' | 'REPO' | 'SBOM_COMPONENT' | 'OTHER';
|
||||
readonly name: string;
|
||||
readonly digest: Record<string, string>;
|
||||
readonly sbomNodeId?: string;
|
||||
}
|
||||
|
||||
export interface VexEvidenceRef {
|
||||
readonly type: 'PR' | 'TICKET' | 'DOC' | 'COMMIT' | 'OTHER';
|
||||
readonly title?: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface VexScope {
|
||||
readonly environments?: readonly string[];
|
||||
readonly projects?: readonly string[];
|
||||
}
|
||||
|
||||
export interface VexValidFor {
|
||||
readonly notBefore?: string;
|
||||
readonly notAfter?: string;
|
||||
}
|
||||
|
||||
export interface VexActorRef {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signature metadata for signed VEX decisions.
|
||||
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui (FE-RISK-005)
|
||||
*/
|
||||
export interface VexDecisionSignatureInfo {
|
||||
/** Whether the decision is cryptographically signed */
|
||||
readonly isSigned: boolean;
|
||||
/** DSSE envelope digest (base64-encoded) */
|
||||
readonly dsseDigest?: string;
|
||||
/** Signature algorithm used (e.g., 'ecdsa-p256', 'rsa-sha256') */
|
||||
readonly signatureAlgorithm?: string;
|
||||
/** Key ID used for signing */
|
||||
readonly signingKeyId?: string;
|
||||
/** Signer identity (e.g., email, OIDC subject) */
|
||||
readonly signerIdentity?: string;
|
||||
/** Timestamp when signed (ISO-8601) */
|
||||
readonly signedAt?: string;
|
||||
/** Signature verification status */
|
||||
readonly verificationStatus?: 'verified' | 'failed' | 'pending' | 'unknown';
|
||||
/** Rekor transparency log entry if logged */
|
||||
readonly rekorEntry?: VexRekorEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rekor transparency log entry for VEX decisions.
|
||||
*/
|
||||
export interface VexRekorEntry {
|
||||
/** Rekor log index */
|
||||
readonly logIndex: number;
|
||||
/** Rekor log ID (tree hash) */
|
||||
readonly logId?: string;
|
||||
/** Entry UUID in Rekor */
|
||||
readonly entryUuid?: string;
|
||||
/** Time integrated into the log (ISO-8601) */
|
||||
readonly integratedTime?: string;
|
||||
/** URL to view/verify the entry */
|
||||
readonly verifyUrl?: string;
|
||||
}
|
||||
|
||||
export interface VexDecision {
|
||||
readonly id: string;
|
||||
readonly vulnerabilityId: string;
|
||||
readonly subject: VexSubjectRef;
|
||||
readonly status: VexStatus;
|
||||
readonly justificationType: VexJustificationType;
|
||||
readonly justificationText?: string;
|
||||
readonly evidenceRefs?: readonly VexEvidenceRef[];
|
||||
readonly scope?: VexScope;
|
||||
readonly validFor?: VexValidFor;
|
||||
readonly supersedesDecisionId?: string;
|
||||
readonly createdBy: VexActorRef;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
/** Signature metadata for signed decisions (FE-RISK-005) */
|
||||
readonly signatureInfo?: VexDecisionSignatureInfo;
|
||||
}
|
||||
|
||||
// VEX status summary for UI display
|
||||
export interface VexStatusSummary {
|
||||
readonly notAffected: number;
|
||||
readonly underInvestigation: number;
|
||||
readonly affectedMitigated: number;
|
||||
readonly affectedUnmitigated: number;
|
||||
readonly fixed: number;
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
// VEX conflict indicator
|
||||
export interface VexConflict {
|
||||
readonly vulnerabilityId: string;
|
||||
readonly conflictingStatuses: readonly VexStatus[];
|
||||
readonly decisionIds: readonly string[];
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
// Evidence panel data combining all elements
|
||||
export interface EvidenceData {
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly observations: readonly Observation[];
|
||||
readonly linkset?: Linkset;
|
||||
readonly policyEvidence?: PolicyEvidence;
|
||||
readonly vexDecisions?: readonly VexDecision[];
|
||||
readonly vexConflicts?: readonly VexConflict[];
|
||||
readonly hasConflicts: boolean;
|
||||
readonly conflictCount: number;
|
||||
}
|
||||
|
||||
// Source metadata for display
|
||||
export interface SourceInfo {
|
||||
readonly sourceId: string;
|
||||
readonly name: string;
|
||||
readonly icon?: string;
|
||||
readonly url?: string;
|
||||
readonly lastUpdated?: string;
|
||||
}
|
||||
|
||||
// Filter configuration for observations/linksets
|
||||
export type SeverityBucket = 'critical' | 'high' | 'medium' | 'low' | 'all';
|
||||
|
||||
export interface ObservationFilters {
|
||||
readonly sources: readonly string[]; // Filter by source IDs
|
||||
readonly severityBucket: SeverityBucket; // Filter by severity level
|
||||
readonly conflictOnly: boolean; // Show only observations with conflicts
|
||||
readonly hasCvssVector: boolean | null; // null = all, true = has vector, false = no vector
|
||||
}
|
||||
|
||||
export const DEFAULT_OBSERVATION_FILTERS: ObservationFilters = {
|
||||
sources: [],
|
||||
severityBucket: 'all',
|
||||
conflictOnly: false,
|
||||
hasCvssVector: null,
|
||||
};
|
||||
|
||||
// Pagination configuration
|
||||
export interface PaginationState {
|
||||
readonly pageSize: number;
|
||||
readonly currentPage: number;
|
||||
readonly totalItems: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export const SOURCE_INFO: Record<string, SourceInfo> = {
|
||||
ghsa: {
|
||||
sourceId: 'ghsa',
|
||||
name: 'GitHub Security Advisories',
|
||||
icon: 'github',
|
||||
url: 'https://github.com/advisories',
|
||||
},
|
||||
nvd: {
|
||||
sourceId: 'nvd',
|
||||
name: 'National Vulnerability Database',
|
||||
icon: 'database',
|
||||
url: 'https://nvd.nist.gov',
|
||||
},
|
||||
'cert-bund': {
|
||||
sourceId: 'cert-bund',
|
||||
name: 'CERT-Bund',
|
||||
icon: 'shield',
|
||||
url: 'https://www.cert-bund.de',
|
||||
},
|
||||
osv: {
|
||||
sourceId: 'osv',
|
||||
name: 'Open Source Vulnerabilities',
|
||||
icon: 'box',
|
||||
url: 'https://osv.dev',
|
||||
},
|
||||
cve: {
|
||||
sourceId: 'cve',
|
||||
name: 'CVE Program',
|
||||
icon: 'alert-triangle',
|
||||
url: 'https://cve.mitre.org',
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Link-Not-Merge Evidence Models
|
||||
* Based on docs/modules/concelier/link-not-merge-schema.md
|
||||
*/
|
||||
|
||||
// Severity from advisory sources
|
||||
export interface AdvisorySeverity {
|
||||
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
|
||||
readonly score: number;
|
||||
readonly vector?: string;
|
||||
}
|
||||
|
||||
// Affected package information
|
||||
export interface AffectedPackage {
|
||||
readonly purl: string;
|
||||
readonly package?: string;
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly ecosystem?: string;
|
||||
readonly cpe?: readonly string[];
|
||||
}
|
||||
|
||||
export interface VersionRange {
|
||||
readonly type: string;
|
||||
readonly events: readonly VersionEvent[];
|
||||
}
|
||||
|
||||
export interface VersionEvent {
|
||||
readonly introduced?: string;
|
||||
readonly fixed?: string;
|
||||
readonly last_affected?: string;
|
||||
}
|
||||
|
||||
// Relationship between advisories
|
||||
export interface AdvisoryRelationship {
|
||||
readonly type: string;
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly provenance?: string;
|
||||
}
|
||||
|
||||
// Provenance tracking
|
||||
export interface ObservationProvenance {
|
||||
readonly sourceArtifactSha: string;
|
||||
readonly fetchedAt: string;
|
||||
readonly ingestJobId?: string;
|
||||
readonly signature?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Raw observation from a single source
|
||||
export interface Observation {
|
||||
readonly observationId: string;
|
||||
readonly tenantId: string;
|
||||
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly summary?: string;
|
||||
readonly severities: readonly AdvisorySeverity[];
|
||||
readonly affected: readonly AffectedPackage[];
|
||||
readonly references?: readonly string[];
|
||||
readonly scopes?: readonly string[];
|
||||
readonly relationships?: readonly AdvisoryRelationship[];
|
||||
readonly weaknesses?: readonly string[];
|
||||
readonly published?: string;
|
||||
readonly modified?: string;
|
||||
readonly provenance: ObservationProvenance;
|
||||
readonly ingestedAt: string;
|
||||
}
|
||||
|
||||
// Conflict when sources disagree
|
||||
export interface LinksetConflict {
|
||||
readonly field: string;
|
||||
readonly reason: string;
|
||||
readonly values?: readonly string[];
|
||||
readonly sourceIds?: readonly string[];
|
||||
}
|
||||
|
||||
// Linkset provenance
|
||||
export interface LinksetProvenance {
|
||||
readonly observationHashes: readonly string[];
|
||||
readonly toolVersion?: string;
|
||||
readonly policyHash?: string;
|
||||
}
|
||||
|
||||
// Normalized linkset aggregating multiple observations
|
||||
export interface Linkset {
|
||||
readonly linksetId: string;
|
||||
readonly tenantId: string;
|
||||
readonly advisoryId: string;
|
||||
readonly source: string;
|
||||
readonly observations: readonly string[]; // observation IDs
|
||||
readonly normalized?: {
|
||||
readonly purls?: readonly string[];
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly severities?: readonly AdvisorySeverity[];
|
||||
};
|
||||
readonly confidence?: number; // 0-1
|
||||
readonly conflicts: readonly LinksetConflict[];
|
||||
readonly createdAt: string;
|
||||
readonly builtByJobId?: string;
|
||||
readonly provenance?: LinksetProvenance;
|
||||
// Artifact and verification fields (SPRINT_0341_0001_0001)
|
||||
readonly artifactRef?: string; // e.g., registry.example.com/image:tag
|
||||
readonly artifactDigest?: string; // e.g., sha256:abc123...
|
||||
readonly sbomDigest?: string; // SBOM attestation digest
|
||||
readonly rekorLogIndex?: number; // Rekor transparency log index
|
||||
}
|
||||
|
||||
// Policy decision result
|
||||
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
|
||||
|
||||
// Policy decision with evidence
|
||||
export interface PolicyEvidence {
|
||||
readonly policyId: string;
|
||||
readonly policyName: string;
|
||||
readonly decision: PolicyDecision;
|
||||
readonly decidedAt: string;
|
||||
readonly reason?: string;
|
||||
readonly rules: readonly PolicyRuleResult[];
|
||||
readonly linksetIds: readonly string[];
|
||||
readonly aocChain?: AocChainEntry[];
|
||||
// Decision verification fields (SPRINT_0341_0001_0001)
|
||||
readonly decisionDigest?: string; // Hash of the decision for verification
|
||||
readonly rekorLogIndex?: number; // Rekor log index if logged
|
||||
}
|
||||
|
||||
export interface PolicyRuleResult {
|
||||
readonly ruleId: string;
|
||||
readonly ruleName: string;
|
||||
readonly passed: boolean;
|
||||
readonly reason?: string;
|
||||
readonly matchedItems?: readonly string[];
|
||||
// Confidence metadata (UI-POLICY-13-007)
|
||||
readonly unknownConfidence?: number | null;
|
||||
readonly confidenceBand?: string | null;
|
||||
readonly unknownAgeDays?: number | null;
|
||||
readonly sourceTrust?: string | null;
|
||||
readonly reachability?: string | null;
|
||||
readonly quietedBy?: string | null;
|
||||
readonly quiet?: boolean | null;
|
||||
}
|
||||
|
||||
// AOC (Attestation of Compliance) chain entry
|
||||
export interface AocChainEntry {
|
||||
readonly attestationId: string;
|
||||
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
|
||||
readonly hash: string;
|
||||
readonly timestamp: string;
|
||||
readonly signer?: string;
|
||||
readonly parentHash?: string;
|
||||
}
|
||||
|
||||
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
|
||||
export type VexStatus =
|
||||
| 'NOT_AFFECTED'
|
||||
| 'UNDER_INVESTIGATION'
|
||||
| 'AFFECTED_MITIGATED'
|
||||
| 'AFFECTED_UNMITIGATED'
|
||||
| 'FIXED';
|
||||
|
||||
export type VexJustificationType =
|
||||
| 'CODE_NOT_PRESENT'
|
||||
| 'CODE_NOT_REACHABLE'
|
||||
| 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH'
|
||||
| 'CONFIGURATION_NOT_AFFECTED'
|
||||
| 'OS_NOT_AFFECTED'
|
||||
| 'RUNTIME_MITIGATION_PRESENT'
|
||||
| 'COMPENSATING_CONTROLS'
|
||||
| 'ACCEPTED_BUSINESS_RISK'
|
||||
| 'OTHER';
|
||||
|
||||
export interface VexSubjectRef {
|
||||
readonly type: 'IMAGE' | 'REPO' | 'SBOM_COMPONENT' | 'OTHER';
|
||||
readonly name: string;
|
||||
readonly digest: Record<string, string>;
|
||||
readonly sbomNodeId?: string;
|
||||
}
|
||||
|
||||
export interface VexEvidenceRef {
|
||||
readonly type: 'PR' | 'TICKET' | 'DOC' | 'COMMIT' | 'OTHER';
|
||||
readonly title?: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface VexScope {
|
||||
readonly environments?: readonly string[];
|
||||
readonly projects?: readonly string[];
|
||||
}
|
||||
|
||||
export interface VexValidFor {
|
||||
readonly notBefore?: string;
|
||||
readonly notAfter?: string;
|
||||
}
|
||||
|
||||
export interface VexActorRef {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signature metadata for signed VEX decisions.
|
||||
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui (FE-RISK-005)
|
||||
*/
|
||||
export interface VexDecisionSignatureInfo {
|
||||
/** Whether the decision is cryptographically signed */
|
||||
readonly isSigned: boolean;
|
||||
/** DSSE envelope digest (base64-encoded) */
|
||||
readonly dsseDigest?: string;
|
||||
/** Signature algorithm used (e.g., 'ecdsa-p256', 'rsa-sha256') */
|
||||
readonly signatureAlgorithm?: string;
|
||||
/** Key ID used for signing */
|
||||
readonly signingKeyId?: string;
|
||||
/** Signer identity (e.g., email, OIDC subject) */
|
||||
readonly signerIdentity?: string;
|
||||
/** Timestamp when signed (ISO-8601) */
|
||||
readonly signedAt?: string;
|
||||
/** Signature verification status */
|
||||
readonly verificationStatus?: 'verified' | 'failed' | 'pending' | 'unknown';
|
||||
/** Rekor transparency log entry if logged */
|
||||
readonly rekorEntry?: VexRekorEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rekor transparency log entry for VEX decisions.
|
||||
*/
|
||||
export interface VexRekorEntry {
|
||||
/** Rekor log index */
|
||||
readonly logIndex: number;
|
||||
/** Rekor log ID (tree hash) */
|
||||
readonly logId?: string;
|
||||
/** Entry UUID in Rekor */
|
||||
readonly entryUuid?: string;
|
||||
/** Time integrated into the log (ISO-8601) */
|
||||
readonly integratedTime?: string;
|
||||
/** URL to view/verify the entry */
|
||||
readonly verifyUrl?: string;
|
||||
}
|
||||
|
||||
export interface VexDecision {
|
||||
readonly id: string;
|
||||
readonly vulnerabilityId: string;
|
||||
readonly subject: VexSubjectRef;
|
||||
readonly status: VexStatus;
|
||||
readonly justificationType: VexJustificationType;
|
||||
readonly justificationText?: string;
|
||||
readonly evidenceRefs?: readonly VexEvidenceRef[];
|
||||
readonly scope?: VexScope;
|
||||
readonly validFor?: VexValidFor;
|
||||
readonly supersedesDecisionId?: string;
|
||||
readonly createdBy: VexActorRef;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
/** Signature metadata for signed decisions (FE-RISK-005) */
|
||||
readonly signatureInfo?: VexDecisionSignatureInfo;
|
||||
}
|
||||
|
||||
// VEX status summary for UI display
|
||||
export interface VexStatusSummary {
|
||||
readonly notAffected: number;
|
||||
readonly underInvestigation: number;
|
||||
readonly affectedMitigated: number;
|
||||
readonly affectedUnmitigated: number;
|
||||
readonly fixed: number;
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
// VEX conflict indicator
|
||||
export interface VexConflict {
|
||||
readonly vulnerabilityId: string;
|
||||
readonly conflictingStatuses: readonly VexStatus[];
|
||||
readonly decisionIds: readonly string[];
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
// Evidence panel data combining all elements
|
||||
export interface EvidenceData {
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly observations: readonly Observation[];
|
||||
readonly linkset?: Linkset;
|
||||
readonly policyEvidence?: PolicyEvidence;
|
||||
readonly vexDecisions?: readonly VexDecision[];
|
||||
readonly vexConflicts?: readonly VexConflict[];
|
||||
readonly hasConflicts: boolean;
|
||||
readonly conflictCount: number;
|
||||
}
|
||||
|
||||
// Source metadata for display
|
||||
export interface SourceInfo {
|
||||
readonly sourceId: string;
|
||||
readonly name: string;
|
||||
readonly icon?: string;
|
||||
readonly url?: string;
|
||||
readonly lastUpdated?: string;
|
||||
}
|
||||
|
||||
// Filter configuration for observations/linksets
|
||||
export type SeverityBucket = 'critical' | 'high' | 'medium' | 'low' | 'all';
|
||||
|
||||
export interface ObservationFilters {
|
||||
readonly sources: readonly string[]; // Filter by source IDs
|
||||
readonly severityBucket: SeverityBucket; // Filter by severity level
|
||||
readonly conflictOnly: boolean; // Show only observations with conflicts
|
||||
readonly hasCvssVector: boolean | null; // null = all, true = has vector, false = no vector
|
||||
}
|
||||
|
||||
export const DEFAULT_OBSERVATION_FILTERS: ObservationFilters = {
|
||||
sources: [],
|
||||
severityBucket: 'all',
|
||||
conflictOnly: false,
|
||||
hasCvssVector: null,
|
||||
};
|
||||
|
||||
// Pagination configuration
|
||||
export interface PaginationState {
|
||||
readonly pageSize: number;
|
||||
readonly currentPage: number;
|
||||
readonly totalItems: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export const SOURCE_INFO: Record<string, SourceInfo> = {
|
||||
ghsa: {
|
||||
sourceId: 'ghsa',
|
||||
name: 'GitHub Security Advisories',
|
||||
icon: 'github',
|
||||
url: 'https://github.com/advisories',
|
||||
},
|
||||
nvd: {
|
||||
sourceId: 'nvd',
|
||||
name: 'National Vulnerability Database',
|
||||
icon: 'database',
|
||||
url: 'https://nvd.nist.gov',
|
||||
},
|
||||
'cert-bund': {
|
||||
sourceId: 'cert-bund',
|
||||
name: 'CERT-Bund',
|
||||
icon: 'shield',
|
||||
url: 'https://www.cert-bund.de',
|
||||
},
|
||||
osv: {
|
||||
sourceId: 'osv',
|
||||
name: 'Open Source Vulnerabilities',
|
||||
icon: 'box',
|
||||
url: 'https://osv.dev',
|
||||
},
|
||||
cve: {
|
||||
sourceId: 'cve',
|
||||
name: 'CVE Program',
|
||||
icon: 'alert-triangle',
|
||||
url: 'https://cve.mitre.org',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -56,18 +56,18 @@ export class ExceptionApiHttpClient implements ExceptionApi {
|
||||
if (options?.severity) {
|
||||
params = params.set('severity', options.severity);
|
||||
}
|
||||
if (options?.search) {
|
||||
params = params.set('search', options.search);
|
||||
}
|
||||
if (options?.sortBy) {
|
||||
params = params.set('sortBy', options.sortBy);
|
||||
}
|
||||
if (options?.sortOrder) {
|
||||
params = params.set('sortOrder', options.sortOrder);
|
||||
}
|
||||
if (options?.limit) {
|
||||
params = params.set('limit', options.limit.toString());
|
||||
}
|
||||
if (options?.search) {
|
||||
params = params.set('search', options.search);
|
||||
}
|
||||
if (options?.sortBy) {
|
||||
params = params.set('sortBy', options.sortBy);
|
||||
}
|
||||
if (options?.sortOrder) {
|
||||
params = params.set('sortOrder', options.sortOrder);
|
||||
}
|
||||
if (options?.limit) {
|
||||
params = params.set('limit', options.limit.toString());
|
||||
}
|
||||
if (options?.continuationToken) {
|
||||
params = params.set('continuationToken', options.continuationToken);
|
||||
}
|
||||
@@ -190,198 +190,198 @@ export class ExceptionApiHttpClient implements ExceptionApi {
|
||||
return new HttpHeaders(headers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation for development and testing.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
||||
/**
|
||||
* Mock implementation for development and testing.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockExceptionApiService implements ExceptionApi {
|
||||
private readonly mockExceptions: Exception[] = [
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-001',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'log4j-temporary-exception',
|
||||
displayName: 'Log4j Temporary Exception',
|
||||
description: 'Temporary exception for legacy Log4j usage in internal tooling',
|
||||
status: 'approved',
|
||||
severity: 'high',
|
||||
scope: {
|
||||
type: 'component',
|
||||
componentPurls: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'],
|
||||
vulnIds: ['CVE-2021-44228'],
|
||||
},
|
||||
justification: {
|
||||
template: 'internal-only',
|
||||
text: 'Internal-only service with no external exposure. Migration planned for Q1.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-03-31T23:59:59Z',
|
||||
autoRenew: false,
|
||||
},
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-001',
|
||||
approvedBy: 'security-lead@example.com',
|
||||
approvedAt: '2024-12-15T10:30:00Z',
|
||||
comment: 'Approved with condition: migrate before Q2',
|
||||
},
|
||||
],
|
||||
labels: { team: 'platform', priority: 'P2' },
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-10T09:00:00Z',
|
||||
updatedBy: 'security-lead@example.com',
|
||||
updatedAt: '2024-12-15T10:30:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-002',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'openssl-vuln-exception',
|
||||
displayName: 'OpenSSL Vulnerability Exception',
|
||||
status: 'pending_review',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-nginx-prod'],
|
||||
vulnIds: ['CVE-2024-0001'],
|
||||
},
|
||||
justification: {
|
||||
template: 'compensating-control',
|
||||
text: 'Compensating controls in place: WAF rules, network segmentation, monitoring.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-15T00:00:00Z',
|
||||
endDate: '2025-02-15T23:59:59Z',
|
||||
},
|
||||
labels: { team: 'infrastructure' },
|
||||
createdBy: 'ops@example.com',
|
||||
createdAt: '2025-01-10T14:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-003',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'legacy-crypto-exception',
|
||||
displayName: 'Legacy Crypto Library',
|
||||
status: 'draft',
|
||||
severity: 'medium',
|
||||
scope: {
|
||||
type: 'tenant',
|
||||
tenantId: 'tenant-dev',
|
||||
},
|
||||
justification: {
|
||||
text: 'Migration in progress. ETA: 2 weeks.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-20T00:00:00Z',
|
||||
endDate: '2025-02-20T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2025-01-18T11:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-004',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'expired-cert-exception',
|
||||
displayName: 'Expired Certificate Exception',
|
||||
status: 'expired',
|
||||
severity: 'low',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-test-env'],
|
||||
},
|
||||
justification: {
|
||||
text: 'Test environment only, not production facing.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2024-10-01T00:00:00Z',
|
||||
endDate: '2024-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'qa@example.com',
|
||||
createdAt: '2024-09-25T08:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-005',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'rejected-exception',
|
||||
displayName: 'Rejected Risk Exception',
|
||||
status: 'rejected',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'global',
|
||||
},
|
||||
justification: {
|
||||
text: 'Blanket exception for all critical vulns.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-20T16:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly mockExceptions: Exception[] = [
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-001',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'log4j-temporary-exception',
|
||||
displayName: 'Log4j Temporary Exception',
|
||||
description: 'Temporary exception for legacy Log4j usage in internal tooling',
|
||||
status: 'approved',
|
||||
severity: 'high',
|
||||
scope: {
|
||||
type: 'component',
|
||||
componentPurls: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'],
|
||||
vulnIds: ['CVE-2021-44228'],
|
||||
},
|
||||
justification: {
|
||||
template: 'internal-only',
|
||||
text: 'Internal-only service with no external exposure. Migration planned for Q1.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-03-31T23:59:59Z',
|
||||
autoRenew: false,
|
||||
},
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-001',
|
||||
approvedBy: 'security-lead@example.com',
|
||||
approvedAt: '2024-12-15T10:30:00Z',
|
||||
comment: 'Approved with condition: migrate before Q2',
|
||||
},
|
||||
],
|
||||
labels: { team: 'platform', priority: 'P2' },
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-10T09:00:00Z',
|
||||
updatedBy: 'security-lead@example.com',
|
||||
updatedAt: '2024-12-15T10:30:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-002',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'openssl-vuln-exception',
|
||||
displayName: 'OpenSSL Vulnerability Exception',
|
||||
status: 'pending_review',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-nginx-prod'],
|
||||
vulnIds: ['CVE-2024-0001'],
|
||||
},
|
||||
justification: {
|
||||
template: 'compensating-control',
|
||||
text: 'Compensating controls in place: WAF rules, network segmentation, monitoring.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-15T00:00:00Z',
|
||||
endDate: '2025-02-15T23:59:59Z',
|
||||
},
|
||||
labels: { team: 'infrastructure' },
|
||||
createdBy: 'ops@example.com',
|
||||
createdAt: '2025-01-10T14:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-003',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'legacy-crypto-exception',
|
||||
displayName: 'Legacy Crypto Library',
|
||||
status: 'draft',
|
||||
severity: 'medium',
|
||||
scope: {
|
||||
type: 'tenant',
|
||||
tenantId: 'tenant-dev',
|
||||
},
|
||||
justification: {
|
||||
text: 'Migration in progress. ETA: 2 weeks.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-20T00:00:00Z',
|
||||
endDate: '2025-02-20T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2025-01-18T11:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-004',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'expired-cert-exception',
|
||||
displayName: 'Expired Certificate Exception',
|
||||
status: 'expired',
|
||||
severity: 'low',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-test-env'],
|
||||
},
|
||||
justification: {
|
||||
text: 'Test environment only, not production facing.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2024-10-01T00:00:00Z',
|
||||
endDate: '2024-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'qa@example.com',
|
||||
createdAt: '2024-09-25T08:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-005',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'rejected-exception',
|
||||
displayName: 'Rejected Risk Exception',
|
||||
status: 'rejected',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'global',
|
||||
},
|
||||
justification: {
|
||||
text: 'Blanket exception for all critical vulns.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-20T16:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
|
||||
let filtered = [...this.mockExceptions];
|
||||
|
||||
if (options?.status) {
|
||||
filtered = filtered.filter((e) => e.status === options.status);
|
||||
}
|
||||
if (options?.severity) {
|
||||
filtered = filtered.filter((e) => e.severity === options.severity);
|
||||
}
|
||||
if (options?.search) {
|
||||
const searchLower = options.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(searchLower) ||
|
||||
e.displayName?.toLowerCase().includes(searchLower) ||
|
||||
e.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
const sortBy = options?.sortBy ?? 'createdAt';
|
||||
const sortOrder = options?.sortOrder ?? 'desc';
|
||||
filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'severity':
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
||||
break;
|
||||
case 'status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
case 'updatedAt':
|
||||
comparison = (a.updatedAt ?? a.createdAt).localeCompare(b.updatedAt ?? b.createdAt);
|
||||
break;
|
||||
default:
|
||||
comparison = a.createdAt.localeCompare(b.createdAt);
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
const limit = options?.limit ?? 20;
|
||||
const items = filtered.slice(0, limit);
|
||||
|
||||
return new Observable((subscriber) => {
|
||||
setTimeout(() => {
|
||||
subscriber.next({
|
||||
items,
|
||||
count: filtered.length,
|
||||
continuationToken: filtered.length > limit ? 'next-page-token' : null,
|
||||
});
|
||||
subscriber.complete();
|
||||
}, 300);
|
||||
});
|
||||
let filtered = [...this.mockExceptions];
|
||||
|
||||
if (options?.status) {
|
||||
filtered = filtered.filter((e) => e.status === options.status);
|
||||
}
|
||||
if (options?.severity) {
|
||||
filtered = filtered.filter((e) => e.severity === options.severity);
|
||||
}
|
||||
if (options?.search) {
|
||||
const searchLower = options.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(searchLower) ||
|
||||
e.displayName?.toLowerCase().includes(searchLower) ||
|
||||
e.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
const sortBy = options?.sortBy ?? 'createdAt';
|
||||
const sortOrder = options?.sortOrder ?? 'desc';
|
||||
filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'severity':
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
||||
break;
|
||||
case 'status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
case 'updatedAt':
|
||||
comparison = (a.updatedAt ?? a.createdAt).localeCompare(b.updatedAt ?? b.createdAt);
|
||||
break;
|
||||
default:
|
||||
comparison = a.createdAt.localeCompare(b.createdAt);
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
const limit = options?.limit ?? 20;
|
||||
const items = filtered.slice(0, limit);
|
||||
|
||||
return new Observable((subscriber) => {
|
||||
setTimeout(() => {
|
||||
subscriber.next({
|
||||
items,
|
||||
count: filtered.length,
|
||||
continuationToken: filtered.length > limit ? 'next-page-token' : null,
|
||||
});
|
||||
subscriber.complete();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
getException(exceptionId: string): Observable<Exception> {
|
||||
@@ -390,11 +390,11 @@ export class MockExceptionApiService implements ExceptionApi {
|
||||
setTimeout(() => {
|
||||
if (exception) {
|
||||
subscriber.next(exception);
|
||||
} else {
|
||||
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
||||
}
|
||||
subscriber.complete();
|
||||
}, 100);
|
||||
} else {
|
||||
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
||||
}
|
||||
subscriber.complete();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -404,26 +404,26 @@ export class MockExceptionApiService implements ExceptionApi {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: `exc-${Math.random().toString(36).slice(2, 10)}`,
|
||||
tenantId: 'tenant-dev',
|
||||
name: exception.name ?? 'new-exception',
|
||||
status: 'draft',
|
||||
severity: exception.severity ?? 'medium',
|
||||
scope: exception.scope ?? { type: 'tenant' },
|
||||
justification: exception.justification ?? { text: '' },
|
||||
timebox: exception.timebox ?? {
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
createdBy: 'ui@stella-ops.local',
|
||||
createdAt: new Date().toISOString(),
|
||||
...exception,
|
||||
} as Exception;
|
||||
|
||||
this.mockExceptions.push(newException);
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next(newException);
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
name: exception.name ?? 'new-exception',
|
||||
status: 'draft',
|
||||
severity: exception.severity ?? 'medium',
|
||||
scope: exception.scope ?? { type: 'tenant' },
|
||||
justification: exception.justification ?? { text: '' },
|
||||
timebox: exception.timebox ?? {
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
createdBy: 'ui@stella-ops.local',
|
||||
createdAt: new Date().toISOString(),
|
||||
...exception,
|
||||
} as Exception;
|
||||
|
||||
this.mockExceptions.push(newException);
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next(newException);
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -433,20 +433,20 @@ export class MockExceptionApiService implements ExceptionApi {
|
||||
if (index === -1) {
|
||||
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...this.mockExceptions[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: 'ui@stella-ops.local',
|
||||
};
|
||||
this.mockExceptions[index] = updated;
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next(updated);
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...this.mockExceptions[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: 'ui@stella-ops.local',
|
||||
};
|
||||
this.mockExceptions[index] = updated;
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next(updated);
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -456,51 +456,51 @@ export class MockExceptionApiService implements ExceptionApi {
|
||||
if (index !== -1) {
|
||||
this.mockExceptions.splice(index, 1);
|
||||
}
|
||||
setTimeout(() => {
|
||||
subscriber.next();
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
setTimeout(() => {
|
||||
subscriber.next();
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
|
||||
return this.updateException(transition.exceptionId, {
|
||||
status: transition.newStatus,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
|
||||
return this.updateException(transition.exceptionId, {
|
||||
status: transition.newStatus,
|
||||
});
|
||||
}
|
||||
|
||||
getStats(): Observable<ExceptionStats> {
|
||||
return new Observable((subscriber) => {
|
||||
const byStatus: Record<string, number> = {
|
||||
draft: 0,
|
||||
pending_review: 0,
|
||||
approved: 0,
|
||||
rejected: 0,
|
||||
expired: 0,
|
||||
revoked: 0,
|
||||
};
|
||||
const bySeverity: Record<string, number> = {
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
|
||||
this.mockExceptions.forEach((e) => {
|
||||
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
|
||||
bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next({
|
||||
total: this.mockExceptions.length,
|
||||
byStatus: byStatus as Record<any, number>,
|
||||
bySeverity: bySeverity as Record<any, number>,
|
||||
expiringWithin7Days: 1,
|
||||
pendingApproval: byStatus['pending_review'],
|
||||
});
|
||||
subscriber.complete();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
rejected: 0,
|
||||
expired: 0,
|
||||
revoked: 0,
|
||||
};
|
||||
const bySeverity: Record<string, number> = {
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
|
||||
this.mockExceptions.forEach((e) => {
|
||||
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
|
||||
bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next({
|
||||
total: this.mockExceptions.length,
|
||||
byStatus: byStatus as Record<any, number>,
|
||||
bySeverity: bySeverity as Record<any, number>,
|
||||
expiringWithin7Days: 1,
|
||||
pendingApproval: byStatus['pending_review'],
|
||||
});
|
||||
subscriber.complete();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,252 +1,252 @@
|
||||
/**
|
||||
* Exception management models for the Exception Center.
|
||||
*/
|
||||
|
||||
export type ExceptionStatus =
|
||||
| 'draft'
|
||||
| 'pending_review'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'expired'
|
||||
| 'revoked';
|
||||
|
||||
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
|
||||
|
||||
export interface Exception {
|
||||
/** Unique exception ID */
|
||||
id: string;
|
||||
|
||||
/** Short title */
|
||||
title: string;
|
||||
|
||||
/** Detailed justification */
|
||||
justification: string;
|
||||
|
||||
/** Exception type */
|
||||
type: ExceptionType;
|
||||
|
||||
/** Current status */
|
||||
status: ExceptionStatus;
|
||||
|
||||
/** Severity being excepted */
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/** Scope definition */
|
||||
scope: ExceptionScope;
|
||||
|
||||
/** Time constraints */
|
||||
timebox: ExceptionTimebox;
|
||||
|
||||
/** Workflow history */
|
||||
workflow: ExceptionWorkflow;
|
||||
|
||||
/** Audit trail */
|
||||
auditLog: ExceptionAuditEntry[];
|
||||
|
||||
/** Associated findings/violations */
|
||||
findings: string[];
|
||||
|
||||
/** Tags for filtering */
|
||||
tags: string[];
|
||||
|
||||
/** Created timestamp */
|
||||
createdAt: string;
|
||||
|
||||
/** Last updated timestamp */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ExceptionScope {
|
||||
/** Affected images (glob patterns allowed) */
|
||||
images?: string[];
|
||||
|
||||
/** Affected CVEs */
|
||||
cves?: string[];
|
||||
|
||||
/** Affected packages */
|
||||
packages?: string[];
|
||||
|
||||
/** Affected licenses */
|
||||
licenses?: string[];
|
||||
|
||||
/** Affected policy rules */
|
||||
policyRules?: string[];
|
||||
|
||||
/** Tenant scope */
|
||||
tenantId?: string;
|
||||
|
||||
/** Environment scope */
|
||||
environments?: string[];
|
||||
}
|
||||
|
||||
export interface ExceptionTimebox {
|
||||
/** Start date */
|
||||
startsAt: string;
|
||||
|
||||
/** Expiration date */
|
||||
expiresAt: string;
|
||||
|
||||
/** Remaining days */
|
||||
remainingDays: number;
|
||||
|
||||
/** Is expired */
|
||||
isExpired: boolean;
|
||||
|
||||
/** Warning threshold (days before expiry) */
|
||||
warnDays: number;
|
||||
|
||||
/** Is in warning period */
|
||||
isWarning: boolean;
|
||||
}
|
||||
|
||||
export interface ExceptionWorkflow {
|
||||
/** Current workflow state */
|
||||
state: ExceptionStatus;
|
||||
|
||||
/** Requested by */
|
||||
requestedBy: string;
|
||||
|
||||
/** Requested at */
|
||||
requestedAt: string;
|
||||
|
||||
/** Approved by */
|
||||
approvedBy?: string;
|
||||
|
||||
/** Approved at */
|
||||
approvedAt?: string;
|
||||
|
||||
/** Revoked by */
|
||||
revokedBy?: string;
|
||||
|
||||
/** Revoked at */
|
||||
revokedAt?: string;
|
||||
|
||||
/** Revocation reason */
|
||||
revocationReason?: string;
|
||||
|
||||
/** Required approvers */
|
||||
requiredApprovers: string[];
|
||||
|
||||
/** Current approvals */
|
||||
approvals: ExceptionApproval[];
|
||||
}
|
||||
|
||||
export interface ExceptionApproval {
|
||||
/** Approver identity */
|
||||
approver: string;
|
||||
|
||||
/** Decision */
|
||||
decision: 'approved' | 'rejected';
|
||||
|
||||
/** Timestamp */
|
||||
at: string;
|
||||
|
||||
/** Optional comment */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionAuditEntry {
|
||||
/** Entry ID */
|
||||
id: string;
|
||||
|
||||
/** Action performed */
|
||||
action: 'created' | 'submitted' | 'approved' | 'rejected' | 'activated' | 'expired' | 'revoked' | 'edited';
|
||||
|
||||
/** Actor */
|
||||
actor: string;
|
||||
|
||||
/** Timestamp */
|
||||
at: string;
|
||||
|
||||
/** Details */
|
||||
details?: string;
|
||||
|
||||
/** Previous values (for edits) */
|
||||
previousValues?: Record<string, unknown>;
|
||||
|
||||
/** New values (for edits) */
|
||||
newValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExceptionFilter {
|
||||
status?: ExceptionStatus[];
|
||||
type?: ExceptionType[];
|
||||
severity?: string[];
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
expiringSoon?: boolean;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionSortOption {
|
||||
field: 'createdAt' | 'updatedAt' | 'expiresAt' | 'severity' | 'title';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ExceptionTransition {
|
||||
from: ExceptionStatus;
|
||||
to: ExceptionStatus;
|
||||
action: string;
|
||||
requiresApproval: boolean;
|
||||
allowedRoles: string[];
|
||||
}
|
||||
|
||||
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
|
||||
{ from: 'draft', to: 'pending_review', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
|
||||
{ from: 'pending_review', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'pending_review', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'pending_review', to: 'rejected', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'approved', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
|
||||
];
|
||||
|
||||
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
|
||||
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
|
||||
{ status: 'pending_review', label: 'Pending Review', color: '#f59e0b' },
|
||||
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
|
||||
{ status: 'rejected', label: 'Rejected', color: '#f472b6' },
|
||||
{ status: 'expired', label: 'Expired', color: '#6b7280' },
|
||||
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Exception ledger entry for timeline display.
|
||||
* @sprint SPRINT_20251226_004_FE_risk_dashboard
|
||||
* @task DASH-12
|
||||
*/
|
||||
export interface ExceptionLedgerEntry {
|
||||
/** Entry ID. */
|
||||
id: string;
|
||||
/** Exception ID. */
|
||||
exceptionId: string;
|
||||
/** Event type. */
|
||||
eventType: 'created' | 'approved' | 'rejected' | 'expired' | 'revoked' | 'extended' | 'modified';
|
||||
/** Event timestamp. */
|
||||
timestamp: string;
|
||||
/** Actor user ID. */
|
||||
actorId: string;
|
||||
/** Actor display name. */
|
||||
actorName?: string;
|
||||
/** Event details. */
|
||||
details?: Record<string, unknown>;
|
||||
/** Comment. */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception summary for risk budget dashboard.
|
||||
* @sprint SPRINT_20251226_004_FE_risk_dashboard
|
||||
* @task DASH-04
|
||||
*/
|
||||
export interface ExceptionSummary {
|
||||
/** Total active exceptions. */
|
||||
active: number;
|
||||
/** Pending approval. */
|
||||
pending: number;
|
||||
/** Expiring within 7 days. */
|
||||
expiringSoon: number;
|
||||
/** Total risk points covered. */
|
||||
riskPointsCovered: number;
|
||||
/** Trace ID. */
|
||||
traceId: string;
|
||||
}
|
||||
/**
|
||||
* Exception management models for the Exception Center.
|
||||
*/
|
||||
|
||||
export type ExceptionStatus =
|
||||
| 'draft'
|
||||
| 'pending_review'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'expired'
|
||||
| 'revoked';
|
||||
|
||||
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
|
||||
|
||||
export interface Exception {
|
||||
/** Unique exception ID */
|
||||
id: string;
|
||||
|
||||
/** Short title */
|
||||
title: string;
|
||||
|
||||
/** Detailed justification */
|
||||
justification: string;
|
||||
|
||||
/** Exception type */
|
||||
type: ExceptionType;
|
||||
|
||||
/** Current status */
|
||||
status: ExceptionStatus;
|
||||
|
||||
/** Severity being excepted */
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/** Scope definition */
|
||||
scope: ExceptionScope;
|
||||
|
||||
/** Time constraints */
|
||||
timebox: ExceptionTimebox;
|
||||
|
||||
/** Workflow history */
|
||||
workflow: ExceptionWorkflow;
|
||||
|
||||
/** Audit trail */
|
||||
auditLog: ExceptionAuditEntry[];
|
||||
|
||||
/** Associated findings/violations */
|
||||
findings: string[];
|
||||
|
||||
/** Tags for filtering */
|
||||
tags: string[];
|
||||
|
||||
/** Created timestamp */
|
||||
createdAt: string;
|
||||
|
||||
/** Last updated timestamp */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ExceptionScope {
|
||||
/** Affected images (glob patterns allowed) */
|
||||
images?: string[];
|
||||
|
||||
/** Affected CVEs */
|
||||
cves?: string[];
|
||||
|
||||
/** Affected packages */
|
||||
packages?: string[];
|
||||
|
||||
/** Affected licenses */
|
||||
licenses?: string[];
|
||||
|
||||
/** Affected policy rules */
|
||||
policyRules?: string[];
|
||||
|
||||
/** Tenant scope */
|
||||
tenantId?: string;
|
||||
|
||||
/** Environment scope */
|
||||
environments?: string[];
|
||||
}
|
||||
|
||||
export interface ExceptionTimebox {
|
||||
/** Start date */
|
||||
startsAt: string;
|
||||
|
||||
/** Expiration date */
|
||||
expiresAt: string;
|
||||
|
||||
/** Remaining days */
|
||||
remainingDays: number;
|
||||
|
||||
/** Is expired */
|
||||
isExpired: boolean;
|
||||
|
||||
/** Warning threshold (days before expiry) */
|
||||
warnDays: number;
|
||||
|
||||
/** Is in warning period */
|
||||
isWarning: boolean;
|
||||
}
|
||||
|
||||
export interface ExceptionWorkflow {
|
||||
/** Current workflow state */
|
||||
state: ExceptionStatus;
|
||||
|
||||
/** Requested by */
|
||||
requestedBy: string;
|
||||
|
||||
/** Requested at */
|
||||
requestedAt: string;
|
||||
|
||||
/** Approved by */
|
||||
approvedBy?: string;
|
||||
|
||||
/** Approved at */
|
||||
approvedAt?: string;
|
||||
|
||||
/** Revoked by */
|
||||
revokedBy?: string;
|
||||
|
||||
/** Revoked at */
|
||||
revokedAt?: string;
|
||||
|
||||
/** Revocation reason */
|
||||
revocationReason?: string;
|
||||
|
||||
/** Required approvers */
|
||||
requiredApprovers: string[];
|
||||
|
||||
/** Current approvals */
|
||||
approvals: ExceptionApproval[];
|
||||
}
|
||||
|
||||
export interface ExceptionApproval {
|
||||
/** Approver identity */
|
||||
approver: string;
|
||||
|
||||
/** Decision */
|
||||
decision: 'approved' | 'rejected';
|
||||
|
||||
/** Timestamp */
|
||||
at: string;
|
||||
|
||||
/** Optional comment */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionAuditEntry {
|
||||
/** Entry ID */
|
||||
id: string;
|
||||
|
||||
/** Action performed */
|
||||
action: 'created' | 'submitted' | 'approved' | 'rejected' | 'activated' | 'expired' | 'revoked' | 'edited';
|
||||
|
||||
/** Actor */
|
||||
actor: string;
|
||||
|
||||
/** Timestamp */
|
||||
at: string;
|
||||
|
||||
/** Details */
|
||||
details?: string;
|
||||
|
||||
/** Previous values (for edits) */
|
||||
previousValues?: Record<string, unknown>;
|
||||
|
||||
/** New values (for edits) */
|
||||
newValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExceptionFilter {
|
||||
status?: ExceptionStatus[];
|
||||
type?: ExceptionType[];
|
||||
severity?: string[];
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
expiringSoon?: boolean;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionSortOption {
|
||||
field: 'createdAt' | 'updatedAt' | 'expiresAt' | 'severity' | 'title';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ExceptionTransition {
|
||||
from: ExceptionStatus;
|
||||
to: ExceptionStatus;
|
||||
action: string;
|
||||
requiresApproval: boolean;
|
||||
allowedRoles: string[];
|
||||
}
|
||||
|
||||
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
|
||||
{ from: 'draft', to: 'pending_review', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
|
||||
{ from: 'pending_review', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'pending_review', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'pending_review', to: 'rejected', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'approved', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
|
||||
];
|
||||
|
||||
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
|
||||
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
|
||||
{ status: 'pending_review', label: 'Pending Review', color: '#f59e0b' },
|
||||
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
|
||||
{ status: 'rejected', label: 'Rejected', color: '#f472b6' },
|
||||
{ status: 'expired', label: 'Expired', color: '#6b7280' },
|
||||
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Exception ledger entry for timeline display.
|
||||
* @sprint SPRINT_20251226_004_FE_risk_dashboard
|
||||
* @task DASH-12
|
||||
*/
|
||||
export interface ExceptionLedgerEntry {
|
||||
/** Entry ID. */
|
||||
id: string;
|
||||
/** Exception ID. */
|
||||
exceptionId: string;
|
||||
/** Event type. */
|
||||
eventType: 'created' | 'approved' | 'rejected' | 'expired' | 'revoked' | 'extended' | 'modified';
|
||||
/** Event timestamp. */
|
||||
timestamp: string;
|
||||
/** Actor user ID. */
|
||||
actorId: string;
|
||||
/** Actor display name. */
|
||||
actorName?: string;
|
||||
/** Event details. */
|
||||
details?: Record<string, unknown>;
|
||||
/** Comment. */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception summary for risk budget dashboard.
|
||||
* @sprint SPRINT_20251226_004_FE_risk_dashboard
|
||||
* @task DASH-04
|
||||
*/
|
||||
export interface ExceptionSummary {
|
||||
/** Total active exceptions. */
|
||||
active: number;
|
||||
/** Pending approval. */
|
||||
pending: number;
|
||||
/** Expiring within 7 days. */
|
||||
expiringSoon: number;
|
||||
/** Total risk points covered. */
|
||||
riskPointsCovered: number;
|
||||
/** Trace ID. */
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,419 +1,419 @@
|
||||
export type NotifyChannelType =
|
||||
| 'Slack'
|
||||
| 'Teams'
|
||||
| 'Email'
|
||||
| 'Webhook'
|
||||
| 'Custom';
|
||||
|
||||
export type ChannelHealthStatus = 'Healthy' | 'Degraded' | 'Unhealthy';
|
||||
|
||||
export type NotifyDeliveryStatus =
|
||||
| 'Pending'
|
||||
| 'Sent'
|
||||
| 'Failed'
|
||||
| 'Throttled'
|
||||
| 'Digested'
|
||||
| 'Dropped';
|
||||
|
||||
export type NotifyDeliveryAttemptStatus =
|
||||
| 'Enqueued'
|
||||
| 'Sending'
|
||||
| 'Succeeded'
|
||||
| 'Failed'
|
||||
| 'Throttled'
|
||||
| 'Skipped';
|
||||
|
||||
export type NotifyDeliveryFormat =
|
||||
| 'Slack'
|
||||
| 'Teams'
|
||||
| 'Email'
|
||||
| 'Webhook'
|
||||
| 'Json';
|
||||
|
||||
export interface NotifyChannelLimits {
|
||||
readonly concurrency?: number | null;
|
||||
readonly requestsPerMinute?: number | null;
|
||||
readonly timeout?: string | null;
|
||||
readonly maxBatchSize?: number | null;
|
||||
}
|
||||
|
||||
export interface NotifyChannelConfig {
|
||||
readonly secretRef: string;
|
||||
readonly target?: string;
|
||||
readonly endpoint?: string;
|
||||
readonly properties?: Record<string, string>;
|
||||
readonly limits?: NotifyChannelLimits | null;
|
||||
}
|
||||
|
||||
export interface NotifyChannel {
|
||||
readonly schemaVersion?: string;
|
||||
readonly channelId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly displayName?: string;
|
||||
readonly description?: string;
|
||||
readonly type: NotifyChannelType;
|
||||
readonly enabled: boolean;
|
||||
readonly config: NotifyChannelConfig;
|
||||
readonly labels?: Record<string, string>;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdBy?: string;
|
||||
readonly createdAt?: string;
|
||||
readonly updatedBy?: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyRuleMatchVex {
|
||||
readonly includeAcceptedJustifications?: boolean;
|
||||
readonly includeRejectedJustifications?: boolean;
|
||||
readonly includeUnknownJustifications?: boolean;
|
||||
readonly justificationKinds?: readonly string[];
|
||||
}
|
||||
|
||||
export interface NotifyRuleMatch {
|
||||
readonly eventKinds?: readonly string[];
|
||||
readonly namespaces?: readonly string[];
|
||||
readonly repositories?: readonly string[];
|
||||
readonly digests?: readonly string[];
|
||||
readonly labels?: readonly string[];
|
||||
readonly componentPurls?: readonly string[];
|
||||
readonly minSeverity?: string | null;
|
||||
readonly verdicts?: readonly string[];
|
||||
readonly kevOnly?: boolean | null;
|
||||
readonly vex?: NotifyRuleMatchVex | null;
|
||||
}
|
||||
|
||||
export interface NotifyRuleAction {
|
||||
readonly actionId: string;
|
||||
readonly channel: string;
|
||||
readonly template?: string;
|
||||
readonly digest?: string;
|
||||
readonly throttle?: string | null;
|
||||
readonly locale?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NotifyRule {
|
||||
readonly schemaVersion?: string;
|
||||
readonly ruleId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly match: NotifyRuleMatch;
|
||||
readonly actions: readonly NotifyRuleAction[];
|
||||
readonly labels?: Record<string, string>;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdBy?: string;
|
||||
readonly createdAt?: string;
|
||||
readonly updatedBy?: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveryAttempt {
|
||||
readonly timestamp: string;
|
||||
readonly status: NotifyDeliveryAttemptStatus;
|
||||
readonly statusCode?: number;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveryRendered {
|
||||
readonly channelType: NotifyChannelType;
|
||||
readonly format: NotifyDeliveryFormat;
|
||||
readonly target: string;
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly summary?: string;
|
||||
readonly textBody?: string;
|
||||
readonly locale?: string;
|
||||
readonly bodyHash?: string;
|
||||
readonly attachments?: readonly string[];
|
||||
}
|
||||
|
||||
export interface NotifyDelivery {
|
||||
readonly deliveryId: string;
|
||||
readonly tenantId: string;
|
||||
readonly ruleId: string;
|
||||
readonly actionId: string;
|
||||
readonly eventId: string;
|
||||
readonly kind: string;
|
||||
readonly status: NotifyDeliveryStatus;
|
||||
readonly statusReason?: string;
|
||||
readonly rendered?: NotifyDeliveryRendered;
|
||||
readonly attempts?: readonly NotifyDeliveryAttempt[];
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdAt: string;
|
||||
readonly sentAt?: string;
|
||||
readonly completedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveriesQueryOptions {
|
||||
readonly status?: NotifyDeliveryStatus;
|
||||
readonly since?: string;
|
||||
readonly limit?: number;
|
||||
readonly continuationToken?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveriesResponse {
|
||||
readonly items: readonly NotifyDelivery[];
|
||||
readonly continuationToken?: string | null;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface ChannelHealthResponse {
|
||||
readonly tenantId: string;
|
||||
readonly channelId: string;
|
||||
readonly status: ChannelHealthStatus;
|
||||
readonly message?: string | null;
|
||||
readonly checkedAt: string;
|
||||
readonly traceId: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ChannelTestSendRequest {
|
||||
readonly target?: string;
|
||||
readonly templateId?: string;
|
||||
readonly title?: string;
|
||||
readonly summary?: string;
|
||||
readonly body?: string;
|
||||
readonly textBody?: string;
|
||||
readonly locale?: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly attachments?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ChannelTestSendResponse {
|
||||
readonly tenantId: string;
|
||||
readonly channelId: string;
|
||||
readonly preview: NotifyDeliveryRendered;
|
||||
readonly queuedAt: string;
|
||||
readonly traceId: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-NOTIFY-39-001: Digest scheduling, quiet-hours, throttle management.
|
||||
*/
|
||||
|
||||
/** Digest frequency. */
|
||||
export type DigestFrequency = 'instant' | 'hourly' | 'daily' | 'weekly';
|
||||
|
||||
/** Digest schedule. */
|
||||
export interface DigestSchedule {
|
||||
readonly scheduleId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly frequency: DigestFrequency;
|
||||
readonly timezone: string;
|
||||
readonly hour?: number;
|
||||
readonly dayOfWeek?: number;
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Digest schedules response. */
|
||||
export interface DigestSchedulesResponse {
|
||||
readonly items: readonly DigestSchedule[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Quiet hour window. */
|
||||
export interface QuietHourWindow {
|
||||
readonly timezone: string;
|
||||
readonly days: readonly string[];
|
||||
readonly start: string;
|
||||
readonly end: string;
|
||||
}
|
||||
|
||||
/** Quiet hour exemption. */
|
||||
export interface QuietHourExemption {
|
||||
readonly eventKinds: readonly string[];
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/** Quiet hours configuration. */
|
||||
export interface QuietHours {
|
||||
readonly quietHoursId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly windows: readonly QuietHourWindow[];
|
||||
readonly exemptions?: readonly QuietHourExemption[];
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Quiet hours response. */
|
||||
export interface QuietHoursResponse {
|
||||
readonly items: readonly QuietHours[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Throttle configuration. */
|
||||
export interface ThrottleConfig {
|
||||
readonly throttleId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly windowSeconds: number;
|
||||
readonly maxEvents: number;
|
||||
readonly burstLimit?: number;
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Throttle configs response. */
|
||||
export interface ThrottleConfigsResponse {
|
||||
readonly items: readonly ThrottleConfig[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Simulation request. */
|
||||
export interface NotifySimulationRequest {
|
||||
readonly eventKind: string;
|
||||
readonly payload: Record<string, unknown>;
|
||||
readonly targetChannels?: readonly string[];
|
||||
readonly dryRun: boolean;
|
||||
}
|
||||
|
||||
/** Simulation result. */
|
||||
export interface NotifySimulationResult {
|
||||
readonly simulationId: string;
|
||||
readonly matchedRules: readonly string[];
|
||||
readonly wouldNotify: readonly {
|
||||
readonly channelId: string;
|
||||
readonly actionId: string;
|
||||
readonly template: string;
|
||||
readonly digest: DigestFrequency;
|
||||
}[];
|
||||
readonly throttled: boolean;
|
||||
readonly quietHoursActive: boolean;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-NOTIFY-40-001: Escalation, localization, channel health, ack verification.
|
||||
*/
|
||||
|
||||
/** Escalation policy. */
|
||||
export interface EscalationPolicy {
|
||||
readonly policyId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly levels: readonly EscalationLevel[];
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Escalation level. */
|
||||
export interface EscalationLevel {
|
||||
readonly level: number;
|
||||
readonly delayMinutes: number;
|
||||
readonly channels: readonly string[];
|
||||
readonly notifyOnAck: boolean;
|
||||
}
|
||||
|
||||
/** Escalation policies response. */
|
||||
export interface EscalationPoliciesResponse {
|
||||
readonly items: readonly EscalationPolicy[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Localization config. */
|
||||
export interface LocalizationConfig {
|
||||
readonly localeId: string;
|
||||
readonly tenantId: string;
|
||||
readonly locale: string;
|
||||
readonly name: string;
|
||||
readonly templates: Record<string, string>;
|
||||
readonly dateFormat?: string;
|
||||
readonly timeFormat?: string;
|
||||
readonly timezone?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Localization configs response. */
|
||||
export interface LocalizationConfigsResponse {
|
||||
readonly items: readonly LocalizationConfig[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Incident for acknowledgment. */
|
||||
export interface NotifyIncident {
|
||||
readonly incidentId: string;
|
||||
readonly tenantId: string;
|
||||
readonly title: string;
|
||||
readonly severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
|
||||
readonly status: 'open' | 'acknowledged' | 'resolved' | 'closed';
|
||||
readonly eventIds: readonly string[];
|
||||
readonly escalationLevel?: number;
|
||||
readonly escalationPolicyId?: string;
|
||||
readonly assignee?: string;
|
||||
readonly acknowledgedAt?: string;
|
||||
readonly acknowledgedBy?: string;
|
||||
readonly resolvedAt?: string;
|
||||
readonly resolvedBy?: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Incidents response. */
|
||||
export interface NotifyIncidentsResponse {
|
||||
readonly items: readonly NotifyIncident[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Acknowledgment request. */
|
||||
export interface AckRequest {
|
||||
readonly ackToken: string;
|
||||
readonly note?: string;
|
||||
}
|
||||
|
||||
/** Acknowledgment response. */
|
||||
export interface AckResponse {
|
||||
readonly incidentId: string;
|
||||
readonly acknowledged: boolean;
|
||||
readonly acknowledgedAt: string;
|
||||
readonly acknowledgedBy: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Notify query options. */
|
||||
export interface NotifyQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly pageToken?: string;
|
||||
readonly pageSize?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Notify error codes. */
|
||||
export type NotifyErrorCode =
|
||||
| 'ERR_NOTIFY_CHANNEL_NOT_FOUND'
|
||||
| 'ERR_NOTIFY_RULE_NOT_FOUND'
|
||||
| 'ERR_NOTIFY_INVALID_CONFIG'
|
||||
| 'ERR_NOTIFY_RATE_LIMIT'
|
||||
| 'ERR_NOTIFY_ACK_INVALID'
|
||||
| 'ERR_NOTIFY_ACK_EXPIRED';
|
||||
|
||||
export type NotifyChannelType =
|
||||
| 'Slack'
|
||||
| 'Teams'
|
||||
| 'Email'
|
||||
| 'Webhook'
|
||||
| 'Custom';
|
||||
|
||||
export type ChannelHealthStatus = 'Healthy' | 'Degraded' | 'Unhealthy';
|
||||
|
||||
export type NotifyDeliveryStatus =
|
||||
| 'Pending'
|
||||
| 'Sent'
|
||||
| 'Failed'
|
||||
| 'Throttled'
|
||||
| 'Digested'
|
||||
| 'Dropped';
|
||||
|
||||
export type NotifyDeliveryAttemptStatus =
|
||||
| 'Enqueued'
|
||||
| 'Sending'
|
||||
| 'Succeeded'
|
||||
| 'Failed'
|
||||
| 'Throttled'
|
||||
| 'Skipped';
|
||||
|
||||
export type NotifyDeliveryFormat =
|
||||
| 'Slack'
|
||||
| 'Teams'
|
||||
| 'Email'
|
||||
| 'Webhook'
|
||||
| 'Json';
|
||||
|
||||
export interface NotifyChannelLimits {
|
||||
readonly concurrency?: number | null;
|
||||
readonly requestsPerMinute?: number | null;
|
||||
readonly timeout?: string | null;
|
||||
readonly maxBatchSize?: number | null;
|
||||
}
|
||||
|
||||
export interface NotifyChannelConfig {
|
||||
readonly secretRef: string;
|
||||
readonly target?: string;
|
||||
readonly endpoint?: string;
|
||||
readonly properties?: Record<string, string>;
|
||||
readonly limits?: NotifyChannelLimits | null;
|
||||
}
|
||||
|
||||
export interface NotifyChannel {
|
||||
readonly schemaVersion?: string;
|
||||
readonly channelId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly displayName?: string;
|
||||
readonly description?: string;
|
||||
readonly type: NotifyChannelType;
|
||||
readonly enabled: boolean;
|
||||
readonly config: NotifyChannelConfig;
|
||||
readonly labels?: Record<string, string>;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdBy?: string;
|
||||
readonly createdAt?: string;
|
||||
readonly updatedBy?: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyRuleMatchVex {
|
||||
readonly includeAcceptedJustifications?: boolean;
|
||||
readonly includeRejectedJustifications?: boolean;
|
||||
readonly includeUnknownJustifications?: boolean;
|
||||
readonly justificationKinds?: readonly string[];
|
||||
}
|
||||
|
||||
export interface NotifyRuleMatch {
|
||||
readonly eventKinds?: readonly string[];
|
||||
readonly namespaces?: readonly string[];
|
||||
readonly repositories?: readonly string[];
|
||||
readonly digests?: readonly string[];
|
||||
readonly labels?: readonly string[];
|
||||
readonly componentPurls?: readonly string[];
|
||||
readonly minSeverity?: string | null;
|
||||
readonly verdicts?: readonly string[];
|
||||
readonly kevOnly?: boolean | null;
|
||||
readonly vex?: NotifyRuleMatchVex | null;
|
||||
}
|
||||
|
||||
export interface NotifyRuleAction {
|
||||
readonly actionId: string;
|
||||
readonly channel: string;
|
||||
readonly template?: string;
|
||||
readonly digest?: string;
|
||||
readonly throttle?: string | null;
|
||||
readonly locale?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NotifyRule {
|
||||
readonly schemaVersion?: string;
|
||||
readonly ruleId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly match: NotifyRuleMatch;
|
||||
readonly actions: readonly NotifyRuleAction[];
|
||||
readonly labels?: Record<string, string>;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdBy?: string;
|
||||
readonly createdAt?: string;
|
||||
readonly updatedBy?: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveryAttempt {
|
||||
readonly timestamp: string;
|
||||
readonly status: NotifyDeliveryAttemptStatus;
|
||||
readonly statusCode?: number;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveryRendered {
|
||||
readonly channelType: NotifyChannelType;
|
||||
readonly format: NotifyDeliveryFormat;
|
||||
readonly target: string;
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly summary?: string;
|
||||
readonly textBody?: string;
|
||||
readonly locale?: string;
|
||||
readonly bodyHash?: string;
|
||||
readonly attachments?: readonly string[];
|
||||
}
|
||||
|
||||
export interface NotifyDelivery {
|
||||
readonly deliveryId: string;
|
||||
readonly tenantId: string;
|
||||
readonly ruleId: string;
|
||||
readonly actionId: string;
|
||||
readonly eventId: string;
|
||||
readonly kind: string;
|
||||
readonly status: NotifyDeliveryStatus;
|
||||
readonly statusReason?: string;
|
||||
readonly rendered?: NotifyDeliveryRendered;
|
||||
readonly attempts?: readonly NotifyDeliveryAttempt[];
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdAt: string;
|
||||
readonly sentAt?: string;
|
||||
readonly completedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveriesQueryOptions {
|
||||
readonly status?: NotifyDeliveryStatus;
|
||||
readonly since?: string;
|
||||
readonly limit?: number;
|
||||
readonly continuationToken?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveriesResponse {
|
||||
readonly items: readonly NotifyDelivery[];
|
||||
readonly continuationToken?: string | null;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface ChannelHealthResponse {
|
||||
readonly tenantId: string;
|
||||
readonly channelId: string;
|
||||
readonly status: ChannelHealthStatus;
|
||||
readonly message?: string | null;
|
||||
readonly checkedAt: string;
|
||||
readonly traceId: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ChannelTestSendRequest {
|
||||
readonly target?: string;
|
||||
readonly templateId?: string;
|
||||
readonly title?: string;
|
||||
readonly summary?: string;
|
||||
readonly body?: string;
|
||||
readonly textBody?: string;
|
||||
readonly locale?: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly attachments?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ChannelTestSendResponse {
|
||||
readonly tenantId: string;
|
||||
readonly channelId: string;
|
||||
readonly preview: NotifyDeliveryRendered;
|
||||
readonly queuedAt: string;
|
||||
readonly traceId: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-NOTIFY-39-001: Digest scheduling, quiet-hours, throttle management.
|
||||
*/
|
||||
|
||||
/** Digest frequency. */
|
||||
export type DigestFrequency = 'instant' | 'hourly' | 'daily' | 'weekly';
|
||||
|
||||
/** Digest schedule. */
|
||||
export interface DigestSchedule {
|
||||
readonly scheduleId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly frequency: DigestFrequency;
|
||||
readonly timezone: string;
|
||||
readonly hour?: number;
|
||||
readonly dayOfWeek?: number;
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Digest schedules response. */
|
||||
export interface DigestSchedulesResponse {
|
||||
readonly items: readonly DigestSchedule[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Quiet hour window. */
|
||||
export interface QuietHourWindow {
|
||||
readonly timezone: string;
|
||||
readonly days: readonly string[];
|
||||
readonly start: string;
|
||||
readonly end: string;
|
||||
}
|
||||
|
||||
/** Quiet hour exemption. */
|
||||
export interface QuietHourExemption {
|
||||
readonly eventKinds: readonly string[];
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/** Quiet hours configuration. */
|
||||
export interface QuietHours {
|
||||
readonly quietHoursId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly windows: readonly QuietHourWindow[];
|
||||
readonly exemptions?: readonly QuietHourExemption[];
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Quiet hours response. */
|
||||
export interface QuietHoursResponse {
|
||||
readonly items: readonly QuietHours[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Throttle configuration. */
|
||||
export interface ThrottleConfig {
|
||||
readonly throttleId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly windowSeconds: number;
|
||||
readonly maxEvents: number;
|
||||
readonly burstLimit?: number;
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Throttle configs response. */
|
||||
export interface ThrottleConfigsResponse {
|
||||
readonly items: readonly ThrottleConfig[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Simulation request. */
|
||||
export interface NotifySimulationRequest {
|
||||
readonly eventKind: string;
|
||||
readonly payload: Record<string, unknown>;
|
||||
readonly targetChannels?: readonly string[];
|
||||
readonly dryRun: boolean;
|
||||
}
|
||||
|
||||
/** Simulation result. */
|
||||
export interface NotifySimulationResult {
|
||||
readonly simulationId: string;
|
||||
readonly matchedRules: readonly string[];
|
||||
readonly wouldNotify: readonly {
|
||||
readonly channelId: string;
|
||||
readonly actionId: string;
|
||||
readonly template: string;
|
||||
readonly digest: DigestFrequency;
|
||||
}[];
|
||||
readonly throttled: boolean;
|
||||
readonly quietHoursActive: boolean;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-NOTIFY-40-001: Escalation, localization, channel health, ack verification.
|
||||
*/
|
||||
|
||||
/** Escalation policy. */
|
||||
export interface EscalationPolicy {
|
||||
readonly policyId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly levels: readonly EscalationLevel[];
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Escalation level. */
|
||||
export interface EscalationLevel {
|
||||
readonly level: number;
|
||||
readonly delayMinutes: number;
|
||||
readonly channels: readonly string[];
|
||||
readonly notifyOnAck: boolean;
|
||||
}
|
||||
|
||||
/** Escalation policies response. */
|
||||
export interface EscalationPoliciesResponse {
|
||||
readonly items: readonly EscalationPolicy[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Localization config. */
|
||||
export interface LocalizationConfig {
|
||||
readonly localeId: string;
|
||||
readonly tenantId: string;
|
||||
readonly locale: string;
|
||||
readonly name: string;
|
||||
readonly templates: Record<string, string>;
|
||||
readonly dateFormat?: string;
|
||||
readonly timeFormat?: string;
|
||||
readonly timezone?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Localization configs response. */
|
||||
export interface LocalizationConfigsResponse {
|
||||
readonly items: readonly LocalizationConfig[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Incident for acknowledgment. */
|
||||
export interface NotifyIncident {
|
||||
readonly incidentId: string;
|
||||
readonly tenantId: string;
|
||||
readonly title: string;
|
||||
readonly severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
|
||||
readonly status: 'open' | 'acknowledged' | 'resolved' | 'closed';
|
||||
readonly eventIds: readonly string[];
|
||||
readonly escalationLevel?: number;
|
||||
readonly escalationPolicyId?: string;
|
||||
readonly assignee?: string;
|
||||
readonly acknowledgedAt?: string;
|
||||
readonly acknowledgedBy?: string;
|
||||
readonly resolvedAt?: string;
|
||||
readonly resolvedBy?: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Incidents response. */
|
||||
export interface NotifyIncidentsResponse {
|
||||
readonly items: readonly NotifyIncident[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Acknowledgment request. */
|
||||
export interface AckRequest {
|
||||
readonly ackToken: string;
|
||||
readonly note?: string;
|
||||
}
|
||||
|
||||
/** Acknowledgment response. */
|
||||
export interface AckResponse {
|
||||
readonly incidentId: string;
|
||||
readonly acknowledged: boolean;
|
||||
readonly acknowledgedAt: string;
|
||||
readonly acknowledgedBy: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Notify query options. */
|
||||
export interface NotifyQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly pageToken?: string;
|
||||
readonly pageSize?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Notify error codes. */
|
||||
export type NotifyErrorCode =
|
||||
| 'ERR_NOTIFY_CHANNEL_NOT_FOUND'
|
||||
| 'ERR_NOTIFY_RULE_NOT_FOUND'
|
||||
| 'ERR_NOTIFY_INVALID_CONFIG'
|
||||
| 'ERR_NOTIFY_RATE_LIMIT'
|
||||
| 'ERR_NOTIFY_ACK_INVALID'
|
||||
| 'ERR_NOTIFY_ACK_EXPIRED';
|
||||
|
||||
|
||||
@@ -1,128 +1,128 @@
|
||||
export interface PolicyPreviewRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
policy?: PolicyPreviewPolicyDto;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewPolicyDto {
|
||||
content?: string;
|
||||
format?: string;
|
||||
actor?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewFindingDto {
|
||||
id: string;
|
||||
severity: string;
|
||||
environment?: string;
|
||||
source?: string;
|
||||
vendor?: string;
|
||||
license?: string;
|
||||
image?: string;
|
||||
repository?: string;
|
||||
package?: string;
|
||||
purl?: string;
|
||||
cve?: string;
|
||||
path?: string;
|
||||
layerDigest?: string;
|
||||
tags?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewVerdictDto {
|
||||
findingId: string;
|
||||
status: string;
|
||||
ruleName?: string | null;
|
||||
ruleAction?: string | null;
|
||||
notes?: string | null;
|
||||
score?: number | null;
|
||||
configVersion?: string | null;
|
||||
inputs?: Readonly<Record<string, number>>;
|
||||
quietedBy?: string | null;
|
||||
quiet?: boolean | null;
|
||||
unknownConfidence?: number | null;
|
||||
confidenceBand?: string | null;
|
||||
unknownAgeDays?: number | null;
|
||||
sourceTrust?: string | null;
|
||||
reachability?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewDiffDto {
|
||||
findingId: string;
|
||||
baseline: PolicyPreviewVerdictDto;
|
||||
projected: PolicyPreviewVerdictDto;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewIssueDto {
|
||||
code: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewResponseDto {
|
||||
success: boolean;
|
||||
policyDigest: string;
|
||||
revisionId?: string | null;
|
||||
changed: number;
|
||||
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewSample {
|
||||
previewRequest: PolicyPreviewRequestDto;
|
||||
previewResponse: PolicyPreviewResponseDto;
|
||||
}
|
||||
|
||||
export interface PolicyReportRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportResponseDto {
|
||||
report: PolicyReportDocumentDto;
|
||||
dsse?: DsseEnvelopeDto | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportDocumentDto {
|
||||
reportId: string;
|
||||
imageDigest: string;
|
||||
generatedAt: string;
|
||||
verdict: string;
|
||||
policy: PolicyReportPolicyDto;
|
||||
summary: PolicyReportSummaryDto;
|
||||
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportPolicyDto {
|
||||
revisionId?: string | null;
|
||||
digest?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportSummaryDto {
|
||||
total: number;
|
||||
blocked: number;
|
||||
warned: number;
|
||||
ignored: number;
|
||||
quieted: number;
|
||||
}
|
||||
|
||||
export interface DsseEnvelopeDto {
|
||||
payloadType: string;
|
||||
payload: string;
|
||||
signatures: ReadonlyArray<DsseSignatureDto>;
|
||||
}
|
||||
|
||||
export interface DsseSignatureDto {
|
||||
keyId: string;
|
||||
algorithm: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface PolicyReportSample {
|
||||
reportRequest: PolicyReportRequestDto;
|
||||
reportResponse: PolicyReportResponseDto;
|
||||
}
|
||||
export interface PolicyPreviewRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
policy?: PolicyPreviewPolicyDto;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewPolicyDto {
|
||||
content?: string;
|
||||
format?: string;
|
||||
actor?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewFindingDto {
|
||||
id: string;
|
||||
severity: string;
|
||||
environment?: string;
|
||||
source?: string;
|
||||
vendor?: string;
|
||||
license?: string;
|
||||
image?: string;
|
||||
repository?: string;
|
||||
package?: string;
|
||||
purl?: string;
|
||||
cve?: string;
|
||||
path?: string;
|
||||
layerDigest?: string;
|
||||
tags?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewVerdictDto {
|
||||
findingId: string;
|
||||
status: string;
|
||||
ruleName?: string | null;
|
||||
ruleAction?: string | null;
|
||||
notes?: string | null;
|
||||
score?: number | null;
|
||||
configVersion?: string | null;
|
||||
inputs?: Readonly<Record<string, number>>;
|
||||
quietedBy?: string | null;
|
||||
quiet?: boolean | null;
|
||||
unknownConfidence?: number | null;
|
||||
confidenceBand?: string | null;
|
||||
unknownAgeDays?: number | null;
|
||||
sourceTrust?: string | null;
|
||||
reachability?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewDiffDto {
|
||||
findingId: string;
|
||||
baseline: PolicyPreviewVerdictDto;
|
||||
projected: PolicyPreviewVerdictDto;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewIssueDto {
|
||||
code: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewResponseDto {
|
||||
success: boolean;
|
||||
policyDigest: string;
|
||||
revisionId?: string | null;
|
||||
changed: number;
|
||||
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewSample {
|
||||
previewRequest: PolicyPreviewRequestDto;
|
||||
previewResponse: PolicyPreviewResponseDto;
|
||||
}
|
||||
|
||||
export interface PolicyReportRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportResponseDto {
|
||||
report: PolicyReportDocumentDto;
|
||||
dsse?: DsseEnvelopeDto | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportDocumentDto {
|
||||
reportId: string;
|
||||
imageDigest: string;
|
||||
generatedAt: string;
|
||||
verdict: string;
|
||||
policy: PolicyReportPolicyDto;
|
||||
summary: PolicyReportSummaryDto;
|
||||
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportPolicyDto {
|
||||
revisionId?: string | null;
|
||||
digest?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportSummaryDto {
|
||||
total: number;
|
||||
blocked: number;
|
||||
warned: number;
|
||||
ignored: number;
|
||||
quieted: number;
|
||||
}
|
||||
|
||||
export interface DsseEnvelopeDto {
|
||||
payloadType: string;
|
||||
payload: string;
|
||||
signatures: ReadonlyArray<DsseSignatureDto>;
|
||||
}
|
||||
|
||||
export interface DsseSignatureDto {
|
||||
keyId: string;
|
||||
algorithm: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface PolicyReportSample {
|
||||
reportRequest: PolicyReportRequestDto;
|
||||
reportResponse: PolicyReportResponseDto;
|
||||
}
|
||||
|
||||
@@ -1,163 +1,163 @@
|
||||
/**
|
||||
* Policy gate models for release flow indicators.
|
||||
*/
|
||||
|
||||
export interface PolicyGateStatus {
|
||||
/** Overall gate status */
|
||||
status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
|
||||
|
||||
/** Policy evaluation ID */
|
||||
evaluationId: string;
|
||||
|
||||
/** Target artifact (image, SBOM, etc.) */
|
||||
targetRef: string;
|
||||
|
||||
/** Policy set that was evaluated */
|
||||
policySetId: string;
|
||||
|
||||
/** Individual gate results */
|
||||
gates: PolicyGate[];
|
||||
|
||||
/** Blocking issues preventing publish */
|
||||
blockingIssues: PolicyBlockingIssue[];
|
||||
|
||||
/** Warning-level issues */
|
||||
warnings: PolicyWarning[];
|
||||
|
||||
/** Remediation hints for failures */
|
||||
remediationHints: PolicyRemediationHint[];
|
||||
|
||||
/** Evaluation timestamp */
|
||||
evaluatedAt: string;
|
||||
|
||||
/** Can the artifact be published? */
|
||||
canPublish: boolean;
|
||||
|
||||
/** Reason if publish is blocked */
|
||||
blockReason?: string;
|
||||
}
|
||||
|
||||
export interface PolicyGate {
|
||||
/** Gate identifier */
|
||||
gateId: string;
|
||||
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
|
||||
/** Gate type */
|
||||
type: 'determinism' | 'vulnerability' | 'license' | 'signature' | 'entropy' | 'custom';
|
||||
|
||||
/** Gate result */
|
||||
result: 'passed' | 'failed' | 'warning' | 'skipped';
|
||||
|
||||
/** Is this gate required for publish? */
|
||||
required: boolean;
|
||||
|
||||
/** Gate-specific details */
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
/** Evidence references */
|
||||
evidenceRefs?: string[];
|
||||
}
|
||||
|
||||
export interface PolicyBlockingIssue {
|
||||
/** Issue code */
|
||||
code: string;
|
||||
|
||||
/** Gate that produced this issue */
|
||||
gateId: string;
|
||||
|
||||
/** Issue severity */
|
||||
severity: 'critical' | 'high';
|
||||
|
||||
/** Issue description */
|
||||
message: string;
|
||||
|
||||
/** Affected resource */
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
export interface PolicyWarning {
|
||||
/** Warning code */
|
||||
code: string;
|
||||
|
||||
/** Gate that produced this warning */
|
||||
gateId: string;
|
||||
|
||||
/** Warning message */
|
||||
message: string;
|
||||
|
||||
/** Affected resource */
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
export interface PolicyRemediationHint {
|
||||
/** Which gate/issue this remediates */
|
||||
forGate: string;
|
||||
|
||||
/** Which issue code */
|
||||
forCode?: string;
|
||||
|
||||
/** Hint title */
|
||||
title: string;
|
||||
|
||||
/** Step-by-step instructions */
|
||||
steps: string[];
|
||||
|
||||
/** Documentation link */
|
||||
docsUrl?: string;
|
||||
|
||||
/** CLI command to run */
|
||||
cliCommand?: string;
|
||||
|
||||
/** Estimated effort */
|
||||
effort?: 'trivial' | 'easy' | 'moderate' | 'complex';
|
||||
}
|
||||
|
||||
export interface DeterminismGateDetails {
|
||||
/** Merkle root consistency */
|
||||
merkleRootConsistent: boolean;
|
||||
|
||||
/** Expected Merkle root */
|
||||
expectedMerkleRoot?: string;
|
||||
|
||||
/** Computed Merkle root */
|
||||
computedMerkleRoot?: string;
|
||||
|
||||
/** Fragment verification results */
|
||||
fragmentResults: {
|
||||
fragmentId: string;
|
||||
expected: string;
|
||||
computed: string;
|
||||
match: boolean;
|
||||
}[];
|
||||
|
||||
/** Composition file present */
|
||||
compositionPresent: boolean;
|
||||
|
||||
/** Total fragments */
|
||||
totalFragments: number;
|
||||
|
||||
/** Matching fragments */
|
||||
matchingFragments: number;
|
||||
}
|
||||
|
||||
export interface EntropyGateDetails {
|
||||
/** Overall entropy score */
|
||||
entropyScore: number;
|
||||
|
||||
/** Score threshold for warning */
|
||||
warnThreshold: number;
|
||||
|
||||
/** Score threshold for block */
|
||||
blockThreshold: number;
|
||||
|
||||
/** Action taken based on score */
|
||||
action: 'allow' | 'warn' | 'block';
|
||||
|
||||
/** High entropy files count */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Suspicious patterns detected */
|
||||
suspiciousPatterns: string[];
|
||||
}
|
||||
/**
|
||||
* Policy gate models for release flow indicators.
|
||||
*/
|
||||
|
||||
export interface PolicyGateStatus {
|
||||
/** Overall gate status */
|
||||
status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
|
||||
|
||||
/** Policy evaluation ID */
|
||||
evaluationId: string;
|
||||
|
||||
/** Target artifact (image, SBOM, etc.) */
|
||||
targetRef: string;
|
||||
|
||||
/** Policy set that was evaluated */
|
||||
policySetId: string;
|
||||
|
||||
/** Individual gate results */
|
||||
gates: PolicyGate[];
|
||||
|
||||
/** Blocking issues preventing publish */
|
||||
blockingIssues: PolicyBlockingIssue[];
|
||||
|
||||
/** Warning-level issues */
|
||||
warnings: PolicyWarning[];
|
||||
|
||||
/** Remediation hints for failures */
|
||||
remediationHints: PolicyRemediationHint[];
|
||||
|
||||
/** Evaluation timestamp */
|
||||
evaluatedAt: string;
|
||||
|
||||
/** Can the artifact be published? */
|
||||
canPublish: boolean;
|
||||
|
||||
/** Reason if publish is blocked */
|
||||
blockReason?: string;
|
||||
}
|
||||
|
||||
export interface PolicyGate {
|
||||
/** Gate identifier */
|
||||
gateId: string;
|
||||
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
|
||||
/** Gate type */
|
||||
type: 'determinism' | 'vulnerability' | 'license' | 'signature' | 'entropy' | 'custom';
|
||||
|
||||
/** Gate result */
|
||||
result: 'passed' | 'failed' | 'warning' | 'skipped';
|
||||
|
||||
/** Is this gate required for publish? */
|
||||
required: boolean;
|
||||
|
||||
/** Gate-specific details */
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
/** Evidence references */
|
||||
evidenceRefs?: string[];
|
||||
}
|
||||
|
||||
export interface PolicyBlockingIssue {
|
||||
/** Issue code */
|
||||
code: string;
|
||||
|
||||
/** Gate that produced this issue */
|
||||
gateId: string;
|
||||
|
||||
/** Issue severity */
|
||||
severity: 'critical' | 'high';
|
||||
|
||||
/** Issue description */
|
||||
message: string;
|
||||
|
||||
/** Affected resource */
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
export interface PolicyWarning {
|
||||
/** Warning code */
|
||||
code: string;
|
||||
|
||||
/** Gate that produced this warning */
|
||||
gateId: string;
|
||||
|
||||
/** Warning message */
|
||||
message: string;
|
||||
|
||||
/** Affected resource */
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
export interface PolicyRemediationHint {
|
||||
/** Which gate/issue this remediates */
|
||||
forGate: string;
|
||||
|
||||
/** Which issue code */
|
||||
forCode?: string;
|
||||
|
||||
/** Hint title */
|
||||
title: string;
|
||||
|
||||
/** Step-by-step instructions */
|
||||
steps: string[];
|
||||
|
||||
/** Documentation link */
|
||||
docsUrl?: string;
|
||||
|
||||
/** CLI command to run */
|
||||
cliCommand?: string;
|
||||
|
||||
/** Estimated effort */
|
||||
effort?: 'trivial' | 'easy' | 'moderate' | 'complex';
|
||||
}
|
||||
|
||||
export interface DeterminismGateDetails {
|
||||
/** Merkle root consistency */
|
||||
merkleRootConsistent: boolean;
|
||||
|
||||
/** Expected Merkle root */
|
||||
expectedMerkleRoot?: string;
|
||||
|
||||
/** Computed Merkle root */
|
||||
computedMerkleRoot?: string;
|
||||
|
||||
/** Fragment verification results */
|
||||
fragmentResults: {
|
||||
fragmentId: string;
|
||||
expected: string;
|
||||
computed: string;
|
||||
match: boolean;
|
||||
}[];
|
||||
|
||||
/** Composition file present */
|
||||
compositionPresent: boolean;
|
||||
|
||||
/** Total fragments */
|
||||
totalFragments: number;
|
||||
|
||||
/** Matching fragments */
|
||||
matchingFragments: number;
|
||||
}
|
||||
|
||||
export interface EntropyGateDetails {
|
||||
/** Overall entropy score */
|
||||
entropyScore: number;
|
||||
|
||||
/** Score threshold for warning */
|
||||
warnThreshold: number;
|
||||
|
||||
/** Score threshold for block */
|
||||
blockThreshold: number;
|
||||
|
||||
/** Action taken based on score */
|
||||
action: 'allow' | 'warn' | 'block';
|
||||
|
||||
/** High entropy files count */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Suspicious patterns detected */
|
||||
suspiciousPatterns: string[];
|
||||
}
|
||||
|
||||
@@ -1,373 +1,373 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
Release,
|
||||
ReleaseArtifact,
|
||||
PolicyEvaluation,
|
||||
PolicyGateResult,
|
||||
DeterminismGateDetails,
|
||||
RemediationHint,
|
||||
DeterminismFeatureFlags,
|
||||
PolicyGateStatus,
|
||||
} from './release.models';
|
||||
|
||||
/**
|
||||
* Injection token for Release API client.
|
||||
*/
|
||||
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
|
||||
|
||||
/**
|
||||
* Release API interface.
|
||||
*/
|
||||
export interface ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release>;
|
||||
listReleases(): Observable<readonly Release[]>;
|
||||
publishRelease(releaseId: string): Observable<Release>;
|
||||
cancelRelease(releaseId: string): Observable<Release>;
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data Fixtures
|
||||
// ============================================================================
|
||||
|
||||
const determinismPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-001',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'passed',
|
||||
message: 'Merkle root consistent. All fragment attestations verified.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-abc123?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f6...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const determinismFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-002',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'failed',
|
||||
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-def456?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a6...',
|
||||
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: [
|
||||
'sha256:layer3digest...',
|
||||
'sha256:layer5digest...',
|
||||
],
|
||||
},
|
||||
},
|
||||
remediation: {
|
||||
gateType: 'determinism',
|
||||
severity: 'critical',
|
||||
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
|
||||
steps: [
|
||||
{
|
||||
action: 'rebuild',
|
||||
title: 'Rebuild with deterministic toolchain',
|
||||
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
|
||||
command: 'stella scan --deterministic --sign --push',
|
||||
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide provenance attestation',
|
||||
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
|
||||
documentationUrl: 'https://docs.stellaops.io/provenance',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Re-sign with valid key',
|
||||
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
|
||||
command: 'stella sign --artifact sha256:...',
|
||||
automated: true,
|
||||
},
|
||||
{
|
||||
action: 'request-exception',
|
||||
title: 'Request policy exception',
|
||||
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '15-30 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const vulnerabilityPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-vuln-001',
|
||||
gateType: 'vulnerability',
|
||||
name: 'Vulnerability Scan',
|
||||
status: 'passed',
|
||||
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const entropyWarningGate: PolicyGateResult = {
|
||||
gateId: 'gate-ent-001',
|
||||
gateType: 'entropy',
|
||||
name: 'Entropy Analysis',
|
||||
status: 'warning',
|
||||
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
remediation: {
|
||||
gateType: 'entropy',
|
||||
severity: 'medium',
|
||||
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
|
||||
steps: [
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide source provenance',
|
||||
description: 'Attach build provenance or source mappings for high-entropy binaries.',
|
||||
automated: false,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '10 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const licensePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-lic-001',
|
||||
gateType: 'license',
|
||||
name: 'License Compliance',
|
||||
status: 'passed',
|
||||
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const signaturePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-001',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'passed',
|
||||
message: 'Image signature verified against tenant keyring.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
};
|
||||
|
||||
const signatureFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-002',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'failed',
|
||||
message: 'No valid signature found. Image must be signed before release.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
remediation: {
|
||||
gateType: 'signature',
|
||||
severity: 'critical',
|
||||
summary: 'The image is not signed or the signature cannot be verified.',
|
||||
steps: [
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Sign the image',
|
||||
description: 'Sign the image using your tenant signing key.',
|
||||
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '2 minutes',
|
||||
exceptionAllowed: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Artifacts with policy evaluations
|
||||
const passingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-001',
|
||||
name: 'api-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
size: 245_000_000,
|
||||
createdAt: '2025-11-27T08:00:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-001',
|
||||
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
overallStatus: 'passed',
|
||||
gates: [
|
||||
determinismPassingGate,
|
||||
vulnerabilityPassingGate,
|
||||
entropyWarningGate,
|
||||
licensePassingGate,
|
||||
signaturePassingGate,
|
||||
],
|
||||
blockingGates: [],
|
||||
canPublish: true,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
|
||||
merkleRootConsistent: true,
|
||||
contentHash: 'sha256:content1234567890abcdef',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const failingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-002',
|
||||
name: 'worker-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
size: 312_000_000,
|
||||
createdAt: '2025-11-27T07:45:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-002',
|
||||
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
overallStatus: 'failed',
|
||||
gates: [
|
||||
determinismFailingGate,
|
||||
vulnerabilityPassingGate,
|
||||
licensePassingGate,
|
||||
signatureFailingGate,
|
||||
],
|
||||
blockingGates: ['gate-det-002', 'gate-sig-002'],
|
||||
canPublish: false,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a67890',
|
||||
merkleRootConsistent: false,
|
||||
contentHash: 'sha256:content9876543210',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Release fixtures
|
||||
const passingRelease: Release = {
|
||||
releaseId: 'rel-001',
|
||||
name: 'Platform v1.2.3',
|
||||
version: '1.2.3',
|
||||
status: 'pending_approval',
|
||||
createdAt: '2025-11-27T08:30:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [passingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Feature release with API improvements and bug fixes.',
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-001',
|
||||
approver: 'security-team',
|
||||
decision: 'approved',
|
||||
comment: 'Security review passed.',
|
||||
decidedAt: '2025-11-27T09:00:00Z',
|
||||
},
|
||||
{
|
||||
approvalId: 'apr-002',
|
||||
approver: 'release-manager',
|
||||
decision: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const blockedRelease: Release = {
|
||||
releaseId: 'rel-002',
|
||||
name: 'Platform v1.2.4-rc1',
|
||||
version: '1.2.4-rc1',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T07:00:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [failingArtifact],
|
||||
targetEnvironment: 'staging',
|
||||
notes: 'Release candidate blocked due to policy gate failures.',
|
||||
};
|
||||
|
||||
const mixedRelease: Release = {
|
||||
releaseId: 'rel-003',
|
||||
name: 'Platform v1.2.5',
|
||||
version: '1.2.5',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T06:00:00Z',
|
||||
createdBy: 'ci-pipeline',
|
||||
artifacts: [passingArtifact, failingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Multi-artifact release with mixed policy results.',
|
||||
};
|
||||
|
||||
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
|
||||
|
||||
const mockFeatureFlags: DeterminismFeatureFlags = {
|
||||
enabled: true,
|
||||
blockOnFailure: true,
|
||||
warnOnly: false,
|
||||
bypassRoles: ['security-admin', 'release-manager'],
|
||||
requireApprovalForBypass: true,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mock API Implementation
|
||||
// ============================================================================
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockReleaseApi implements ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of(release).pipe(delay(200));
|
||||
}
|
||||
|
||||
listReleases(): Observable<readonly Release[]> {
|
||||
return of(mockReleases).pipe(delay(300));
|
||||
}
|
||||
|
||||
publishRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
// Simulate publish (would update status in real implementation)
|
||||
return of({
|
||||
...release,
|
||||
status: 'published',
|
||||
publishedAt: new Date().toISOString(),
|
||||
} as Release).pipe(delay(500));
|
||||
}
|
||||
|
||||
cancelRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of({
|
||||
...release,
|
||||
status: 'cancelled',
|
||||
} as Release).pipe(delay(300));
|
||||
}
|
||||
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
|
||||
return of(mockFeatureFlags).pipe(delay(100));
|
||||
}
|
||||
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
|
||||
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
|
||||
}
|
||||
}
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
Release,
|
||||
ReleaseArtifact,
|
||||
PolicyEvaluation,
|
||||
PolicyGateResult,
|
||||
DeterminismGateDetails,
|
||||
RemediationHint,
|
||||
DeterminismFeatureFlags,
|
||||
PolicyGateStatus,
|
||||
} from './release.models';
|
||||
|
||||
/**
|
||||
* Injection token for Release API client.
|
||||
*/
|
||||
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
|
||||
|
||||
/**
|
||||
* Release API interface.
|
||||
*/
|
||||
export interface ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release>;
|
||||
listReleases(): Observable<readonly Release[]>;
|
||||
publishRelease(releaseId: string): Observable<Release>;
|
||||
cancelRelease(releaseId: string): Observable<Release>;
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data Fixtures
|
||||
// ============================================================================
|
||||
|
||||
const determinismPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-001',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'passed',
|
||||
message: 'Merkle root consistent. All fragment attestations verified.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-abc123?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f6...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const determinismFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-002',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'failed',
|
||||
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-def456?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a6...',
|
||||
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: [
|
||||
'sha256:layer3digest...',
|
||||
'sha256:layer5digest...',
|
||||
],
|
||||
},
|
||||
},
|
||||
remediation: {
|
||||
gateType: 'determinism',
|
||||
severity: 'critical',
|
||||
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
|
||||
steps: [
|
||||
{
|
||||
action: 'rebuild',
|
||||
title: 'Rebuild with deterministic toolchain',
|
||||
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
|
||||
command: 'stella scan --deterministic --sign --push',
|
||||
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide provenance attestation',
|
||||
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
|
||||
documentationUrl: 'https://docs.stellaops.io/provenance',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Re-sign with valid key',
|
||||
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
|
||||
command: 'stella sign --artifact sha256:...',
|
||||
automated: true,
|
||||
},
|
||||
{
|
||||
action: 'request-exception',
|
||||
title: 'Request policy exception',
|
||||
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '15-30 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const vulnerabilityPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-vuln-001',
|
||||
gateType: 'vulnerability',
|
||||
name: 'Vulnerability Scan',
|
||||
status: 'passed',
|
||||
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const entropyWarningGate: PolicyGateResult = {
|
||||
gateId: 'gate-ent-001',
|
||||
gateType: 'entropy',
|
||||
name: 'Entropy Analysis',
|
||||
status: 'warning',
|
||||
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
remediation: {
|
||||
gateType: 'entropy',
|
||||
severity: 'medium',
|
||||
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
|
||||
steps: [
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide source provenance',
|
||||
description: 'Attach build provenance or source mappings for high-entropy binaries.',
|
||||
automated: false,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '10 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const licensePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-lic-001',
|
||||
gateType: 'license',
|
||||
name: 'License Compliance',
|
||||
status: 'passed',
|
||||
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const signaturePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-001',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'passed',
|
||||
message: 'Image signature verified against tenant keyring.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
};
|
||||
|
||||
const signatureFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-002',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'failed',
|
||||
message: 'No valid signature found. Image must be signed before release.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
remediation: {
|
||||
gateType: 'signature',
|
||||
severity: 'critical',
|
||||
summary: 'The image is not signed or the signature cannot be verified.',
|
||||
steps: [
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Sign the image',
|
||||
description: 'Sign the image using your tenant signing key.',
|
||||
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '2 minutes',
|
||||
exceptionAllowed: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Artifacts with policy evaluations
|
||||
const passingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-001',
|
||||
name: 'api-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
size: 245_000_000,
|
||||
createdAt: '2025-11-27T08:00:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-001',
|
||||
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
overallStatus: 'passed',
|
||||
gates: [
|
||||
determinismPassingGate,
|
||||
vulnerabilityPassingGate,
|
||||
entropyWarningGate,
|
||||
licensePassingGate,
|
||||
signaturePassingGate,
|
||||
],
|
||||
blockingGates: [],
|
||||
canPublish: true,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
|
||||
merkleRootConsistent: true,
|
||||
contentHash: 'sha256:content1234567890abcdef',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const failingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-002',
|
||||
name: 'worker-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
size: 312_000_000,
|
||||
createdAt: '2025-11-27T07:45:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-002',
|
||||
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
overallStatus: 'failed',
|
||||
gates: [
|
||||
determinismFailingGate,
|
||||
vulnerabilityPassingGate,
|
||||
licensePassingGate,
|
||||
signatureFailingGate,
|
||||
],
|
||||
blockingGates: ['gate-det-002', 'gate-sig-002'],
|
||||
canPublish: false,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a67890',
|
||||
merkleRootConsistent: false,
|
||||
contentHash: 'sha256:content9876543210',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Release fixtures
|
||||
const passingRelease: Release = {
|
||||
releaseId: 'rel-001',
|
||||
name: 'Platform v1.2.3',
|
||||
version: '1.2.3',
|
||||
status: 'pending_approval',
|
||||
createdAt: '2025-11-27T08:30:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [passingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Feature release with API improvements and bug fixes.',
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-001',
|
||||
approver: 'security-team',
|
||||
decision: 'approved',
|
||||
comment: 'Security review passed.',
|
||||
decidedAt: '2025-11-27T09:00:00Z',
|
||||
},
|
||||
{
|
||||
approvalId: 'apr-002',
|
||||
approver: 'release-manager',
|
||||
decision: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const blockedRelease: Release = {
|
||||
releaseId: 'rel-002',
|
||||
name: 'Platform v1.2.4-rc1',
|
||||
version: '1.2.4-rc1',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T07:00:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [failingArtifact],
|
||||
targetEnvironment: 'staging',
|
||||
notes: 'Release candidate blocked due to policy gate failures.',
|
||||
};
|
||||
|
||||
const mixedRelease: Release = {
|
||||
releaseId: 'rel-003',
|
||||
name: 'Platform v1.2.5',
|
||||
version: '1.2.5',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T06:00:00Z',
|
||||
createdBy: 'ci-pipeline',
|
||||
artifacts: [passingArtifact, failingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Multi-artifact release with mixed policy results.',
|
||||
};
|
||||
|
||||
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
|
||||
|
||||
const mockFeatureFlags: DeterminismFeatureFlags = {
|
||||
enabled: true,
|
||||
blockOnFailure: true,
|
||||
warnOnly: false,
|
||||
bypassRoles: ['security-admin', 'release-manager'],
|
||||
requireApprovalForBypass: true,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mock API Implementation
|
||||
// ============================================================================
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockReleaseApi implements ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of(release).pipe(delay(200));
|
||||
}
|
||||
|
||||
listReleases(): Observable<readonly Release[]> {
|
||||
return of(mockReleases).pipe(delay(300));
|
||||
}
|
||||
|
||||
publishRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
// Simulate publish (would update status in real implementation)
|
||||
return of({
|
||||
...release,
|
||||
status: 'published',
|
||||
publishedAt: new Date().toISOString(),
|
||||
} as Release).pipe(delay(500));
|
||||
}
|
||||
|
||||
cancelRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of({
|
||||
...release,
|
||||
status: 'cancelled',
|
||||
} as Release).pipe(delay(300));
|
||||
}
|
||||
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
|
||||
return of(mockFeatureFlags).pipe(delay(100));
|
||||
}
|
||||
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
|
||||
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,161 +1,161 @@
|
||||
/**
|
||||
* Release and Policy Gate models for UI-POLICY-DET-01.
|
||||
* Supports determinism-gated release flows with remediation hints.
|
||||
*/
|
||||
|
||||
// Policy gate evaluation status
|
||||
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
|
||||
|
||||
// Types of policy gates
|
||||
export type PolicyGateType =
|
||||
| 'determinism'
|
||||
| 'vulnerability'
|
||||
| 'license'
|
||||
| 'entropy'
|
||||
| 'signature'
|
||||
| 'sbom-completeness'
|
||||
| 'custom';
|
||||
|
||||
// Remediation action types
|
||||
export type RemediationActionType =
|
||||
| 'rebuild'
|
||||
| 'provide-provenance'
|
||||
| 'sign-artifact'
|
||||
| 'update-dependency'
|
||||
| 'request-exception'
|
||||
| 'manual-review';
|
||||
|
||||
/**
|
||||
* A single remediation step with optional automation support.
|
||||
*/
|
||||
export interface RemediationStep {
|
||||
readonly action: RemediationActionType;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly command?: string; // Optional CLI command to run
|
||||
readonly documentationUrl?: string;
|
||||
readonly automated: boolean; // Can be triggered from UI
|
||||
}
|
||||
|
||||
/**
|
||||
* Remediation hints for a failed policy gate.
|
||||
*/
|
||||
export interface RemediationHint {
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly summary: string;
|
||||
readonly steps: readonly RemediationStep[];
|
||||
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
|
||||
readonly exceptionAllowed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual policy gate evaluation result.
|
||||
*/
|
||||
export interface PolicyGateResult {
|
||||
readonly gateId: string;
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly name: string;
|
||||
readonly status: PolicyGateStatus;
|
||||
readonly message: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly blockingPublish: boolean;
|
||||
readonly evidence?: {
|
||||
readonly type: string;
|
||||
readonly url?: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
};
|
||||
readonly remediation?: RemediationHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determinism-specific gate details.
|
||||
*/
|
||||
export interface DeterminismGateDetails {
|
||||
readonly merkleRoot?: string;
|
||||
readonly merkleRootConsistent: boolean;
|
||||
readonly contentHash?: string;
|
||||
readonly compositionManifestUri?: string;
|
||||
readonly fragmentCount?: number;
|
||||
readonly verifiedFragments?: number;
|
||||
readonly failedFragments?: readonly string[]; // Layer digests that failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall policy evaluation for a release artifact.
|
||||
*/
|
||||
export interface PolicyEvaluation {
|
||||
readonly evaluationId: string;
|
||||
readonly artifactDigest: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly overallStatus: PolicyGateStatus;
|
||||
readonly gates: readonly PolicyGateResult[];
|
||||
readonly blockingGates: readonly string[]; // Gate IDs that block publish
|
||||
readonly canPublish: boolean;
|
||||
readonly determinismDetails?: DeterminismGateDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release artifact with policy evaluation.
|
||||
*/
|
||||
export interface ReleaseArtifact {
|
||||
readonly artifactId: string;
|
||||
readonly name: string;
|
||||
readonly tag: string;
|
||||
readonly digest: string;
|
||||
readonly size: number;
|
||||
readonly createdAt: string;
|
||||
readonly registry: string;
|
||||
readonly policyEvaluation?: PolicyEvaluation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release workflow status.
|
||||
*/
|
||||
export type ReleaseStatus =
|
||||
| 'draft'
|
||||
| 'pending_approval'
|
||||
| 'approved'
|
||||
| 'publishing'
|
||||
| 'published'
|
||||
| 'blocked'
|
||||
| 'cancelled';
|
||||
|
||||
/**
|
||||
* Release with multiple artifacts and policy gates.
|
||||
*/
|
||||
export interface Release {
|
||||
readonly releaseId: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly status: ReleaseStatus;
|
||||
readonly createdAt: string;
|
||||
readonly createdBy: string;
|
||||
readonly artifacts: readonly ReleaseArtifact[];
|
||||
readonly targetEnvironment: string;
|
||||
readonly notes?: string;
|
||||
readonly approvals?: readonly ReleaseApproval[];
|
||||
readonly publishedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release approval record.
|
||||
*/
|
||||
export interface ReleaseApproval {
|
||||
readonly approvalId: string;
|
||||
readonly approver: string;
|
||||
readonly decision: 'approved' | 'rejected' | 'pending';
|
||||
readonly comment?: string;
|
||||
readonly decidedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag configuration for determinism blocking.
|
||||
*/
|
||||
export interface DeterminismFeatureFlags {
|
||||
readonly enabled: boolean;
|
||||
readonly blockOnFailure: boolean;
|
||||
readonly warnOnly: boolean;
|
||||
readonly bypassRoles?: readonly string[];
|
||||
readonly requireApprovalForBypass: boolean;
|
||||
}
|
||||
/**
|
||||
* Release and Policy Gate models for UI-POLICY-DET-01.
|
||||
* Supports determinism-gated release flows with remediation hints.
|
||||
*/
|
||||
|
||||
// Policy gate evaluation status
|
||||
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
|
||||
|
||||
// Types of policy gates
|
||||
export type PolicyGateType =
|
||||
| 'determinism'
|
||||
| 'vulnerability'
|
||||
| 'license'
|
||||
| 'entropy'
|
||||
| 'signature'
|
||||
| 'sbom-completeness'
|
||||
| 'custom';
|
||||
|
||||
// Remediation action types
|
||||
export type RemediationActionType =
|
||||
| 'rebuild'
|
||||
| 'provide-provenance'
|
||||
| 'sign-artifact'
|
||||
| 'update-dependency'
|
||||
| 'request-exception'
|
||||
| 'manual-review';
|
||||
|
||||
/**
|
||||
* A single remediation step with optional automation support.
|
||||
*/
|
||||
export interface RemediationStep {
|
||||
readonly action: RemediationActionType;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly command?: string; // Optional CLI command to run
|
||||
readonly documentationUrl?: string;
|
||||
readonly automated: boolean; // Can be triggered from UI
|
||||
}
|
||||
|
||||
/**
|
||||
* Remediation hints for a failed policy gate.
|
||||
*/
|
||||
export interface RemediationHint {
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly summary: string;
|
||||
readonly steps: readonly RemediationStep[];
|
||||
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
|
||||
readonly exceptionAllowed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual policy gate evaluation result.
|
||||
*/
|
||||
export interface PolicyGateResult {
|
||||
readonly gateId: string;
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly name: string;
|
||||
readonly status: PolicyGateStatus;
|
||||
readonly message: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly blockingPublish: boolean;
|
||||
readonly evidence?: {
|
||||
readonly type: string;
|
||||
readonly url?: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
};
|
||||
readonly remediation?: RemediationHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determinism-specific gate details.
|
||||
*/
|
||||
export interface DeterminismGateDetails {
|
||||
readonly merkleRoot?: string;
|
||||
readonly merkleRootConsistent: boolean;
|
||||
readonly contentHash?: string;
|
||||
readonly compositionManifestUri?: string;
|
||||
readonly fragmentCount?: number;
|
||||
readonly verifiedFragments?: number;
|
||||
readonly failedFragments?: readonly string[]; // Layer digests that failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall policy evaluation for a release artifact.
|
||||
*/
|
||||
export interface PolicyEvaluation {
|
||||
readonly evaluationId: string;
|
||||
readonly artifactDigest: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly overallStatus: PolicyGateStatus;
|
||||
readonly gates: readonly PolicyGateResult[];
|
||||
readonly blockingGates: readonly string[]; // Gate IDs that block publish
|
||||
readonly canPublish: boolean;
|
||||
readonly determinismDetails?: DeterminismGateDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release artifact with policy evaluation.
|
||||
*/
|
||||
export interface ReleaseArtifact {
|
||||
readonly artifactId: string;
|
||||
readonly name: string;
|
||||
readonly tag: string;
|
||||
readonly digest: string;
|
||||
readonly size: number;
|
||||
readonly createdAt: string;
|
||||
readonly registry: string;
|
||||
readonly policyEvaluation?: PolicyEvaluation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release workflow status.
|
||||
*/
|
||||
export type ReleaseStatus =
|
||||
| 'draft'
|
||||
| 'pending_approval'
|
||||
| 'approved'
|
||||
| 'publishing'
|
||||
| 'published'
|
||||
| 'blocked'
|
||||
| 'cancelled';
|
||||
|
||||
/**
|
||||
* Release with multiple artifacts and policy gates.
|
||||
*/
|
||||
export interface Release {
|
||||
readonly releaseId: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly status: ReleaseStatus;
|
||||
readonly createdAt: string;
|
||||
readonly createdBy: string;
|
||||
readonly artifacts: readonly ReleaseArtifact[];
|
||||
readonly targetEnvironment: string;
|
||||
readonly notes?: string;
|
||||
readonly approvals?: readonly ReleaseApproval[];
|
||||
readonly publishedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release approval record.
|
||||
*/
|
||||
export interface ReleaseApproval {
|
||||
readonly approvalId: string;
|
||||
readonly approver: string;
|
||||
readonly decision: 'approved' | 'rejected' | 'pending';
|
||||
readonly comment?: string;
|
||||
readonly decidedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag configuration for determinism blocking.
|
||||
*/
|
||||
export interface DeterminismFeatureFlags {
|
||||
readonly enabled: boolean;
|
||||
readonly blockOnFailure: boolean;
|
||||
readonly warnOnly: boolean;
|
||||
readonly bypassRoles?: readonly string[];
|
||||
readonly requireApprovalForBypass: boolean;
|
||||
}
|
||||
|
||||
@@ -1,147 +1,147 @@
|
||||
export type ScanAttestationStatusKind = 'verified' | 'pending' | 'failed';
|
||||
|
||||
export interface ScanAttestationStatus {
|
||||
readonly uuid: string;
|
||||
readonly status: ScanAttestationStatusKind;
|
||||
readonly index?: number;
|
||||
readonly logUrl?: string;
|
||||
readonly checkedAt?: string;
|
||||
readonly statusMessage?: string;
|
||||
}
|
||||
|
||||
// Determinism models based on docs/modules/scanner/deterministic-sbom-compose.md
|
||||
|
||||
export type DeterminismStatus = 'verified' | 'pending' | 'failed' | 'unknown';
|
||||
|
||||
export interface FragmentAttestation {
|
||||
readonly layerDigest: string;
|
||||
readonly fragmentSha256: string;
|
||||
readonly dsseEnvelopeSha256: string;
|
||||
readonly dsseStatus: 'verified' | 'pending' | 'failed';
|
||||
readonly verifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface CompositionManifest {
|
||||
readonly compositionUri: string;
|
||||
readonly merkleRoot: string;
|
||||
readonly fragmentCount: number;
|
||||
readonly fragments: readonly FragmentAttestation[];
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface DeterminismEvidence {
|
||||
readonly status: DeterminismStatus;
|
||||
readonly merkleRoot?: string;
|
||||
readonly merkleRootConsistent: boolean;
|
||||
readonly compositionManifest?: CompositionManifest;
|
||||
readonly contentHash?: string;
|
||||
readonly verifiedAt?: string;
|
||||
readonly failureReason?: string;
|
||||
readonly stellaProperties?: {
|
||||
readonly 'stellaops:stella.contentHash'?: string;
|
||||
readonly 'stellaops:composition.manifest'?: string;
|
||||
readonly 'stellaops:merkle.root'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Entropy analysis models based on docs/modules/scanner/entropy.md
|
||||
|
||||
export interface EntropyWindow {
|
||||
readonly offset: number;
|
||||
readonly length: number;
|
||||
readonly entropy: number; // 0-8 bits/byte
|
||||
}
|
||||
|
||||
export interface EntropyFile {
|
||||
readonly path: string;
|
||||
readonly size: number;
|
||||
readonly opaqueBytes: number;
|
||||
readonly opaqueRatio: number; // 0-1
|
||||
readonly flags: readonly string[]; // e.g., 'stripped', 'section:.UPX0', 'no-symbols', 'packed'
|
||||
readonly windows: readonly EntropyWindow[];
|
||||
}
|
||||
|
||||
export interface EntropyLayerSummary {
|
||||
readonly digest: string;
|
||||
readonly opaqueBytes: number;
|
||||
readonly totalBytes: number;
|
||||
readonly opaqueRatio: number; // 0-1
|
||||
readonly indicators: readonly string[]; // e.g., 'packed', 'no-symbols'
|
||||
}
|
||||
|
||||
export interface EntropyReport {
|
||||
readonly schema: string;
|
||||
readonly generatedAt: string;
|
||||
readonly imageDigest: string;
|
||||
readonly layerDigest?: string;
|
||||
readonly files: readonly EntropyFile[];
|
||||
}
|
||||
|
||||
export interface EntropyLayerSummaryReport {
|
||||
readonly schema: string;
|
||||
readonly generatedAt: string;
|
||||
readonly imageDigest: string;
|
||||
readonly layers: readonly EntropyLayerSummary[];
|
||||
readonly imageOpaqueRatio: number; // 0-1
|
||||
readonly entropyPenalty: number; // 0-0.3
|
||||
}
|
||||
|
||||
export interface EntropyEvidence {
|
||||
readonly report?: EntropyReport;
|
||||
readonly layerSummary?: EntropyLayerSummaryReport;
|
||||
readonly downloadUrl?: string; // URL to entropy.report.json
|
||||
}
|
||||
|
||||
// Binary Evidence models for SPRINT_20251226_014_BINIDX (SCANINT-17,18,19)
|
||||
|
||||
export type BinaryFixStatus = 'fixed' | 'vulnerable' | 'not_affected' | 'wontfix' | 'unknown';
|
||||
|
||||
export interface BinaryIdentity {
|
||||
readonly format: 'elf' | 'pe' | 'macho';
|
||||
readonly buildId?: string;
|
||||
readonly fileSha256: string;
|
||||
readonly architecture: string;
|
||||
readonly binaryKey: string;
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
export interface BinaryFixStatusInfo {
|
||||
readonly state: BinaryFixStatus;
|
||||
readonly fixedVersion?: string;
|
||||
readonly method: 'changelog' | 'patch_analysis' | 'advisory';
|
||||
readonly confidence: number;
|
||||
}
|
||||
|
||||
export interface BinaryVulnMatch {
|
||||
readonly cveId: string;
|
||||
readonly method: 'buildid_catalog' | 'fingerprint_match' | 'range_match';
|
||||
readonly confidence: number;
|
||||
readonly vulnerablePurl: string;
|
||||
readonly fixStatus?: BinaryFixStatusInfo;
|
||||
readonly similarity?: number;
|
||||
readonly matchedFunction?: string;
|
||||
}
|
||||
|
||||
export interface BinaryFinding {
|
||||
readonly identity: BinaryIdentity;
|
||||
readonly layerDigest: string;
|
||||
readonly matches: readonly BinaryVulnMatch[];
|
||||
}
|
||||
|
||||
export interface BinaryEvidence {
|
||||
readonly binaries: readonly BinaryFinding[];
|
||||
readonly scanId: string;
|
||||
readonly scannedAt: string;
|
||||
readonly distro?: string;
|
||||
readonly release?: string;
|
||||
}
|
||||
|
||||
export interface ScanDetail {
|
||||
readonly scanId: string;
|
||||
readonly imageDigest: string;
|
||||
readonly completedAt: string;
|
||||
readonly attestation?: ScanAttestationStatus;
|
||||
readonly determinism?: DeterminismEvidence;
|
||||
readonly entropy?: EntropyEvidence;
|
||||
readonly binaryEvidence?: BinaryEvidence;
|
||||
}
|
||||
export type ScanAttestationStatusKind = 'verified' | 'pending' | 'failed';
|
||||
|
||||
export interface ScanAttestationStatus {
|
||||
readonly uuid: string;
|
||||
readonly status: ScanAttestationStatusKind;
|
||||
readonly index?: number;
|
||||
readonly logUrl?: string;
|
||||
readonly checkedAt?: string;
|
||||
readonly statusMessage?: string;
|
||||
}
|
||||
|
||||
// Determinism models based on docs/modules/scanner/deterministic-sbom-compose.md
|
||||
|
||||
export type DeterminismStatus = 'verified' | 'pending' | 'failed' | 'unknown';
|
||||
|
||||
export interface FragmentAttestation {
|
||||
readonly layerDigest: string;
|
||||
readonly fragmentSha256: string;
|
||||
readonly dsseEnvelopeSha256: string;
|
||||
readonly dsseStatus: 'verified' | 'pending' | 'failed';
|
||||
readonly verifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface CompositionManifest {
|
||||
readonly compositionUri: string;
|
||||
readonly merkleRoot: string;
|
||||
readonly fragmentCount: number;
|
||||
readonly fragments: readonly FragmentAttestation[];
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface DeterminismEvidence {
|
||||
readonly status: DeterminismStatus;
|
||||
readonly merkleRoot?: string;
|
||||
readonly merkleRootConsistent: boolean;
|
||||
readonly compositionManifest?: CompositionManifest;
|
||||
readonly contentHash?: string;
|
||||
readonly verifiedAt?: string;
|
||||
readonly failureReason?: string;
|
||||
readonly stellaProperties?: {
|
||||
readonly 'stellaops:stella.contentHash'?: string;
|
||||
readonly 'stellaops:composition.manifest'?: string;
|
||||
readonly 'stellaops:merkle.root'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Entropy analysis models based on docs/modules/scanner/entropy.md
|
||||
|
||||
export interface EntropyWindow {
|
||||
readonly offset: number;
|
||||
readonly length: number;
|
||||
readonly entropy: number; // 0-8 bits/byte
|
||||
}
|
||||
|
||||
export interface EntropyFile {
|
||||
readonly path: string;
|
||||
readonly size: number;
|
||||
readonly opaqueBytes: number;
|
||||
readonly opaqueRatio: number; // 0-1
|
||||
readonly flags: readonly string[]; // e.g., 'stripped', 'section:.UPX0', 'no-symbols', 'packed'
|
||||
readonly windows: readonly EntropyWindow[];
|
||||
}
|
||||
|
||||
export interface EntropyLayerSummary {
|
||||
readonly digest: string;
|
||||
readonly opaqueBytes: number;
|
||||
readonly totalBytes: number;
|
||||
readonly opaqueRatio: number; // 0-1
|
||||
readonly indicators: readonly string[]; // e.g., 'packed', 'no-symbols'
|
||||
}
|
||||
|
||||
export interface EntropyReport {
|
||||
readonly schema: string;
|
||||
readonly generatedAt: string;
|
||||
readonly imageDigest: string;
|
||||
readonly layerDigest?: string;
|
||||
readonly files: readonly EntropyFile[];
|
||||
}
|
||||
|
||||
export interface EntropyLayerSummaryReport {
|
||||
readonly schema: string;
|
||||
readonly generatedAt: string;
|
||||
readonly imageDigest: string;
|
||||
readonly layers: readonly EntropyLayerSummary[];
|
||||
readonly imageOpaqueRatio: number; // 0-1
|
||||
readonly entropyPenalty: number; // 0-0.3
|
||||
}
|
||||
|
||||
export interface EntropyEvidence {
|
||||
readonly report?: EntropyReport;
|
||||
readonly layerSummary?: EntropyLayerSummaryReport;
|
||||
readonly downloadUrl?: string; // URL to entropy.report.json
|
||||
}
|
||||
|
||||
// Binary Evidence models for SPRINT_20251226_014_BINIDX (SCANINT-17,18,19)
|
||||
|
||||
export type BinaryFixStatus = 'fixed' | 'vulnerable' | 'not_affected' | 'wontfix' | 'unknown';
|
||||
|
||||
export interface BinaryIdentity {
|
||||
readonly format: 'elf' | 'pe' | 'macho';
|
||||
readonly buildId?: string;
|
||||
readonly fileSha256: string;
|
||||
readonly architecture: string;
|
||||
readonly binaryKey: string;
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
export interface BinaryFixStatusInfo {
|
||||
readonly state: BinaryFixStatus;
|
||||
readonly fixedVersion?: string;
|
||||
readonly method: 'changelog' | 'patch_analysis' | 'advisory';
|
||||
readonly confidence: number;
|
||||
}
|
||||
|
||||
export interface BinaryVulnMatch {
|
||||
readonly cveId: string;
|
||||
readonly method: 'buildid_catalog' | 'fingerprint_match' | 'range_match';
|
||||
readonly confidence: number;
|
||||
readonly vulnerablePurl: string;
|
||||
readonly fixStatus?: BinaryFixStatusInfo;
|
||||
readonly similarity?: number;
|
||||
readonly matchedFunction?: string;
|
||||
}
|
||||
|
||||
export interface BinaryFinding {
|
||||
readonly identity: BinaryIdentity;
|
||||
readonly layerDigest: string;
|
||||
readonly matches: readonly BinaryVulnMatch[];
|
||||
}
|
||||
|
||||
export interface BinaryEvidence {
|
||||
readonly binaries: readonly BinaryFinding[];
|
||||
readonly scanId: string;
|
||||
readonly scannedAt: string;
|
||||
readonly distro?: string;
|
||||
readonly release?: string;
|
||||
}
|
||||
|
||||
export interface ScanDetail {
|
||||
readonly scanId: string;
|
||||
readonly imageDigest: string;
|
||||
readonly completedAt: string;
|
||||
readonly attestation?: ScanAttestationStatus;
|
||||
readonly determinism?: DeterminismEvidence;
|
||||
readonly entropy?: EntropyEvidence;
|
||||
readonly binaryEvidence?: BinaryEvidence;
|
||||
}
|
||||
|
||||
@@ -1,267 +1,267 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
Vulnerability,
|
||||
VulnerabilitiesQueryOptions,
|
||||
VulnerabilitiesResponse,
|
||||
VulnerabilityStats,
|
||||
VulnWorkflowRequest,
|
||||
VulnWorkflowResponse,
|
||||
VulnExportRequest,
|
||||
VulnExportResponse,
|
||||
} from './vulnerability.models';
|
||||
|
||||
/**
|
||||
* Vulnerability API interface.
|
||||
* Implements WEB-VULN-29-001 contract with tenant scoping and RBAC/ABAC enforcement.
|
||||
*/
|
||||
export interface VulnerabilityApi {
|
||||
/** List vulnerabilities with filtering and pagination. */
|
||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse>;
|
||||
|
||||
/** Get a single vulnerability by ID. */
|
||||
getVulnerability(vulnId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<Vulnerability>;
|
||||
|
||||
/** Get vulnerability statistics. */
|
||||
getStats(options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats>;
|
||||
|
||||
/** Submit a workflow action (ack, close, reopen, etc.). */
|
||||
submitWorkflowAction(request: VulnWorkflowRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnWorkflowResponse>;
|
||||
|
||||
/** Request a vulnerability export. */
|
||||
requestExport(request: VulnExportRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
|
||||
|
||||
/** Get export status by ID. */
|
||||
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
|
||||
}
|
||||
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
Vulnerability,
|
||||
VulnerabilitiesQueryOptions,
|
||||
VulnerabilitiesResponse,
|
||||
VulnerabilityStats,
|
||||
VulnWorkflowRequest,
|
||||
VulnWorkflowResponse,
|
||||
VulnExportRequest,
|
||||
VulnExportResponse,
|
||||
} from './vulnerability.models';
|
||||
|
||||
/**
|
||||
* Vulnerability API interface.
|
||||
* Implements WEB-VULN-29-001 contract with tenant scoping and RBAC/ABAC enforcement.
|
||||
*/
|
||||
export interface VulnerabilityApi {
|
||||
/** List vulnerabilities with filtering and pagination. */
|
||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse>;
|
||||
|
||||
/** Get a single vulnerability by ID. */
|
||||
getVulnerability(vulnId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<Vulnerability>;
|
||||
|
||||
/** Get vulnerability statistics. */
|
||||
getStats(options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats>;
|
||||
|
||||
/** Submit a workflow action (ack, close, reopen, etc.). */
|
||||
submitWorkflowAction(request: VulnWorkflowRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnWorkflowResponse>;
|
||||
|
||||
/** Request a vulnerability export. */
|
||||
requestExport(request: VulnExportRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
|
||||
|
||||
/** Get export status by ID. */
|
||||
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
|
||||
}
|
||||
|
||||
export const VULNERABILITY_API = new InjectionToken<VulnerabilityApi>('VULNERABILITY_API');
|
||||
|
||||
const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
{
|
||||
vulnId: 'vuln-001',
|
||||
cveId: 'CVE-2021-44228',
|
||||
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
|
||||
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severity: 'critical',
|
||||
cvssScore: 10.0,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||
status: 'open',
|
||||
publishedAt: '2021-12-10T00:00:00Z',
|
||||
modifiedAt: '2024-06-27T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
|
||||
name: 'log4j-core',
|
||||
version: '2.14.1',
|
||||
fixedVersion: '2.17.1',
|
||||
assetIds: ['asset-web-prod', 'asset-api-prod'],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||
'https://logging.apache.org/log4j/2.x/security.html',
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-002',
|
||||
cveId: 'CVE-2021-45046',
|
||||
title: 'Log4j2 Thread Context Message Pattern DoS',
|
||||
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
|
||||
severity: 'critical',
|
||||
cvssScore: 9.0,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||
status: 'excepted',
|
||||
publishedAt: '2021-12-14T00:00:00Z',
|
||||
modifiedAt: '2023-11-06T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0',
|
||||
name: 'log4j-core',
|
||||
version: '2.15.0',
|
||||
fixedVersion: '2.17.1',
|
||||
assetIds: ['asset-internal-001'],
|
||||
},
|
||||
],
|
||||
hasException: true,
|
||||
exceptionId: 'exc-test-001',
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-003',
|
||||
cveId: 'CVE-2023-44487',
|
||||
title: 'HTTP/2 Rapid Reset Attack',
|
||||
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
|
||||
severity: 'high',
|
||||
cvssScore: 7.5,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H',
|
||||
status: 'in_progress',
|
||||
publishedAt: '2023-10-10T00:00:00Z',
|
||||
modifiedAt: '2024-05-01T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/golang.org/x/net@0.15.0',
|
||||
name: 'golang.org/x/net',
|
||||
version: '0.15.0',
|
||||
fixedVersion: '0.17.0',
|
||||
assetIds: ['asset-api-prod', 'asset-worker-prod'],
|
||||
},
|
||||
{
|
||||
purl: 'pkg:npm/nghttp2@1.55.0',
|
||||
name: 'nghttp2',
|
||||
version: '1.55.0',
|
||||
fixedVersion: '1.57.0',
|
||||
assetIds: ['asset-web-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-004',
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'runc container escape vulnerability',
|
||||
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
|
||||
severity: 'high',
|
||||
cvssScore: 8.6,
|
||||
cvssVector: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H',
|
||||
status: 'fixed',
|
||||
publishedAt: '2024-01-31T00:00:00Z',
|
||||
modifiedAt: '2024-09-13T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/github.com/opencontainers/runc@1.1.10',
|
||||
name: 'runc',
|
||||
version: '1.1.10',
|
||||
fixedVersion: '1.1.12',
|
||||
assetIds: ['asset-builder-001'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-005',
|
||||
cveId: 'CVE-2023-38545',
|
||||
title: 'curl SOCKS5 heap buffer overflow',
|
||||
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
|
||||
severity: 'high',
|
||||
cvssScore: 9.8,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
|
||||
status: 'open',
|
||||
publishedAt: '2023-10-11T00:00:00Z',
|
||||
modifiedAt: '2024-06-10T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:deb/debian/curl@7.88.1-10',
|
||||
name: 'curl',
|
||||
version: '7.88.1-10',
|
||||
fixedVersion: '8.4.0',
|
||||
assetIds: ['asset-web-prod', 'asset-api-prod', 'asset-worker-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-006',
|
||||
cveId: 'CVE-2022-22965',
|
||||
title: 'Spring4Shell - Spring Framework RCE',
|
||||
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
|
||||
severity: 'critical',
|
||||
cvssScore: 9.8,
|
||||
status: 'wont_fix',
|
||||
publishedAt: '2022-03-31T00:00:00Z',
|
||||
modifiedAt: '2024-08-20T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.springframework/spring-beans@5.3.17',
|
||||
name: 'spring-beans',
|
||||
version: '5.3.17',
|
||||
fixedVersion: '5.3.18',
|
||||
assetIds: ['asset-legacy-001'],
|
||||
},
|
||||
],
|
||||
hasException: true,
|
||||
exceptionId: 'exc-legacy-spring',
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-007',
|
||||
cveId: 'CVE-2023-45853',
|
||||
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
|
||||
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
|
||||
severity: 'medium',
|
||||
cvssScore: 5.3,
|
||||
status: 'open',
|
||||
publishedAt: '2023-10-14T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:deb/debian/zlib@1.2.13',
|
||||
name: 'zlib',
|
||||
version: '1.2.13',
|
||||
fixedVersion: '1.3.1',
|
||||
assetIds: ['asset-web-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-008',
|
||||
cveId: 'CVE-2024-0567',
|
||||
title: 'GnuTLS certificate verification bypass',
|
||||
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
|
||||
severity: 'medium',
|
||||
cvssScore: 5.9,
|
||||
status: 'open',
|
||||
publishedAt: '2024-01-16T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:rpm/fedora/gnutls@3.8.2',
|
||||
name: 'gnutls',
|
||||
version: '3.8.2',
|
||||
fixedVersion: '3.8.3',
|
||||
assetIds: ['asset-internal-001'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-009',
|
||||
cveId: 'CVE-2023-5363',
|
||||
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
|
||||
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
|
||||
severity: 'low',
|
||||
cvssScore: 3.7,
|
||||
status: 'fixed',
|
||||
publishedAt: '2023-10-24T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:nuget/System.Security.Cryptography.Pkcs@7.0.2',
|
||||
name: 'System.Security.Cryptography.Pkcs',
|
||||
version: '7.0.2',
|
||||
fixedVersion: '8.0.0',
|
||||
assetIds: ['asset-api-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-010',
|
||||
cveId: 'CVE-2024-24790',
|
||||
title: 'Go net/netip ParseAddr stack exhaustion',
|
||||
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
|
||||
severity: 'low',
|
||||
cvssScore: 4.0,
|
||||
status: 'open',
|
||||
publishedAt: '2024-06-05T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/stdlib@1.21.10',
|
||||
name: 'go stdlib',
|
||||
version: '1.21.10',
|
||||
fixedVersion: '1.21.11',
|
||||
assetIds: ['asset-api-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
{
|
||||
vulnId: 'vuln-001',
|
||||
cveId: 'CVE-2021-44228',
|
||||
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
|
||||
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severity: 'critical',
|
||||
cvssScore: 10.0,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||
status: 'open',
|
||||
publishedAt: '2021-12-10T00:00:00Z',
|
||||
modifiedAt: '2024-06-27T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
|
||||
name: 'log4j-core',
|
||||
version: '2.14.1',
|
||||
fixedVersion: '2.17.1',
|
||||
assetIds: ['asset-web-prod', 'asset-api-prod'],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||
'https://logging.apache.org/log4j/2.x/security.html',
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-002',
|
||||
cveId: 'CVE-2021-45046',
|
||||
title: 'Log4j2 Thread Context Message Pattern DoS',
|
||||
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
|
||||
severity: 'critical',
|
||||
cvssScore: 9.0,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||
status: 'excepted',
|
||||
publishedAt: '2021-12-14T00:00:00Z',
|
||||
modifiedAt: '2023-11-06T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0',
|
||||
name: 'log4j-core',
|
||||
version: '2.15.0',
|
||||
fixedVersion: '2.17.1',
|
||||
assetIds: ['asset-internal-001'],
|
||||
},
|
||||
],
|
||||
hasException: true,
|
||||
exceptionId: 'exc-test-001',
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-003',
|
||||
cveId: 'CVE-2023-44487',
|
||||
title: 'HTTP/2 Rapid Reset Attack',
|
||||
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
|
||||
severity: 'high',
|
||||
cvssScore: 7.5,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H',
|
||||
status: 'in_progress',
|
||||
publishedAt: '2023-10-10T00:00:00Z',
|
||||
modifiedAt: '2024-05-01T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/golang.org/x/net@0.15.0',
|
||||
name: 'golang.org/x/net',
|
||||
version: '0.15.0',
|
||||
fixedVersion: '0.17.0',
|
||||
assetIds: ['asset-api-prod', 'asset-worker-prod'],
|
||||
},
|
||||
{
|
||||
purl: 'pkg:npm/nghttp2@1.55.0',
|
||||
name: 'nghttp2',
|
||||
version: '1.55.0',
|
||||
fixedVersion: '1.57.0',
|
||||
assetIds: ['asset-web-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-004',
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'runc container escape vulnerability',
|
||||
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
|
||||
severity: 'high',
|
||||
cvssScore: 8.6,
|
||||
cvssVector: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H',
|
||||
status: 'fixed',
|
||||
publishedAt: '2024-01-31T00:00:00Z',
|
||||
modifiedAt: '2024-09-13T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/github.com/opencontainers/runc@1.1.10',
|
||||
name: 'runc',
|
||||
version: '1.1.10',
|
||||
fixedVersion: '1.1.12',
|
||||
assetIds: ['asset-builder-001'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-005',
|
||||
cveId: 'CVE-2023-38545',
|
||||
title: 'curl SOCKS5 heap buffer overflow',
|
||||
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
|
||||
severity: 'high',
|
||||
cvssScore: 9.8,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
|
||||
status: 'open',
|
||||
publishedAt: '2023-10-11T00:00:00Z',
|
||||
modifiedAt: '2024-06-10T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:deb/debian/curl@7.88.1-10',
|
||||
name: 'curl',
|
||||
version: '7.88.1-10',
|
||||
fixedVersion: '8.4.0',
|
||||
assetIds: ['asset-web-prod', 'asset-api-prod', 'asset-worker-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-006',
|
||||
cveId: 'CVE-2022-22965',
|
||||
title: 'Spring4Shell - Spring Framework RCE',
|
||||
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
|
||||
severity: 'critical',
|
||||
cvssScore: 9.8,
|
||||
status: 'wont_fix',
|
||||
publishedAt: '2022-03-31T00:00:00Z',
|
||||
modifiedAt: '2024-08-20T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.springframework/spring-beans@5.3.17',
|
||||
name: 'spring-beans',
|
||||
version: '5.3.17',
|
||||
fixedVersion: '5.3.18',
|
||||
assetIds: ['asset-legacy-001'],
|
||||
},
|
||||
],
|
||||
hasException: true,
|
||||
exceptionId: 'exc-legacy-spring',
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-007',
|
||||
cveId: 'CVE-2023-45853',
|
||||
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
|
||||
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
|
||||
severity: 'medium',
|
||||
cvssScore: 5.3,
|
||||
status: 'open',
|
||||
publishedAt: '2023-10-14T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:deb/debian/zlib@1.2.13',
|
||||
name: 'zlib',
|
||||
version: '1.2.13',
|
||||
fixedVersion: '1.3.1',
|
||||
assetIds: ['asset-web-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-008',
|
||||
cveId: 'CVE-2024-0567',
|
||||
title: 'GnuTLS certificate verification bypass',
|
||||
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
|
||||
severity: 'medium',
|
||||
cvssScore: 5.9,
|
||||
status: 'open',
|
||||
publishedAt: '2024-01-16T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:rpm/fedora/gnutls@3.8.2',
|
||||
name: 'gnutls',
|
||||
version: '3.8.2',
|
||||
fixedVersion: '3.8.3',
|
||||
assetIds: ['asset-internal-001'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-009',
|
||||
cveId: 'CVE-2023-5363',
|
||||
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
|
||||
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
|
||||
severity: 'low',
|
||||
cvssScore: 3.7,
|
||||
status: 'fixed',
|
||||
publishedAt: '2023-10-24T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:nuget/System.Security.Cryptography.Pkcs@7.0.2',
|
||||
name: 'System.Security.Cryptography.Pkcs',
|
||||
version: '7.0.2',
|
||||
fixedVersion: '8.0.0',
|
||||
assetIds: ['asset-api-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-010',
|
||||
cveId: 'CVE-2024-24790',
|
||||
title: 'Go net/netip ParseAddr stack exhaustion',
|
||||
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
|
||||
severity: 'low',
|
||||
cvssScore: 4.0,
|
||||
status: 'open',
|
||||
publishedAt: '2024-06-05T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/stdlib@1.21.10',
|
||||
name: 'go stdlib',
|
||||
version: '1.21.10',
|
||||
fixedVersion: '1.21.11',
|
||||
assetIds: ['asset-api-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -302,19 +302,19 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
|
||||
|
||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
|
||||
let items = [...MOCK_VULNERABILITIES];
|
||||
|
||||
if (options?.severity && options.severity !== 'all') {
|
||||
items = items.filter((v) => v.severity === options.severity);
|
||||
}
|
||||
|
||||
if (options?.status && options.status !== 'all') {
|
||||
items = items.filter((v) => v.status === options.status);
|
||||
}
|
||||
|
||||
if (options?.hasException !== undefined) {
|
||||
items = items.filter((v) => v.hasException === options.hasException);
|
||||
}
|
||||
|
||||
|
||||
if (options?.severity && options.severity !== 'all') {
|
||||
items = items.filter((v) => v.severity === options.severity);
|
||||
}
|
||||
|
||||
if (options?.status && options.status !== 'all') {
|
||||
items = items.filter((v) => v.status === options.status);
|
||||
}
|
||||
|
||||
if (options?.hasException !== undefined) {
|
||||
items = items.filter((v) => v.hasException === options.hasException);
|
||||
}
|
||||
|
||||
if (options?.search) {
|
||||
const search = options.search.toLowerCase();
|
||||
items = items.filter(
|
||||
@@ -356,25 +356,25 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
|
||||
etag: `"vuln-${vulnId}-etag"`,
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
|
||||
|
||||
getStats(_options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats> {
|
||||
const vulns = MOCK_VULNERABILITIES;
|
||||
const stats: VulnerabilityStats = {
|
||||
total: vulns.length,
|
||||
bySeverity: {
|
||||
critical: vulns.filter((v) => v.severity === 'critical').length,
|
||||
high: vulns.filter((v) => v.severity === 'high').length,
|
||||
medium: vulns.filter((v) => v.severity === 'medium').length,
|
||||
low: vulns.filter((v) => v.severity === 'low').length,
|
||||
unknown: vulns.filter((v) => v.severity === 'unknown').length,
|
||||
},
|
||||
byStatus: {
|
||||
open: vulns.filter((v) => v.status === 'open').length,
|
||||
fixed: vulns.filter((v) => v.status === 'fixed').length,
|
||||
wont_fix: vulns.filter((v) => v.status === 'wont_fix').length,
|
||||
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
|
||||
excepted: vulns.filter((v) => v.status === 'excepted').length,
|
||||
},
|
||||
total: vulns.length,
|
||||
bySeverity: {
|
||||
critical: vulns.filter((v) => v.severity === 'critical').length,
|
||||
high: vulns.filter((v) => v.severity === 'high').length,
|
||||
medium: vulns.filter((v) => v.severity === 'medium').length,
|
||||
low: vulns.filter((v) => v.severity === 'low').length,
|
||||
unknown: vulns.filter((v) => v.severity === 'unknown').length,
|
||||
},
|
||||
byStatus: {
|
||||
open: vulns.filter((v) => v.status === 'open').length,
|
||||
fixed: vulns.filter((v) => v.status === 'fixed').length,
|
||||
wont_fix: vulns.filter((v) => v.status === 'wont_fix').length,
|
||||
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
|
||||
excepted: vulns.filter((v) => v.status === 'excepted').length,
|
||||
},
|
||||
withExceptions: vulns.filter((v) => v.hasException).length,
|
||||
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
|
||||
computedAt: MockVulnerabilityApiService.FixedNowIso,
|
||||
@@ -409,24 +409,24 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
|
||||
fileSize: 1024 * (request.includeComponents ? 50 : 20),
|
||||
traceId,
|
||||
};
|
||||
|
||||
this.mockExports.set(exportId, exportResponse);
|
||||
return of(exportResponse).pipe(delay(500));
|
||||
}
|
||||
|
||||
|
||||
this.mockExports.set(exportId, exportResponse);
|
||||
return of(exportResponse).pipe(delay(500));
|
||||
}
|
||||
|
||||
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse> {
|
||||
const traceId = options?.traceId ?? 'mock-trace-vuln-export-status';
|
||||
const existing = this.mockExports.get(exportId);
|
||||
|
||||
if (existing) {
|
||||
return of(existing).pipe(delay(100));
|
||||
}
|
||||
|
||||
return of({
|
||||
exportId,
|
||||
status: 'failed' as const,
|
||||
traceId,
|
||||
error: { code: 'ERR_EXPORT_NOT_FOUND', message: 'Export not found' },
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return of({
|
||||
exportId,
|
||||
status: 'failed' as const,
|
||||
traceId,
|
||||
error: { code: 'ERR_EXPORT_NOT_FOUND', message: 'Export not found' },
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,208 +1,208 @@
|
||||
export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
|
||||
export type VulnerabilityStatus = 'open' | 'fixed' | 'wont_fix' | 'in_progress' | 'excepted';
|
||||
|
||||
/**
|
||||
* Workflow action types for vulnerability lifecycle.
|
||||
*/
|
||||
export type VulnWorkflowAction = 'open' | 'ack' | 'close' | 'reopen' | 'export';
|
||||
|
||||
/**
|
||||
* Actor types for workflow actions.
|
||||
*/
|
||||
export type VulnActorType = 'user' | 'service' | 'automation';
|
||||
|
||||
export interface Vulnerability {
|
||||
readonly vulnId: string;
|
||||
readonly cveId: string;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly severity: VulnerabilitySeverity;
|
||||
readonly cvssScore?: number;
|
||||
readonly cvssVector?: string;
|
||||
readonly status: VulnerabilityStatus;
|
||||
readonly publishedAt?: string;
|
||||
readonly modifiedAt?: string;
|
||||
readonly affectedComponents: readonly AffectedComponent[];
|
||||
readonly references?: readonly string[];
|
||||
readonly hasException?: boolean;
|
||||
readonly exceptionId?: string;
|
||||
/** ETag for optimistic concurrency. */
|
||||
readonly etag?: string;
|
||||
/** Reachability score from signals integration. */
|
||||
readonly reachabilityScore?: number;
|
||||
/** Reachability status from signals. */
|
||||
readonly reachabilityStatus?: 'reachable' | 'unreachable' | 'unknown';
|
||||
}
|
||||
|
||||
export interface AffectedComponent {
|
||||
readonly purl: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly fixedVersion?: string;
|
||||
readonly assetIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface VulnerabilityStats {
|
||||
readonly total: number;
|
||||
readonly bySeverity: Record<VulnerabilitySeverity, number>;
|
||||
readonly byStatus: Record<VulnerabilityStatus, number>;
|
||||
readonly withExceptions: number;
|
||||
readonly criticalOpen: number;
|
||||
/** Last computation timestamp. */
|
||||
readonly computedAt?: string;
|
||||
/** Trace ID for the stats computation. */
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
export interface VulnerabilitiesQueryOptions {
|
||||
readonly severity?: VulnerabilitySeverity | 'all';
|
||||
readonly status?: VulnerabilityStatus | 'all';
|
||||
readonly search?: string;
|
||||
readonly hasException?: boolean;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
readonly page?: number;
|
||||
readonly pageSize?: number;
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly traceId?: string;
|
||||
/** Filter by reachability status. */
|
||||
readonly reachability?: 'reachable' | 'unreachable' | 'unknown' | 'all';
|
||||
/** Include reachability data in response. */
|
||||
readonly includeReachability?: boolean;
|
||||
}
|
||||
|
||||
export interface VulnerabilitiesResponse {
|
||||
readonly items: readonly Vulnerability[];
|
||||
readonly total: number;
|
||||
readonly hasMore?: boolean;
|
||||
readonly page?: number;
|
||||
readonly pageSize?: number;
|
||||
/** ETag for the response. */
|
||||
readonly etag?: string;
|
||||
/** Trace ID for the request. */
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow action request for Findings Ledger integration.
|
||||
* Implements WEB-VULN-29-002 contract.
|
||||
*/
|
||||
export interface VulnWorkflowRequest {
|
||||
/** Workflow action type. */
|
||||
readonly action: VulnWorkflowAction;
|
||||
/** Finding/vulnerability ID. */
|
||||
readonly findingId: string;
|
||||
/** Reason code for the action. */
|
||||
readonly reasonCode?: string;
|
||||
/** Optional comment. */
|
||||
readonly comment?: string;
|
||||
/** Attachments for the action. */
|
||||
readonly attachments?: readonly VulnWorkflowAttachment[];
|
||||
/** Actor performing the action. */
|
||||
readonly actor: VulnWorkflowActor;
|
||||
/** Additional metadata. */
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment for workflow actions.
|
||||
*/
|
||||
export interface VulnWorkflowAttachment {
|
||||
readonly name: string;
|
||||
readonly digest: string;
|
||||
readonly contentType?: string;
|
||||
readonly size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actor for workflow actions.
|
||||
*/
|
||||
export interface VulnWorkflowActor {
|
||||
readonly subject: string;
|
||||
readonly type: VulnActorType;
|
||||
readonly name?: string;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow action response from Findings Ledger.
|
||||
*/
|
||||
export interface VulnWorkflowResponse {
|
||||
/** Action status. */
|
||||
readonly status: 'accepted' | 'rejected' | 'pending';
|
||||
/** Ledger event ID for correlation. */
|
||||
readonly ledgerEventId: string;
|
||||
/** ETag for optimistic concurrency. */
|
||||
readonly etag: string;
|
||||
/** Trace ID for the request. */
|
||||
readonly traceId: string;
|
||||
/** Correlation ID. */
|
||||
readonly correlationId: string;
|
||||
/** Error details if rejected. */
|
||||
readonly error?: VulnWorkflowError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow error response.
|
||||
*/
|
||||
export interface VulnWorkflowError {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export request for vulnerability data.
|
||||
*/
|
||||
export interface VulnExportRequest {
|
||||
/** Format for export. */
|
||||
readonly format: 'csv' | 'json' | 'cyclonedx' | 'spdx';
|
||||
/** Filter options. */
|
||||
readonly filter?: VulnerabilitiesQueryOptions;
|
||||
/** Include affected components. */
|
||||
readonly includeComponents?: boolean;
|
||||
/** Include reachability data. */
|
||||
readonly includeReachability?: boolean;
|
||||
/** Maximum records (for large exports). */
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export response with signed download URL.
|
||||
*/
|
||||
export interface VulnExportResponse {
|
||||
/** Export job ID. */
|
||||
readonly exportId: string;
|
||||
/** Current status. */
|
||||
readonly status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
/** Signed download URL (when completed). */
|
||||
readonly downloadUrl?: string;
|
||||
/** URL expiration timestamp. */
|
||||
readonly expiresAt?: string;
|
||||
/** Record count. */
|
||||
readonly recordCount?: number;
|
||||
/** File size in bytes. */
|
||||
readonly fileSize?: number;
|
||||
/** Trace ID. */
|
||||
readonly traceId: string;
|
||||
/** Error if failed. */
|
||||
readonly error?: VulnWorkflowError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request logging metadata for observability.
|
||||
*/
|
||||
export interface VulnRequestLog {
|
||||
readonly requestId: string;
|
||||
readonly traceId: string;
|
||||
readonly tenantId: string;
|
||||
readonly projectId?: string;
|
||||
readonly operation: string;
|
||||
readonly path: string;
|
||||
readonly method: string;
|
||||
readonly timestamp: string;
|
||||
readonly durationMs?: number;
|
||||
readonly statusCode?: number;
|
||||
readonly error?: string;
|
||||
}
|
||||
export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
|
||||
export type VulnerabilityStatus = 'open' | 'fixed' | 'wont_fix' | 'in_progress' | 'excepted';
|
||||
|
||||
/**
|
||||
* Workflow action types for vulnerability lifecycle.
|
||||
*/
|
||||
export type VulnWorkflowAction = 'open' | 'ack' | 'close' | 'reopen' | 'export';
|
||||
|
||||
/**
|
||||
* Actor types for workflow actions.
|
||||
*/
|
||||
export type VulnActorType = 'user' | 'service' | 'automation';
|
||||
|
||||
export interface Vulnerability {
|
||||
readonly vulnId: string;
|
||||
readonly cveId: string;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly severity: VulnerabilitySeverity;
|
||||
readonly cvssScore?: number;
|
||||
readonly cvssVector?: string;
|
||||
readonly status: VulnerabilityStatus;
|
||||
readonly publishedAt?: string;
|
||||
readonly modifiedAt?: string;
|
||||
readonly affectedComponents: readonly AffectedComponent[];
|
||||
readonly references?: readonly string[];
|
||||
readonly hasException?: boolean;
|
||||
readonly exceptionId?: string;
|
||||
/** ETag for optimistic concurrency. */
|
||||
readonly etag?: string;
|
||||
/** Reachability score from signals integration. */
|
||||
readonly reachabilityScore?: number;
|
||||
/** Reachability status from signals. */
|
||||
readonly reachabilityStatus?: 'reachable' | 'unreachable' | 'unknown';
|
||||
}
|
||||
|
||||
export interface AffectedComponent {
|
||||
readonly purl: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly fixedVersion?: string;
|
||||
readonly assetIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface VulnerabilityStats {
|
||||
readonly total: number;
|
||||
readonly bySeverity: Record<VulnerabilitySeverity, number>;
|
||||
readonly byStatus: Record<VulnerabilityStatus, number>;
|
||||
readonly withExceptions: number;
|
||||
readonly criticalOpen: number;
|
||||
/** Last computation timestamp. */
|
||||
readonly computedAt?: string;
|
||||
/** Trace ID for the stats computation. */
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
export interface VulnerabilitiesQueryOptions {
|
||||
readonly severity?: VulnerabilitySeverity | 'all';
|
||||
readonly status?: VulnerabilityStatus | 'all';
|
||||
readonly search?: string;
|
||||
readonly hasException?: boolean;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
readonly page?: number;
|
||||
readonly pageSize?: number;
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly traceId?: string;
|
||||
/** Filter by reachability status. */
|
||||
readonly reachability?: 'reachable' | 'unreachable' | 'unknown' | 'all';
|
||||
/** Include reachability data in response. */
|
||||
readonly includeReachability?: boolean;
|
||||
}
|
||||
|
||||
export interface VulnerabilitiesResponse {
|
||||
readonly items: readonly Vulnerability[];
|
||||
readonly total: number;
|
||||
readonly hasMore?: boolean;
|
||||
readonly page?: number;
|
||||
readonly pageSize?: number;
|
||||
/** ETag for the response. */
|
||||
readonly etag?: string;
|
||||
/** Trace ID for the request. */
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow action request for Findings Ledger integration.
|
||||
* Implements WEB-VULN-29-002 contract.
|
||||
*/
|
||||
export interface VulnWorkflowRequest {
|
||||
/** Workflow action type. */
|
||||
readonly action: VulnWorkflowAction;
|
||||
/** Finding/vulnerability ID. */
|
||||
readonly findingId: string;
|
||||
/** Reason code for the action. */
|
||||
readonly reasonCode?: string;
|
||||
/** Optional comment. */
|
||||
readonly comment?: string;
|
||||
/** Attachments for the action. */
|
||||
readonly attachments?: readonly VulnWorkflowAttachment[];
|
||||
/** Actor performing the action. */
|
||||
readonly actor: VulnWorkflowActor;
|
||||
/** Additional metadata. */
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment for workflow actions.
|
||||
*/
|
||||
export interface VulnWorkflowAttachment {
|
||||
readonly name: string;
|
||||
readonly digest: string;
|
||||
readonly contentType?: string;
|
||||
readonly size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actor for workflow actions.
|
||||
*/
|
||||
export interface VulnWorkflowActor {
|
||||
readonly subject: string;
|
||||
readonly type: VulnActorType;
|
||||
readonly name?: string;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow action response from Findings Ledger.
|
||||
*/
|
||||
export interface VulnWorkflowResponse {
|
||||
/** Action status. */
|
||||
readonly status: 'accepted' | 'rejected' | 'pending';
|
||||
/** Ledger event ID for correlation. */
|
||||
readonly ledgerEventId: string;
|
||||
/** ETag for optimistic concurrency. */
|
||||
readonly etag: string;
|
||||
/** Trace ID for the request. */
|
||||
readonly traceId: string;
|
||||
/** Correlation ID. */
|
||||
readonly correlationId: string;
|
||||
/** Error details if rejected. */
|
||||
readonly error?: VulnWorkflowError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow error response.
|
||||
*/
|
||||
export interface VulnWorkflowError {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export request for vulnerability data.
|
||||
*/
|
||||
export interface VulnExportRequest {
|
||||
/** Format for export. */
|
||||
readonly format: 'csv' | 'json' | 'cyclonedx' | 'spdx';
|
||||
/** Filter options. */
|
||||
readonly filter?: VulnerabilitiesQueryOptions;
|
||||
/** Include affected components. */
|
||||
readonly includeComponents?: boolean;
|
||||
/** Include reachability data. */
|
||||
readonly includeReachability?: boolean;
|
||||
/** Maximum records (for large exports). */
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export response with signed download URL.
|
||||
*/
|
||||
export interface VulnExportResponse {
|
||||
/** Export job ID. */
|
||||
readonly exportId: string;
|
||||
/** Current status. */
|
||||
readonly status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
/** Signed download URL (when completed). */
|
||||
readonly downloadUrl?: string;
|
||||
/** URL expiration timestamp. */
|
||||
readonly expiresAt?: string;
|
||||
/** Record count. */
|
||||
readonly recordCount?: number;
|
||||
/** File size in bytes. */
|
||||
readonly fileSize?: number;
|
||||
/** Trace ID. */
|
||||
readonly traceId: string;
|
||||
/** Error if failed. */
|
||||
readonly error?: VulnWorkflowError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request logging metadata for observability.
|
||||
*/
|
||||
export interface VulnRequestLog {
|
||||
readonly requestId: string;
|
||||
readonly traceId: string;
|
||||
readonly tenantId: string;
|
||||
readonly projectId?: string;
|
||||
readonly operation: string;
|
||||
readonly path: string;
|
||||
readonly method: string;
|
||||
readonly timestamp: string;
|
||||
readonly durationMs?: number;
|
||||
readonly statusCode?: number;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export const STEP_TYPES: StepTypeDefinition[] = [
|
||||
label: 'Script',
|
||||
description: 'Execute a custom script or command',
|
||||
icon: 'code',
|
||||
color: '#6366f1',
|
||||
color: '#D4920A',
|
||||
defaultConfig: { command: '', timeout: 300 },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,171 +1,171 @@
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, firstValueFrom, from, throwError } from 'rxjs';
|
||||
import { catchError, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { DpopService } from './dpop/dpop.service';
|
||||
import { AuthorityAuthService } from './authority-auth.service';
|
||||
|
||||
const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
|
||||
|
||||
@Injectable()
|
||||
export class AuthHttpInterceptor implements HttpInterceptor {
|
||||
private excludedOrigins: Set<string> | null = null;
|
||||
private tokenEndpoint: string | null = null;
|
||||
private authorityResolved = false;
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthorityAuthService,
|
||||
private readonly config: AppConfigService,
|
||||
private readonly dpop: DpopService
|
||||
) {
|
||||
// lazy resolve authority configuration in intercept to allow APP_INITIALIZER to run first
|
||||
}
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
this.ensureAuthorityInfo();
|
||||
|
||||
if (request.headers.has('Authorization') || this.shouldSkip(request.url)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
return from(
|
||||
this.auth.getAuthHeadersForRequest(
|
||||
this.resolveAbsoluteUrl(request.url),
|
||||
request.method
|
||||
)
|
||||
).pipe(
|
||||
switchMap((headers) => {
|
||||
if (!headers) {
|
||||
return next.handle(request);
|
||||
}
|
||||
const authorizedRequest = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: headers.authorization,
|
||||
DPoP: headers.dpop,
|
||||
},
|
||||
headers: request.headers.set(RETRY_HEADER, '0'),
|
||||
});
|
||||
return next.handle(authorizedRequest);
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) =>
|
||||
this.handleError(request, error, next)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private handleError(
|
||||
request: HttpRequest<unknown>,
|
||||
error: HttpErrorResponse,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
if (error.status !== 401) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
const nonce = error.headers?.get('DPoP-Nonce');
|
||||
if (!nonce) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
if (request.headers.get(RETRY_HEADER) === '1') {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
return from(this.retryWithNonce(request, nonce, next)).pipe(
|
||||
catchError(() => throwError(() => error))
|
||||
);
|
||||
}
|
||||
|
||||
private async retryWithNonce(
|
||||
request: HttpRequest<unknown>,
|
||||
nonce: string,
|
||||
next: HttpHandler
|
||||
): Promise<HttpEvent<unknown>> {
|
||||
await this.dpop.setNonce(nonce);
|
||||
const headers = await this.auth.getAuthHeadersForRequest(
|
||||
this.resolveAbsoluteUrl(request.url),
|
||||
request.method
|
||||
);
|
||||
if (!headers) {
|
||||
throw new Error('Unable to refresh authorization headers after nonce.');
|
||||
}
|
||||
|
||||
const retried = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: headers.authorization,
|
||||
DPoP: headers.dpop,
|
||||
},
|
||||
headers: request.headers.set(RETRY_HEADER, '1'),
|
||||
});
|
||||
|
||||
return firstValueFrom(next.handle(retried));
|
||||
}
|
||||
|
||||
private shouldSkip(url: string): boolean {
|
||||
this.ensureAuthorityInfo();
|
||||
const absolute = this.resolveAbsoluteUrl(url);
|
||||
if (!absolute) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = new URL(absolute);
|
||||
if (resolved.pathname.endsWith('/config.json')) {
|
||||
return true;
|
||||
}
|
||||
if (this.tokenEndpoint && absolute.startsWith(this.tokenEndpoint)) {
|
||||
return true;
|
||||
}
|
||||
const origin = resolved.origin;
|
||||
return this.excludedOrigins?.has(origin) ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAbsoluteUrl(url: string): string {
|
||||
try {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
const base =
|
||||
typeof window !== 'undefined' && window.location
|
||||
? window.location.origin
|
||||
: undefined;
|
||||
return base ? new URL(url, base).toString() : url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAuthorityInfo(): void {
|
||||
if (this.authorityResolved) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const authority = this.config.authority;
|
||||
this.tokenEndpoint = new URL(
|
||||
authority.tokenEndpoint,
|
||||
authority.issuer
|
||||
).toString();
|
||||
this.excludedOrigins = new Set<string>([
|
||||
this.tokenEndpoint,
|
||||
new URL(authority.authorizeEndpoint, authority.issuer).origin,
|
||||
]);
|
||||
this.authorityResolved = true;
|
||||
} catch {
|
||||
// Configuration not yet loaded; interceptor will retry on the next request.
|
||||
}
|
||||
}
|
||||
}
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, firstValueFrom, from, throwError } from 'rxjs';
|
||||
import { catchError, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { DpopService } from './dpop/dpop.service';
|
||||
import { AuthorityAuthService } from './authority-auth.service';
|
||||
|
||||
const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
|
||||
|
||||
@Injectable()
|
||||
export class AuthHttpInterceptor implements HttpInterceptor {
|
||||
private excludedOrigins: Set<string> | null = null;
|
||||
private tokenEndpoint: string | null = null;
|
||||
private authorityResolved = false;
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthorityAuthService,
|
||||
private readonly config: AppConfigService,
|
||||
private readonly dpop: DpopService
|
||||
) {
|
||||
// lazy resolve authority configuration in intercept to allow APP_INITIALIZER to run first
|
||||
}
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
this.ensureAuthorityInfo();
|
||||
|
||||
if (request.headers.has('Authorization') || this.shouldSkip(request.url)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
return from(
|
||||
this.auth.getAuthHeadersForRequest(
|
||||
this.resolveAbsoluteUrl(request.url),
|
||||
request.method
|
||||
)
|
||||
).pipe(
|
||||
switchMap((headers) => {
|
||||
if (!headers) {
|
||||
return next.handle(request);
|
||||
}
|
||||
const authorizedRequest = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: headers.authorization,
|
||||
DPoP: headers.dpop,
|
||||
},
|
||||
headers: request.headers.set(RETRY_HEADER, '0'),
|
||||
});
|
||||
return next.handle(authorizedRequest);
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) =>
|
||||
this.handleError(request, error, next)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private handleError(
|
||||
request: HttpRequest<unknown>,
|
||||
error: HttpErrorResponse,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
if (error.status !== 401) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
const nonce = error.headers?.get('DPoP-Nonce');
|
||||
if (!nonce) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
if (request.headers.get(RETRY_HEADER) === '1') {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
return from(this.retryWithNonce(request, nonce, next)).pipe(
|
||||
catchError(() => throwError(() => error))
|
||||
);
|
||||
}
|
||||
|
||||
private async retryWithNonce(
|
||||
request: HttpRequest<unknown>,
|
||||
nonce: string,
|
||||
next: HttpHandler
|
||||
): Promise<HttpEvent<unknown>> {
|
||||
await this.dpop.setNonce(nonce);
|
||||
const headers = await this.auth.getAuthHeadersForRequest(
|
||||
this.resolveAbsoluteUrl(request.url),
|
||||
request.method
|
||||
);
|
||||
if (!headers) {
|
||||
throw new Error('Unable to refresh authorization headers after nonce.');
|
||||
}
|
||||
|
||||
const retried = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: headers.authorization,
|
||||
DPoP: headers.dpop,
|
||||
},
|
||||
headers: request.headers.set(RETRY_HEADER, '1'),
|
||||
});
|
||||
|
||||
return firstValueFrom(next.handle(retried));
|
||||
}
|
||||
|
||||
private shouldSkip(url: string): boolean {
|
||||
this.ensureAuthorityInfo();
|
||||
const absolute = this.resolveAbsoluteUrl(url);
|
||||
if (!absolute) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = new URL(absolute);
|
||||
if (resolved.pathname.endsWith('/config.json')) {
|
||||
return true;
|
||||
}
|
||||
if (this.tokenEndpoint && absolute.startsWith(this.tokenEndpoint)) {
|
||||
return true;
|
||||
}
|
||||
const origin = resolved.origin;
|
||||
return this.excludedOrigins?.has(origin) ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAbsoluteUrl(url: string): string {
|
||||
try {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
const base =
|
||||
typeof window !== 'undefined' && window.location
|
||||
? window.location.origin
|
||||
: undefined;
|
||||
return base ? new URL(url, base).toString() : url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAuthorityInfo(): void {
|
||||
if (this.authorityResolved) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const authority = this.config.authority;
|
||||
this.tokenEndpoint = new URL(
|
||||
authority.tokenEndpoint,
|
||||
authority.issuer
|
||||
).toString();
|
||||
this.excludedOrigins = new Set<string>([
|
||||
this.tokenEndpoint,
|
||||
new URL(authority.authorizeEndpoint, authority.issuer).origin,
|
||||
]);
|
||||
this.authorityResolved = true;
|
||||
} catch {
|
||||
// Configuration not yet loaded; interceptor will retry on the next request.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
export interface AuthTokens {
|
||||
readonly accessToken: string;
|
||||
readonly expiresAtEpochMs: number;
|
||||
readonly refreshToken?: string;
|
||||
readonly tokenType: 'Bearer';
|
||||
readonly scope: string;
|
||||
}
|
||||
|
||||
export interface AuthIdentity {
|
||||
readonly subject: string;
|
||||
readonly name?: string;
|
||||
readonly email?: string;
|
||||
readonly roles: readonly string[];
|
||||
readonly idToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
readonly tokens: AuthTokens;
|
||||
readonly identity: AuthIdentity;
|
||||
/**
|
||||
* SHA-256 JWK thumbprint of the active DPoP key pair.
|
||||
*/
|
||||
readonly dpopKeyThumbprint: string;
|
||||
readonly issuedAtEpochMs: number;
|
||||
readonly tenantId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationTimeEpochMs: number | null;
|
||||
readonly freshAuthActive: boolean;
|
||||
readonly freshAuthExpiresAtEpochMs: number | null;
|
||||
}
|
||||
|
||||
export interface PersistedSessionMetadata {
|
||||
readonly subject: string;
|
||||
readonly expiresAtEpochMs: number;
|
||||
readonly issuedAtEpochMs: number;
|
||||
readonly dpopKeyThumbprint: string;
|
||||
readonly tenantId?: string | null;
|
||||
}
|
||||
|
||||
export type AuthStatus =
|
||||
| 'unauthenticated'
|
||||
| 'authenticated'
|
||||
| 'refreshing'
|
||||
| 'loading';
|
||||
|
||||
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
|
||||
|
||||
export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
|
||||
|
||||
export type AuthErrorReason =
|
||||
| 'invalid_state'
|
||||
| 'token_exchange_failed'
|
||||
| 'refresh_failed'
|
||||
| 'dpop_generation_failed'
|
||||
| 'configuration_missing';
|
||||
export interface AuthTokens {
|
||||
readonly accessToken: string;
|
||||
readonly expiresAtEpochMs: number;
|
||||
readonly refreshToken?: string;
|
||||
readonly tokenType: 'Bearer';
|
||||
readonly scope: string;
|
||||
}
|
||||
|
||||
export interface AuthIdentity {
|
||||
readonly subject: string;
|
||||
readonly name?: string;
|
||||
readonly email?: string;
|
||||
readonly roles: readonly string[];
|
||||
readonly idToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
readonly tokens: AuthTokens;
|
||||
readonly identity: AuthIdentity;
|
||||
/**
|
||||
* SHA-256 JWK thumbprint of the active DPoP key pair.
|
||||
*/
|
||||
readonly dpopKeyThumbprint: string;
|
||||
readonly issuedAtEpochMs: number;
|
||||
readonly tenantId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationTimeEpochMs: number | null;
|
||||
readonly freshAuthActive: boolean;
|
||||
readonly freshAuthExpiresAtEpochMs: number | null;
|
||||
}
|
||||
|
||||
export interface PersistedSessionMetadata {
|
||||
readonly subject: string;
|
||||
readonly expiresAtEpochMs: number;
|
||||
readonly issuedAtEpochMs: number;
|
||||
readonly dpopKeyThumbprint: string;
|
||||
readonly tenantId?: string | null;
|
||||
}
|
||||
|
||||
export type AuthStatus =
|
||||
| 'unauthenticated'
|
||||
| 'authenticated'
|
||||
| 'refreshing'
|
||||
| 'loading';
|
||||
|
||||
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
|
||||
|
||||
export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
|
||||
|
||||
export type AuthErrorReason =
|
||||
| 'invalid_state'
|
||||
| 'token_exchange_failed'
|
||||
| 'refresh_failed'
|
||||
| 'dpop_generation_failed'
|
||||
| 'configuration_missing';
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
|
||||
import { AuthSessionStore } from './auth-session.store';
|
||||
|
||||
describe('AuthSessionStore', () => {
|
||||
let store: AuthSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [AuthSessionStore],
|
||||
});
|
||||
store = TestBed.inject(AuthSessionStore);
|
||||
});
|
||||
|
||||
it('persists minimal metadata when session is set', () => {
|
||||
const tokens: AuthTokens = {
|
||||
accessToken: 'token-abc',
|
||||
expiresAtEpochMs: Date.now() + 120_000,
|
||||
refreshToken: 'refresh-xyz',
|
||||
scope: 'openid ui.read',
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
|
||||
const session: AuthSession = {
|
||||
tokens,
|
||||
identity: {
|
||||
subject: 'user-123',
|
||||
name: 'Alex Operator',
|
||||
roles: ['ui.read'],
|
||||
},
|
||||
dpopKeyThumbprint: 'thumbprint-1',
|
||||
issuedAtEpochMs: Date.now(),
|
||||
tenantId: 'tenant-default',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationTimeEpochMs: Date.now(),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
|
||||
};
|
||||
|
||||
store.setSession(session);
|
||||
|
||||
const persisted = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
expect(persisted).toBeTruthy();
|
||||
const parsed = JSON.parse(persisted ?? '{}');
|
||||
expect(parsed.subject).toBe('user-123');
|
||||
expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
|
||||
expect(parsed.tenantId).toBe('tenant-default');
|
||||
|
||||
store.clear();
|
||||
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
|
||||
import { AuthSessionStore } from './auth-session.store';
|
||||
|
||||
describe('AuthSessionStore', () => {
|
||||
let store: AuthSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [AuthSessionStore],
|
||||
});
|
||||
store = TestBed.inject(AuthSessionStore);
|
||||
});
|
||||
|
||||
it('persists minimal metadata when session is set', () => {
|
||||
const tokens: AuthTokens = {
|
||||
accessToken: 'token-abc',
|
||||
expiresAtEpochMs: Date.now() + 120_000,
|
||||
refreshToken: 'refresh-xyz',
|
||||
scope: 'openid ui.read',
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
|
||||
const session: AuthSession = {
|
||||
tokens,
|
||||
identity: {
|
||||
subject: 'user-123',
|
||||
name: 'Alex Operator',
|
||||
roles: ['ui.read'],
|
||||
},
|
||||
dpopKeyThumbprint: 'thumbprint-1',
|
||||
issuedAtEpochMs: Date.now(),
|
||||
tenantId: 'tenant-default',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationTimeEpochMs: Date.now(),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
|
||||
};
|
||||
|
||||
store.setSession(session);
|
||||
|
||||
const persisted = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
expect(persisted).toBeTruthy();
|
||||
const parsed = JSON.parse(persisted ?? '{}');
|
||||
expect(parsed.subject).toBe('user-123');
|
||||
expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
|
||||
expect(parsed.tenantId).toBe('tenant-default');
|
||||
|
||||
store.clear();
|
||||
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,129 +1,129 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
import {
|
||||
AuthSession,
|
||||
AuthStatus,
|
||||
PersistedSessionMetadata,
|
||||
SESSION_STORAGE_KEY,
|
||||
} from './auth-session.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthSessionStore {
|
||||
private readonly sessionSignal = signal<AuthSession | null>(null);
|
||||
private readonly statusSignal = signal<AuthStatus>('unauthenticated');
|
||||
private readonly persistedSignal =
|
||||
signal<PersistedSessionMetadata | null>(this.readPersistedMetadata());
|
||||
|
||||
readonly session = computed(() => this.sessionSignal());
|
||||
readonly status = computed(() => this.statusSignal());
|
||||
|
||||
readonly identity = computed(() => this.sessionSignal()?.identity ?? null);
|
||||
readonly subjectHint = computed(
|
||||
() =>
|
||||
this.sessionSignal()?.identity.subject ??
|
||||
this.persistedSignal()?.subject ??
|
||||
null
|
||||
);
|
||||
|
||||
readonly expiresAtEpochMs = computed(
|
||||
() => this.sessionSignal()?.tokens.expiresAtEpochMs ?? null
|
||||
);
|
||||
|
||||
readonly isAuthenticated = computed(
|
||||
() => this.sessionSignal() !== null && this.statusSignal() !== 'loading'
|
||||
);
|
||||
|
||||
readonly tenantId = computed(
|
||||
() =>
|
||||
this.sessionSignal()?.tenantId ??
|
||||
this.persistedSignal()?.tenantId ??
|
||||
null
|
||||
);
|
||||
|
||||
setStatus(status: AuthStatus): void {
|
||||
this.statusSignal.set(status);
|
||||
}
|
||||
|
||||
setSession(session: AuthSession | null): void {
|
||||
this.sessionSignal.set(session);
|
||||
if (!session) {
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.persistedSignal.set(null);
|
||||
this.clearPersistedMetadata();
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusSignal.set('authenticated');
|
||||
const metadata: PersistedSessionMetadata = {
|
||||
subject: session.identity.subject,
|
||||
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
|
||||
issuedAtEpochMs: session.issuedAtEpochMs,
|
||||
dpopKeyThumbprint: session.dpopKeyThumbprint,
|
||||
tenantId: session.tenantId,
|
||||
};
|
||||
this.persistedSignal.set(metadata);
|
||||
this.persistMetadata(metadata);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.sessionSignal.set(null);
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.persistedSignal.set(null);
|
||||
this.clearPersistedMetadata();
|
||||
}
|
||||
|
||||
private readPersistedMetadata(): PersistedSessionMetadata | null {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PersistedSessionMetadata;
|
||||
if (
|
||||
typeof parsed.subject !== 'string' ||
|
||||
typeof parsed.expiresAtEpochMs !== 'number' ||
|
||||
typeof parsed.issuedAtEpochMs !== 'number' ||
|
||||
typeof parsed.dpopKeyThumbprint !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const tenantId =
|
||||
typeof parsed.tenantId === 'string'
|
||||
? parsed.tenantId.trim() || null
|
||||
: null;
|
||||
return {
|
||||
subject: parsed.subject,
|
||||
expiresAtEpochMs: parsed.expiresAtEpochMs,
|
||||
issuedAtEpochMs: parsed.issuedAtEpochMs,
|
||||
dpopKeyThumbprint: parsed.dpopKeyThumbprint,
|
||||
tenantId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private persistMetadata(metadata: PersistedSessionMetadata): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
private clearPersistedMetadata(): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
getActiveTenantId(): string | null {
|
||||
return this.tenantId();
|
||||
}
|
||||
}
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
import {
|
||||
AuthSession,
|
||||
AuthStatus,
|
||||
PersistedSessionMetadata,
|
||||
SESSION_STORAGE_KEY,
|
||||
} from './auth-session.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthSessionStore {
|
||||
private readonly sessionSignal = signal<AuthSession | null>(null);
|
||||
private readonly statusSignal = signal<AuthStatus>('unauthenticated');
|
||||
private readonly persistedSignal =
|
||||
signal<PersistedSessionMetadata | null>(this.readPersistedMetadata());
|
||||
|
||||
readonly session = computed(() => this.sessionSignal());
|
||||
readonly status = computed(() => this.statusSignal());
|
||||
|
||||
readonly identity = computed(() => this.sessionSignal()?.identity ?? null);
|
||||
readonly subjectHint = computed(
|
||||
() =>
|
||||
this.sessionSignal()?.identity.subject ??
|
||||
this.persistedSignal()?.subject ??
|
||||
null
|
||||
);
|
||||
|
||||
readonly expiresAtEpochMs = computed(
|
||||
() => this.sessionSignal()?.tokens.expiresAtEpochMs ?? null
|
||||
);
|
||||
|
||||
readonly isAuthenticated = computed(
|
||||
() => this.sessionSignal() !== null && this.statusSignal() !== 'loading'
|
||||
);
|
||||
|
||||
readonly tenantId = computed(
|
||||
() =>
|
||||
this.sessionSignal()?.tenantId ??
|
||||
this.persistedSignal()?.tenantId ??
|
||||
null
|
||||
);
|
||||
|
||||
setStatus(status: AuthStatus): void {
|
||||
this.statusSignal.set(status);
|
||||
}
|
||||
|
||||
setSession(session: AuthSession | null): void {
|
||||
this.sessionSignal.set(session);
|
||||
if (!session) {
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.persistedSignal.set(null);
|
||||
this.clearPersistedMetadata();
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusSignal.set('authenticated');
|
||||
const metadata: PersistedSessionMetadata = {
|
||||
subject: session.identity.subject,
|
||||
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
|
||||
issuedAtEpochMs: session.issuedAtEpochMs,
|
||||
dpopKeyThumbprint: session.dpopKeyThumbprint,
|
||||
tenantId: session.tenantId,
|
||||
};
|
||||
this.persistedSignal.set(metadata);
|
||||
this.persistMetadata(metadata);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.sessionSignal.set(null);
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.persistedSignal.set(null);
|
||||
this.clearPersistedMetadata();
|
||||
}
|
||||
|
||||
private readPersistedMetadata(): PersistedSessionMetadata | null {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PersistedSessionMetadata;
|
||||
if (
|
||||
typeof parsed.subject !== 'string' ||
|
||||
typeof parsed.expiresAtEpochMs !== 'number' ||
|
||||
typeof parsed.issuedAtEpochMs !== 'number' ||
|
||||
typeof parsed.dpopKeyThumbprint !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const tenantId =
|
||||
typeof parsed.tenantId === 'string'
|
||||
? parsed.tenantId.trim() || null
|
||||
: null;
|
||||
return {
|
||||
subject: parsed.subject,
|
||||
expiresAtEpochMs: parsed.expiresAtEpochMs,
|
||||
issuedAtEpochMs: parsed.issuedAtEpochMs,
|
||||
dpopKeyThumbprint: parsed.dpopKeyThumbprint,
|
||||
tenantId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private persistMetadata(metadata: PersistedSessionMetadata): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
private clearPersistedMetadata(): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
getActiveTenantId(): string | null {
|
||||
return this.tenantId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
const LOGIN_REQUEST_KEY = 'stellaops.auth.login.request';
|
||||
|
||||
export interface PendingLoginRequest {
|
||||
readonly state: string;
|
||||
readonly codeVerifier: string;
|
||||
readonly createdAtEpochMs: number;
|
||||
readonly returnUrl?: string;
|
||||
readonly nonce?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthStorageService {
|
||||
savePendingLogin(request: PendingLoginRequest): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(LOGIN_REQUEST_KEY, JSON.stringify(request));
|
||||
}
|
||||
|
||||
consumePendingLogin(expectedState: string): PendingLoginRequest | null {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = sessionStorage.getItem(LOGIN_REQUEST_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(LOGIN_REQUEST_KEY);
|
||||
try {
|
||||
const request = JSON.parse(raw) as PendingLoginRequest;
|
||||
if (request.state !== expectedState) {
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
const LOGIN_REQUEST_KEY = 'stellaops.auth.login.request';
|
||||
|
||||
export interface PendingLoginRequest {
|
||||
readonly state: string;
|
||||
readonly codeVerifier: string;
|
||||
readonly createdAtEpochMs: number;
|
||||
readonly returnUrl?: string;
|
||||
readonly nonce?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthStorageService {
|
||||
savePendingLogin(request: PendingLoginRequest): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(LOGIN_REQUEST_KEY, JSON.stringify(request));
|
||||
}
|
||||
|
||||
consumePendingLogin(expectedState: string): PendingLoginRequest | null {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = sessionStorage.getItem(LOGIN_REQUEST_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(LOGIN_REQUEST_KEY);
|
||||
try {
|
||||
const request = JSON.parse(raw) as PendingLoginRequest;
|
||||
if (request.state !== expectedState) {
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,217 +1,217 @@
|
||||
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
|
||||
import {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
/**
|
||||
* User info from authentication.
|
||||
*/
|
||||
export interface AuthUser {
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly name: string;
|
||||
readonly tenantId: string;
|
||||
readonly tenantName: string;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly StellaOpsScope[];
|
||||
readonly picture?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection token for Auth service.
|
||||
*/
|
||||
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
|
||||
|
||||
/**
|
||||
* Auth service interface.
|
||||
*/
|
||||
export interface AuthService {
|
||||
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
|
||||
readonly user: ReturnType<typeof signal<AuthUser | null>>;
|
||||
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean;
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
|
||||
canViewGraph(): boolean;
|
||||
canEditGraph(): boolean;
|
||||
canExportGraph(): boolean;
|
||||
canSimulate(): boolean;
|
||||
// Orchestrator access (UI-ORCH-32-001)
|
||||
canViewOrchestrator(): boolean;
|
||||
canOperateOrchestrator(): boolean;
|
||||
canManageOrchestratorQuotas(): boolean;
|
||||
canInitiateBackfill(): boolean;
|
||||
// Policy Studio access (UI-POLICY-20-003)
|
||||
canViewPolicies(): boolean;
|
||||
canAuthorPolicies(): boolean;
|
||||
canEditPolicies(): boolean;
|
||||
canReviewPolicies(): boolean;
|
||||
canApprovePolicies(): boolean;
|
||||
canOperatePolicies(): boolean;
|
||||
canActivatePolicies(): boolean;
|
||||
canSimulatePolicies(): boolean;
|
||||
canPublishPolicies(): boolean;
|
||||
canAuditPolicies(): boolean;
|
||||
// Session management
|
||||
logout?(): void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Auth Service
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_USER: AuthUser = {
|
||||
id: 'user-001',
|
||||
email: 'developer@example.com',
|
||||
name: 'Developer User',
|
||||
tenantId: 'tenant-001',
|
||||
tenantName: 'Acme Corp',
|
||||
roles: ['developer', 'security-analyst'],
|
||||
scopes: [
|
||||
// Graph permissions
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
// SBOM permissions
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
// Policy permissions (Policy Studio - UI-POLICY-20-003)
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.POLICY_AUTHOR,
|
||||
StellaOpsScopes.POLICY_EDIT,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_SUBMIT,
|
||||
StellaOpsScopes.POLICY_APPROVE,
|
||||
StellaOpsScopes.POLICY_OPERATE,
|
||||
StellaOpsScopes.POLICY_ACTIVATE,
|
||||
StellaOpsScopes.POLICY_RUN,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
// Scanner permissions
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
// Exception permissions
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
// Release permissions
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
// AOC permissions
|
||||
StellaOpsScopes.AOC_READ,
|
||||
// Orchestrator permissions (UI-ORCH-32-001)
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
|
||||
import {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
/**
|
||||
* User info from authentication.
|
||||
*/
|
||||
export interface AuthUser {
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly name: string;
|
||||
readonly tenantId: string;
|
||||
readonly tenantName: string;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly StellaOpsScope[];
|
||||
readonly picture?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection token for Auth service.
|
||||
*/
|
||||
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
|
||||
|
||||
/**
|
||||
* Auth service interface.
|
||||
*/
|
||||
export interface AuthService {
|
||||
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
|
||||
readonly user: ReturnType<typeof signal<AuthUser | null>>;
|
||||
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean;
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
|
||||
canViewGraph(): boolean;
|
||||
canEditGraph(): boolean;
|
||||
canExportGraph(): boolean;
|
||||
canSimulate(): boolean;
|
||||
// Orchestrator access (UI-ORCH-32-001)
|
||||
canViewOrchestrator(): boolean;
|
||||
canOperateOrchestrator(): boolean;
|
||||
canManageOrchestratorQuotas(): boolean;
|
||||
canInitiateBackfill(): boolean;
|
||||
// Policy Studio access (UI-POLICY-20-003)
|
||||
canViewPolicies(): boolean;
|
||||
canAuthorPolicies(): boolean;
|
||||
canEditPolicies(): boolean;
|
||||
canReviewPolicies(): boolean;
|
||||
canApprovePolicies(): boolean;
|
||||
canOperatePolicies(): boolean;
|
||||
canActivatePolicies(): boolean;
|
||||
canSimulatePolicies(): boolean;
|
||||
canPublishPolicies(): boolean;
|
||||
canAuditPolicies(): boolean;
|
||||
// Session management
|
||||
logout?(): void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Auth Service
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_USER: AuthUser = {
|
||||
id: 'user-001',
|
||||
email: 'developer@example.com',
|
||||
name: 'Developer User',
|
||||
tenantId: 'tenant-001',
|
||||
tenantName: 'Acme Corp',
|
||||
roles: ['developer', 'security-analyst'],
|
||||
scopes: [
|
||||
// Graph permissions
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
// SBOM permissions
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
// Policy permissions (Policy Studio - UI-POLICY-20-003)
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.POLICY_AUTHOR,
|
||||
StellaOpsScopes.POLICY_EDIT,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_SUBMIT,
|
||||
StellaOpsScopes.POLICY_APPROVE,
|
||||
StellaOpsScopes.POLICY_OPERATE,
|
||||
StellaOpsScopes.POLICY_ACTIVATE,
|
||||
StellaOpsScopes.POLICY_RUN,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
// Scanner permissions
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
// Exception permissions
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
// Release permissions
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
// AOC permissions
|
||||
StellaOpsScopes.AOC_READ,
|
||||
// Orchestrator permissions (UI-ORCH-32-001)
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
// UI permissions
|
||||
StellaOpsScopes.UI_READ,
|
||||
// Analytics permissions
|
||||
StellaOpsScopes.ANALYTICS_READ,
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAuthService implements AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly user = signal<AuthUser | null>(MOCK_USER);
|
||||
|
||||
readonly scopes = computed(() => {
|
||||
const u = this.user();
|
||||
return u?.scopes ?? [];
|
||||
});
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean {
|
||||
return hasScope(this.scopes(), scope);
|
||||
}
|
||||
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAllScopes(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAnyScope(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
canViewGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_READ);
|
||||
}
|
||||
|
||||
canEditGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
|
||||
}
|
||||
|
||||
canExportGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
|
||||
}
|
||||
|
||||
canSimulate(): boolean {
|
||||
return this.hasAnyScope([
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
]);
|
||||
}
|
||||
|
||||
// Orchestrator access methods (UI-ORCH-32-001)
|
||||
canViewOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_READ);
|
||||
}
|
||||
|
||||
canOperateOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
|
||||
}
|
||||
|
||||
canManageOrchestratorQuotas(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
|
||||
}
|
||||
|
||||
canInitiateBackfill(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
|
||||
}
|
||||
|
||||
// Policy Studio access methods (UI-POLICY-20-003)
|
||||
canViewPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_READ);
|
||||
}
|
||||
|
||||
canAuthorPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_AUTHOR);
|
||||
}
|
||||
|
||||
canEditPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_EDIT);
|
||||
}
|
||||
|
||||
canReviewPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_REVIEW);
|
||||
}
|
||||
|
||||
canApprovePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_APPROVE);
|
||||
}
|
||||
|
||||
canOperatePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_OPERATE);
|
||||
}
|
||||
|
||||
canActivatePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_ACTIVATE);
|
||||
}
|
||||
|
||||
canSimulatePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_SIMULATE);
|
||||
}
|
||||
|
||||
canPublishPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_PUBLISH);
|
||||
}
|
||||
|
||||
canAuditPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_AUDIT);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export scopes for convenience
|
||||
export { StellaOpsScopes, ScopeGroups } from './scopes';
|
||||
export type { StellaOpsScope } from './scopes';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAuthService implements AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly user = signal<AuthUser | null>(MOCK_USER);
|
||||
|
||||
readonly scopes = computed(() => {
|
||||
const u = this.user();
|
||||
return u?.scopes ?? [];
|
||||
});
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean {
|
||||
return hasScope(this.scopes(), scope);
|
||||
}
|
||||
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAllScopes(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAnyScope(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
canViewGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_READ);
|
||||
}
|
||||
|
||||
canEditGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
|
||||
}
|
||||
|
||||
canExportGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
|
||||
}
|
||||
|
||||
canSimulate(): boolean {
|
||||
return this.hasAnyScope([
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
]);
|
||||
}
|
||||
|
||||
// Orchestrator access methods (UI-ORCH-32-001)
|
||||
canViewOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_READ);
|
||||
}
|
||||
|
||||
canOperateOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
|
||||
}
|
||||
|
||||
canManageOrchestratorQuotas(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
|
||||
}
|
||||
|
||||
canInitiateBackfill(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
|
||||
}
|
||||
|
||||
// Policy Studio access methods (UI-POLICY-20-003)
|
||||
canViewPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_READ);
|
||||
}
|
||||
|
||||
canAuthorPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_AUTHOR);
|
||||
}
|
||||
|
||||
canEditPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_EDIT);
|
||||
}
|
||||
|
||||
canReviewPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_REVIEW);
|
||||
}
|
||||
|
||||
canApprovePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_APPROVE);
|
||||
}
|
||||
|
||||
canOperatePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_OPERATE);
|
||||
}
|
||||
|
||||
canActivatePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_ACTIVATE);
|
||||
}
|
||||
|
||||
canSimulatePolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_SIMULATE);
|
||||
}
|
||||
|
||||
canPublishPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_PUBLISH);
|
||||
}
|
||||
|
||||
canAuditPolicies(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.POLICY_AUDIT);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export scopes for convenience
|
||||
export { StellaOpsScopes, ScopeGroups } from './scopes';
|
||||
export type { StellaOpsScope } from './scopes';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,181 +1,181 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { DPoPAlgorithm } from '../../config/app-config.model';
|
||||
import { computeJwkThumbprint } from './jose-utilities';
|
||||
|
||||
const DB_NAME = 'stellaops-auth';
|
||||
const STORE_NAME = 'dpopKeys';
|
||||
const PRIMARY_KEY = 'primary';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
interface PersistedKeyPair {
|
||||
readonly id: string;
|
||||
readonly algorithm: DPoPAlgorithm;
|
||||
readonly publicJwk: JsonWebKey;
|
||||
readonly privateJwk: JsonWebKey;
|
||||
readonly thumbprint: string;
|
||||
readonly createdAtIso: string;
|
||||
}
|
||||
|
||||
export interface LoadedDpopKeyPair {
|
||||
readonly algorithm: DPoPAlgorithm;
|
||||
readonly privateKey: CryptoKey;
|
||||
readonly publicKey: CryptoKey;
|
||||
readonly publicJwk: JsonWebKey;
|
||||
readonly thumbprint: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DpopKeyStore {
|
||||
private dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
async load(): Promise<LoadedDpopKeyPair | null> {
|
||||
const record = await this.read();
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [privateKey, publicKey] = await Promise.all([
|
||||
crypto.subtle.importKey(
|
||||
'jwk',
|
||||
record.privateJwk,
|
||||
this.toKeyAlgorithm(record.algorithm),
|
||||
true,
|
||||
['sign']
|
||||
),
|
||||
crypto.subtle.importKey(
|
||||
'jwk',
|
||||
record.publicJwk,
|
||||
this.toKeyAlgorithm(record.algorithm),
|
||||
true,
|
||||
['verify']
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
algorithm: record.algorithm,
|
||||
privateKey,
|
||||
publicKey,
|
||||
publicJwk: record.publicJwk,
|
||||
thumbprint: record.thumbprint,
|
||||
};
|
||||
}
|
||||
|
||||
async save(
|
||||
keyPair: CryptoKeyPair,
|
||||
algorithm: DPoPAlgorithm
|
||||
): Promise<LoadedDpopKeyPair> {
|
||||
const [publicJwk, privateJwk] = await Promise.all([
|
||||
crypto.subtle.exportKey('jwk', keyPair.publicKey),
|
||||
crypto.subtle.exportKey('jwk', keyPair.privateKey),
|
||||
]);
|
||||
|
||||
if (!publicJwk) {
|
||||
throw new Error('Failed to export public JWK for DPoP key pair.');
|
||||
}
|
||||
|
||||
const thumbprint = await computeJwkThumbprint(publicJwk);
|
||||
const record: PersistedKeyPair = {
|
||||
id: PRIMARY_KEY,
|
||||
algorithm,
|
||||
publicJwk,
|
||||
privateJwk,
|
||||
thumbprint,
|
||||
createdAtIso: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.write(record);
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
publicJwk,
|
||||
thumbprint,
|
||||
};
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const db = await this.openDb();
|
||||
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
||||
store.delete(PRIMARY_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
async generate(algorithm: DPoPAlgorithm): Promise<LoadedDpopKeyPair> {
|
||||
const algo = this.toKeyAlgorithm(algorithm);
|
||||
const keyPair = await crypto.subtle.generateKey(algo, true, [
|
||||
'sign',
|
||||
'verify',
|
||||
]);
|
||||
|
||||
const stored = await this.save(keyPair, algorithm);
|
||||
return stored;
|
||||
}
|
||||
|
||||
private async read(): Promise<PersistedKeyPair | null> {
|
||||
const db = await this.openDb();
|
||||
return transactionPromise(db, STORE_NAME, 'readonly', (store) =>
|
||||
store.get(PRIMARY_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
private async write(record: PersistedKeyPair): Promise<void> {
|
||||
const db = await this.openDb();
|
||||
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
||||
store.put(record)
|
||||
);
|
||||
}
|
||||
|
||||
private toKeyAlgorithm(algorithm: DPoPAlgorithm): EcKeyImportParams {
|
||||
switch (algorithm) {
|
||||
case 'ES384':
|
||||
return { name: 'ECDSA', namedCurve: 'P-384' };
|
||||
case 'EdDSA':
|
||||
throw new Error('EdDSA DPoP keys are not yet supported.');
|
||||
case 'ES256':
|
||||
default:
|
||||
return { name: 'ECDSA', namedCurve: 'P-256' };
|
||||
}
|
||||
}
|
||||
|
||||
private async openDb(): Promise<IDBDatabase> {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
throw new Error('IndexedDB is not available for DPoP key persistence.');
|
||||
}
|
||||
|
||||
if (!this.dbPromise) {
|
||||
this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
return this.dbPromise;
|
||||
}
|
||||
}
|
||||
|
||||
function transactionPromise<T>(
|
||||
db: IDBDatabase,
|
||||
storeName: string,
|
||||
mode: IDBTransactionMode,
|
||||
executor: (store: IDBObjectStore) => IDBRequest<T>
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, mode);
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = executor(store);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { DPoPAlgorithm } from '../../config/app-config.model';
|
||||
import { computeJwkThumbprint } from './jose-utilities';
|
||||
|
||||
const DB_NAME = 'stellaops-auth';
|
||||
const STORE_NAME = 'dpopKeys';
|
||||
const PRIMARY_KEY = 'primary';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
interface PersistedKeyPair {
|
||||
readonly id: string;
|
||||
readonly algorithm: DPoPAlgorithm;
|
||||
readonly publicJwk: JsonWebKey;
|
||||
readonly privateJwk: JsonWebKey;
|
||||
readonly thumbprint: string;
|
||||
readonly createdAtIso: string;
|
||||
}
|
||||
|
||||
export interface LoadedDpopKeyPair {
|
||||
readonly algorithm: DPoPAlgorithm;
|
||||
readonly privateKey: CryptoKey;
|
||||
readonly publicKey: CryptoKey;
|
||||
readonly publicJwk: JsonWebKey;
|
||||
readonly thumbprint: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DpopKeyStore {
|
||||
private dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
async load(): Promise<LoadedDpopKeyPair | null> {
|
||||
const record = await this.read();
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [privateKey, publicKey] = await Promise.all([
|
||||
crypto.subtle.importKey(
|
||||
'jwk',
|
||||
record.privateJwk,
|
||||
this.toKeyAlgorithm(record.algorithm),
|
||||
true,
|
||||
['sign']
|
||||
),
|
||||
crypto.subtle.importKey(
|
||||
'jwk',
|
||||
record.publicJwk,
|
||||
this.toKeyAlgorithm(record.algorithm),
|
||||
true,
|
||||
['verify']
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
algorithm: record.algorithm,
|
||||
privateKey,
|
||||
publicKey,
|
||||
publicJwk: record.publicJwk,
|
||||
thumbprint: record.thumbprint,
|
||||
};
|
||||
}
|
||||
|
||||
async save(
|
||||
keyPair: CryptoKeyPair,
|
||||
algorithm: DPoPAlgorithm
|
||||
): Promise<LoadedDpopKeyPair> {
|
||||
const [publicJwk, privateJwk] = await Promise.all([
|
||||
crypto.subtle.exportKey('jwk', keyPair.publicKey),
|
||||
crypto.subtle.exportKey('jwk', keyPair.privateKey),
|
||||
]);
|
||||
|
||||
if (!publicJwk) {
|
||||
throw new Error('Failed to export public JWK for DPoP key pair.');
|
||||
}
|
||||
|
||||
const thumbprint = await computeJwkThumbprint(publicJwk);
|
||||
const record: PersistedKeyPair = {
|
||||
id: PRIMARY_KEY,
|
||||
algorithm,
|
||||
publicJwk,
|
||||
privateJwk,
|
||||
thumbprint,
|
||||
createdAtIso: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.write(record);
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
publicJwk,
|
||||
thumbprint,
|
||||
};
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const db = await this.openDb();
|
||||
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
||||
store.delete(PRIMARY_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
async generate(algorithm: DPoPAlgorithm): Promise<LoadedDpopKeyPair> {
|
||||
const algo = this.toKeyAlgorithm(algorithm);
|
||||
const keyPair = await crypto.subtle.generateKey(algo, true, [
|
||||
'sign',
|
||||
'verify',
|
||||
]);
|
||||
|
||||
const stored = await this.save(keyPair, algorithm);
|
||||
return stored;
|
||||
}
|
||||
|
||||
private async read(): Promise<PersistedKeyPair | null> {
|
||||
const db = await this.openDb();
|
||||
return transactionPromise(db, STORE_NAME, 'readonly', (store) =>
|
||||
store.get(PRIMARY_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
private async write(record: PersistedKeyPair): Promise<void> {
|
||||
const db = await this.openDb();
|
||||
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
||||
store.put(record)
|
||||
);
|
||||
}
|
||||
|
||||
private toKeyAlgorithm(algorithm: DPoPAlgorithm): EcKeyImportParams {
|
||||
switch (algorithm) {
|
||||
case 'ES384':
|
||||
return { name: 'ECDSA', namedCurve: 'P-384' };
|
||||
case 'EdDSA':
|
||||
throw new Error('EdDSA DPoP keys are not yet supported.');
|
||||
case 'ES256':
|
||||
default:
|
||||
return { name: 'ECDSA', namedCurve: 'P-256' };
|
||||
}
|
||||
}
|
||||
|
||||
private async openDb(): Promise<IDBDatabase> {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
throw new Error('IndexedDB is not available for DPoP key persistence.');
|
||||
}
|
||||
|
||||
if (!this.dbPromise) {
|
||||
this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
return this.dbPromise;
|
||||
}
|
||||
}
|
||||
|
||||
function transactionPromise<T>(
|
||||
db: IDBDatabase,
|
||||
storeName: string,
|
||||
mode: IDBTransactionMode,
|
||||
executor: (store: IDBObjectStore) => IDBRequest<T>
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, mode);
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = executor(store);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { APP_CONFIG, AppConfig } from '../../config/app-config.model';
|
||||
import { AppConfigService } from '../../config/app-config.service';
|
||||
import { base64UrlDecode } from './jose-utilities';
|
||||
import { DpopKeyStore } from './dpop-key-store';
|
||||
import { DpopService } from './dpop.service';
|
||||
|
||||
describe('DpopService', () => {
|
||||
const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
|
||||
const config: AppConfig = {
|
||||
authority: {
|
||||
issuer: 'https://auth.stellaops.test/',
|
||||
clientId: 'ui-client',
|
||||
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
|
||||
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
|
||||
redirectUri: 'https://ui.stellaops.test/auth/callback',
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { APP_CONFIG, AppConfig } from '../../config/app-config.model';
|
||||
import { AppConfigService } from '../../config/app-config.service';
|
||||
import { base64UrlDecode } from './jose-utilities';
|
||||
import { DpopKeyStore } from './dpop-key-store';
|
||||
import { DpopService } from './dpop.service';
|
||||
|
||||
describe('DpopService', () => {
|
||||
const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
|
||||
const config: AppConfig = {
|
||||
authority: {
|
||||
issuer: 'https://auth.stellaops.test/',
|
||||
clientId: 'ui-client',
|
||||
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
|
||||
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
|
||||
redirectUri: 'https://ui.stellaops.test/auth/callback',
|
||||
scope: 'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
|
||||
audience: 'https://scanner.stellaops.test',
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://auth.stellaops.test',
|
||||
scanner: 'https://scanner.stellaops.test',
|
||||
policy: 'https://policy.stellaops.test',
|
||||
concelier: 'https://concelier.stellaops.test',
|
||||
attestor: 'https://attestor.stellaops.test',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
AppConfigService,
|
||||
DpopKeyStore,
|
||||
DpopService,
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
useValue: config,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
|
||||
const store = TestBed.inject(DpopKeyStore);
|
||||
try {
|
||||
await store.clear();
|
||||
} catch {
|
||||
// ignore cleanup issues in test environment
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a DPoP proof with expected header values', async () => {
|
||||
const appConfig = TestBed.inject(AppConfigService);
|
||||
appConfig.setConfigForTesting(config);
|
||||
const service = TestBed.inject(DpopService);
|
||||
|
||||
const proof = await service.createProof({
|
||||
htm: 'get',
|
||||
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
||||
});
|
||||
|
||||
const [rawHeader, rawPayload] = proof.split('.');
|
||||
const header = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(rawHeader))
|
||||
);
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(rawPayload))
|
||||
);
|
||||
|
||||
expect(header.typ).toBe('dpop+jwt');
|
||||
expect(header.alg).toBe('ES256');
|
||||
expect(header.jwk.kty).toBe('EC');
|
||||
expect(payload.htm).toBe('GET');
|
||||
expect(payload.htu).toBe('https://scanner.stellaops.test/api/v1/scans');
|
||||
expect(typeof payload.iat).toBe('number');
|
||||
expect(typeof payload.jti).toBe('string');
|
||||
});
|
||||
|
||||
it('binds access token hash when provided', async () => {
|
||||
const appConfig = TestBed.inject(AppConfigService);
|
||||
appConfig.setConfigForTesting(config);
|
||||
const service = TestBed.inject(DpopService);
|
||||
|
||||
const accessToken = 'sample-access-token';
|
||||
const proof = await service.createProof({
|
||||
htm: 'post',
|
||||
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
||||
accessToken,
|
||||
});
|
||||
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(proof.split('.')[1]))
|
||||
);
|
||||
|
||||
expect(payload.ath).toBeDefined();
|
||||
expect(typeof payload.ath).toBe('string');
|
||||
});
|
||||
});
|
||||
audience: 'https://scanner.stellaops.test',
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://auth.stellaops.test',
|
||||
scanner: 'https://scanner.stellaops.test',
|
||||
policy: 'https://policy.stellaops.test',
|
||||
concelier: 'https://concelier.stellaops.test',
|
||||
attestor: 'https://attestor.stellaops.test',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
AppConfigService,
|
||||
DpopKeyStore,
|
||||
DpopService,
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
useValue: config,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
|
||||
const store = TestBed.inject(DpopKeyStore);
|
||||
try {
|
||||
await store.clear();
|
||||
} catch {
|
||||
// ignore cleanup issues in test environment
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a DPoP proof with expected header values', async () => {
|
||||
const appConfig = TestBed.inject(AppConfigService);
|
||||
appConfig.setConfigForTesting(config);
|
||||
const service = TestBed.inject(DpopService);
|
||||
|
||||
const proof = await service.createProof({
|
||||
htm: 'get',
|
||||
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
||||
});
|
||||
|
||||
const [rawHeader, rawPayload] = proof.split('.');
|
||||
const header = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(rawHeader))
|
||||
);
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(rawPayload))
|
||||
);
|
||||
|
||||
expect(header.typ).toBe('dpop+jwt');
|
||||
expect(header.alg).toBe('ES256');
|
||||
expect(header.jwk.kty).toBe('EC');
|
||||
expect(payload.htm).toBe('GET');
|
||||
expect(payload.htu).toBe('https://scanner.stellaops.test/api/v1/scans');
|
||||
expect(typeof payload.iat).toBe('number');
|
||||
expect(typeof payload.jti).toBe('string');
|
||||
});
|
||||
|
||||
it('binds access token hash when provided', async () => {
|
||||
const appConfig = TestBed.inject(AppConfigService);
|
||||
appConfig.setConfigForTesting(config);
|
||||
const service = TestBed.inject(DpopService);
|
||||
|
||||
const accessToken = 'sample-access-token';
|
||||
const proof = await service.createProof({
|
||||
htm: 'post',
|
||||
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
||||
accessToken,
|
||||
});
|
||||
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(proof.split('.')[1]))
|
||||
);
|
||||
|
||||
expect(payload.ath).toBeDefined();
|
||||
expect(typeof payload.ath).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,148 +1,148 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
import { AppConfigService } from '../../config/app-config.service';
|
||||
import { DPoPAlgorithm } from '../../config/app-config.model';
|
||||
import { sha256, base64UrlEncode, derToJoseSignature } from './jose-utilities';
|
||||
import { DpopKeyStore, LoadedDpopKeyPair } from './dpop-key-store';
|
||||
|
||||
export interface DpopProofOptions {
|
||||
readonly htm: string;
|
||||
readonly htu: string;
|
||||
readonly accessToken?: string;
|
||||
readonly nonce?: string | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DpopService {
|
||||
private keyPairPromise: Promise<LoadedDpopKeyPair> | null = null;
|
||||
private readonly nonceSignal = signal<string | null>(null);
|
||||
readonly nonce = computed(() => this.nonceSignal());
|
||||
|
||||
constructor(
|
||||
private readonly config: AppConfigService,
|
||||
private readonly store: DpopKeyStore
|
||||
) {}
|
||||
|
||||
async setNonce(nonce: string | null): Promise<void> {
|
||||
this.nonceSignal.set(nonce);
|
||||
}
|
||||
|
||||
async getThumbprint(): Promise<string | null> {
|
||||
const key = await this.getOrCreateKeyPair();
|
||||
return key.thumbprint ?? null;
|
||||
}
|
||||
|
||||
async rotateKey(): Promise<void> {
|
||||
const algorithm = this.resolveAlgorithm();
|
||||
this.keyPairPromise = this.store.generate(algorithm);
|
||||
}
|
||||
|
||||
async createProof(options: DpopProofOptions): Promise<string> {
|
||||
const keyPair = await this.getOrCreateKeyPair();
|
||||
|
||||
const header = {
|
||||
typ: 'dpop+jwt',
|
||||
alg: keyPair.algorithm,
|
||||
jwk: keyPair.publicJwk,
|
||||
};
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const payload: Record<string, unknown> = {
|
||||
htm: options.htm.toUpperCase(),
|
||||
htu: normalizeHtu(options.htu),
|
||||
iat: nowSeconds,
|
||||
jti: crypto.randomUUID ? crypto.randomUUID() : createRandomId(),
|
||||
};
|
||||
|
||||
const nonce = options.nonce ?? this.nonceSignal();
|
||||
if (nonce) {
|
||||
payload['nonce'] = nonce;
|
||||
}
|
||||
|
||||
if (options.accessToken) {
|
||||
const accessTokenHash = await sha256(
|
||||
new TextEncoder().encode(options.accessToken)
|
||||
);
|
||||
payload['ath'] = base64UrlEncode(accessTokenHash);
|
||||
}
|
||||
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = await crypto.subtle.sign(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
hash: this.resolveHashAlgorithm(keyPair.algorithm),
|
||||
},
|
||||
keyPair.privateKey,
|
||||
new TextEncoder().encode(signingInput)
|
||||
);
|
||||
|
||||
const joseSignature = base64UrlEncode(derToJoseSignature(signature));
|
||||
return `${signingInput}.${joseSignature}`;
|
||||
}
|
||||
|
||||
private async getOrCreateKeyPair(): Promise<LoadedDpopKeyPair> {
|
||||
if (!this.keyPairPromise) {
|
||||
this.keyPairPromise = this.loadKeyPair();
|
||||
}
|
||||
try {
|
||||
return await this.keyPairPromise;
|
||||
} catch (error) {
|
||||
// Reset the memoized promise so a subsequent call can retry.
|
||||
this.keyPairPromise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadKeyPair(): Promise<LoadedDpopKeyPair> {
|
||||
const algorithm = this.resolveAlgorithm();
|
||||
try {
|
||||
const existing = await this.store.load();
|
||||
if (existing && existing.algorithm === algorithm) {
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// fall through to regeneration
|
||||
}
|
||||
|
||||
return this.store.generate(algorithm);
|
||||
}
|
||||
|
||||
private resolveAlgorithm(): DPoPAlgorithm {
|
||||
const authority = this.config.authority;
|
||||
return authority.dpopAlgorithms?.[0] ?? 'ES256';
|
||||
}
|
||||
|
||||
private resolveHashAlgorithm(algorithm: DPoPAlgorithm): string {
|
||||
switch (algorithm) {
|
||||
case 'ES384':
|
||||
return 'SHA-384';
|
||||
case 'ES256':
|
||||
default:
|
||||
return 'SHA-256';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHtu(value: string): string {
|
||||
try {
|
||||
const base =
|
||||
typeof window !== 'undefined' && window.location
|
||||
? window.location.origin
|
||||
: undefined;
|
||||
const url = base ? new URL(value, base) : new URL(value);
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function createRandomId(): string {
|
||||
const array = new Uint8Array(16);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array);
|
||||
}
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
import { AppConfigService } from '../../config/app-config.service';
|
||||
import { DPoPAlgorithm } from '../../config/app-config.model';
|
||||
import { sha256, base64UrlEncode, derToJoseSignature } from './jose-utilities';
|
||||
import { DpopKeyStore, LoadedDpopKeyPair } from './dpop-key-store';
|
||||
|
||||
export interface DpopProofOptions {
|
||||
readonly htm: string;
|
||||
readonly htu: string;
|
||||
readonly accessToken?: string;
|
||||
readonly nonce?: string | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DpopService {
|
||||
private keyPairPromise: Promise<LoadedDpopKeyPair> | null = null;
|
||||
private readonly nonceSignal = signal<string | null>(null);
|
||||
readonly nonce = computed(() => this.nonceSignal());
|
||||
|
||||
constructor(
|
||||
private readonly config: AppConfigService,
|
||||
private readonly store: DpopKeyStore
|
||||
) {}
|
||||
|
||||
async setNonce(nonce: string | null): Promise<void> {
|
||||
this.nonceSignal.set(nonce);
|
||||
}
|
||||
|
||||
async getThumbprint(): Promise<string | null> {
|
||||
const key = await this.getOrCreateKeyPair();
|
||||
return key.thumbprint ?? null;
|
||||
}
|
||||
|
||||
async rotateKey(): Promise<void> {
|
||||
const algorithm = this.resolveAlgorithm();
|
||||
this.keyPairPromise = this.store.generate(algorithm);
|
||||
}
|
||||
|
||||
async createProof(options: DpopProofOptions): Promise<string> {
|
||||
const keyPair = await this.getOrCreateKeyPair();
|
||||
|
||||
const header = {
|
||||
typ: 'dpop+jwt',
|
||||
alg: keyPair.algorithm,
|
||||
jwk: keyPair.publicJwk,
|
||||
};
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const payload: Record<string, unknown> = {
|
||||
htm: options.htm.toUpperCase(),
|
||||
htu: normalizeHtu(options.htu),
|
||||
iat: nowSeconds,
|
||||
jti: crypto.randomUUID ? crypto.randomUUID() : createRandomId(),
|
||||
};
|
||||
|
||||
const nonce = options.nonce ?? this.nonceSignal();
|
||||
if (nonce) {
|
||||
payload['nonce'] = nonce;
|
||||
}
|
||||
|
||||
if (options.accessToken) {
|
||||
const accessTokenHash = await sha256(
|
||||
new TextEncoder().encode(options.accessToken)
|
||||
);
|
||||
payload['ath'] = base64UrlEncode(accessTokenHash);
|
||||
}
|
||||
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = await crypto.subtle.sign(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
hash: this.resolveHashAlgorithm(keyPair.algorithm),
|
||||
},
|
||||
keyPair.privateKey,
|
||||
new TextEncoder().encode(signingInput)
|
||||
);
|
||||
|
||||
const joseSignature = base64UrlEncode(derToJoseSignature(signature));
|
||||
return `${signingInput}.${joseSignature}`;
|
||||
}
|
||||
|
||||
private async getOrCreateKeyPair(): Promise<LoadedDpopKeyPair> {
|
||||
if (!this.keyPairPromise) {
|
||||
this.keyPairPromise = this.loadKeyPair();
|
||||
}
|
||||
try {
|
||||
return await this.keyPairPromise;
|
||||
} catch (error) {
|
||||
// Reset the memoized promise so a subsequent call can retry.
|
||||
this.keyPairPromise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadKeyPair(): Promise<LoadedDpopKeyPair> {
|
||||
const algorithm = this.resolveAlgorithm();
|
||||
try {
|
||||
const existing = await this.store.load();
|
||||
if (existing && existing.algorithm === algorithm) {
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// fall through to regeneration
|
||||
}
|
||||
|
||||
return this.store.generate(algorithm);
|
||||
}
|
||||
|
||||
private resolveAlgorithm(): DPoPAlgorithm {
|
||||
const authority = this.config.authority;
|
||||
return authority.dpopAlgorithms?.[0] ?? 'ES256';
|
||||
}
|
||||
|
||||
private resolveHashAlgorithm(algorithm: DPoPAlgorithm): string {
|
||||
switch (algorithm) {
|
||||
case 'ES384':
|
||||
return 'SHA-384';
|
||||
case 'ES256':
|
||||
default:
|
||||
return 'SHA-256';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHtu(value: string): string {
|
||||
try {
|
||||
const base =
|
||||
typeof window !== 'undefined' && window.location
|
||||
? window.location.origin
|
||||
: undefined;
|
||||
const url = base ? new URL(value, base) : new URL(value);
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function createRandomId(): string {
|
||||
const array = new Uint8Array(16);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array);
|
||||
}
|
||||
|
||||
@@ -1,123 +1,123 @@
|
||||
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
export function base64UrlEncode(
|
||||
input: ArrayBuffer | Uint8Array | string
|
||||
): string {
|
||||
let bytes: Uint8Array;
|
||||
if (typeof input === 'string') {
|
||||
bytes = new TextEncoder().encode(input);
|
||||
} else if (input instanceof Uint8Array) {
|
||||
bytes = input;
|
||||
} else {
|
||||
bytes = new Uint8Array(input);
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function base64UrlDecode(value: string): Uint8Array {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalized.length % 4;
|
||||
const padded =
|
||||
padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
|
||||
const canonical = canonicalizeJwk(jwk);
|
||||
const digest = await sha256(new TextEncoder().encode(canonical));
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
function canonicalizeJwk(jwk: JsonWebKey): string {
|
||||
if (!jwk.kty) {
|
||||
throw new Error('JWK must include "kty"');
|
||||
}
|
||||
|
||||
if (jwk.kty === 'EC') {
|
||||
const { crv, kty, x, y } = jwk;
|
||||
if (!crv || !x || !y) {
|
||||
throw new Error('EC JWK must include "crv", "x", and "y".');
|
||||
}
|
||||
return JSON.stringify({ crv, kty, x, y });
|
||||
}
|
||||
|
||||
if (jwk.kty === 'OKP') {
|
||||
const { crv, kty, x } = jwk;
|
||||
if (!crv || !x) {
|
||||
throw new Error('OKP JWK must include "crv" and "x".');
|
||||
}
|
||||
return JSON.stringify({ crv, kty, x });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
|
||||
}
|
||||
|
||||
export function derToJoseSignature(der: ArrayBuffer): Uint8Array {
|
||||
const bytes = new Uint8Array(der);
|
||||
if (bytes[0] !== 0x30) {
|
||||
// Some implementations already return raw (r || s) signature bytes.
|
||||
if (bytes.length === 64) {
|
||||
return bytes;
|
||||
}
|
||||
throw new Error('Invalid DER signature: expected sequence.');
|
||||
}
|
||||
|
||||
let offset = 2; // skip SEQUENCE header and length (assume short form)
|
||||
if (bytes[1] & 0x80) {
|
||||
const lengthBytes = bytes[1] & 0x7f;
|
||||
offset = 2 + lengthBytes;
|
||||
}
|
||||
|
||||
if (bytes[offset] !== 0x02) {
|
||||
throw new Error('Invalid DER signature: expected INTEGER for r.');
|
||||
}
|
||||
const rLength = bytes[offset + 1];
|
||||
let r = bytes.slice(offset + 2, offset + 2 + rLength);
|
||||
offset = offset + 2 + rLength;
|
||||
|
||||
if (bytes[offset] !== 0x02) {
|
||||
throw new Error('Invalid DER signature: expected INTEGER for s.');
|
||||
}
|
||||
const sLength = bytes[offset + 1];
|
||||
let s = bytes.slice(offset + 2, offset + 2 + sLength);
|
||||
|
||||
r = trimLeadingZeros(r);
|
||||
s = trimLeadingZeros(s);
|
||||
|
||||
const targetLength = 32;
|
||||
const signature = new Uint8Array(targetLength * 2);
|
||||
signature.set(padStart(r, targetLength), 0);
|
||||
signature.set(padStart(s, targetLength), targetLength);
|
||||
return signature;
|
||||
}
|
||||
|
||||
function trimLeadingZeros(bytes: Uint8Array): Uint8Array {
|
||||
let start = 0;
|
||||
while (start < bytes.length - 1 && bytes[start] === 0x00) {
|
||||
start += 1;
|
||||
}
|
||||
return bytes.subarray(start);
|
||||
}
|
||||
|
||||
function padStart(bytes: Uint8Array, length: number): Uint8Array {
|
||||
if (bytes.length >= length) {
|
||||
return bytes;
|
||||
}
|
||||
const padded = new Uint8Array(length);
|
||||
padded.set(bytes, length - bytes.length);
|
||||
return padded;
|
||||
}
|
||||
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
export function base64UrlEncode(
|
||||
input: ArrayBuffer | Uint8Array | string
|
||||
): string {
|
||||
let bytes: Uint8Array;
|
||||
if (typeof input === 'string') {
|
||||
bytes = new TextEncoder().encode(input);
|
||||
} else if (input instanceof Uint8Array) {
|
||||
bytes = input;
|
||||
} else {
|
||||
bytes = new Uint8Array(input);
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function base64UrlDecode(value: string): Uint8Array {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalized.length % 4;
|
||||
const padded =
|
||||
padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
|
||||
const canonical = canonicalizeJwk(jwk);
|
||||
const digest = await sha256(new TextEncoder().encode(canonical));
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
function canonicalizeJwk(jwk: JsonWebKey): string {
|
||||
if (!jwk.kty) {
|
||||
throw new Error('JWK must include "kty"');
|
||||
}
|
||||
|
||||
if (jwk.kty === 'EC') {
|
||||
const { crv, kty, x, y } = jwk;
|
||||
if (!crv || !x || !y) {
|
||||
throw new Error('EC JWK must include "crv", "x", and "y".');
|
||||
}
|
||||
return JSON.stringify({ crv, kty, x, y });
|
||||
}
|
||||
|
||||
if (jwk.kty === 'OKP') {
|
||||
const { crv, kty, x } = jwk;
|
||||
if (!crv || !x) {
|
||||
throw new Error('OKP JWK must include "crv" and "x".');
|
||||
}
|
||||
return JSON.stringify({ crv, kty, x });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
|
||||
}
|
||||
|
||||
export function derToJoseSignature(der: ArrayBuffer): Uint8Array {
|
||||
const bytes = new Uint8Array(der);
|
||||
if (bytes[0] !== 0x30) {
|
||||
// Some implementations already return raw (r || s) signature bytes.
|
||||
if (bytes.length === 64) {
|
||||
return bytes;
|
||||
}
|
||||
throw new Error('Invalid DER signature: expected sequence.');
|
||||
}
|
||||
|
||||
let offset = 2; // skip SEQUENCE header and length (assume short form)
|
||||
if (bytes[1] & 0x80) {
|
||||
const lengthBytes = bytes[1] & 0x7f;
|
||||
offset = 2 + lengthBytes;
|
||||
}
|
||||
|
||||
if (bytes[offset] !== 0x02) {
|
||||
throw new Error('Invalid DER signature: expected INTEGER for r.');
|
||||
}
|
||||
const rLength = bytes[offset + 1];
|
||||
let r = bytes.slice(offset + 2, offset + 2 + rLength);
|
||||
offset = offset + 2 + rLength;
|
||||
|
||||
if (bytes[offset] !== 0x02) {
|
||||
throw new Error('Invalid DER signature: expected INTEGER for s.');
|
||||
}
|
||||
const sLength = bytes[offset + 1];
|
||||
let s = bytes.slice(offset + 2, offset + 2 + sLength);
|
||||
|
||||
r = trimLeadingZeros(r);
|
||||
s = trimLeadingZeros(s);
|
||||
|
||||
const targetLength = 32;
|
||||
const signature = new Uint8Array(targetLength * 2);
|
||||
signature.set(padStart(r, targetLength), 0);
|
||||
signature.set(padStart(s, targetLength), targetLength);
|
||||
return signature;
|
||||
}
|
||||
|
||||
function trimLeadingZeros(bytes: Uint8Array): Uint8Array {
|
||||
let start = 0;
|
||||
while (start < bytes.length - 1 && bytes[start] === 0x00) {
|
||||
start += 1;
|
||||
}
|
||||
return bytes.subarray(start);
|
||||
}
|
||||
|
||||
function padStart(bytes: Uint8Array, length: number): Uint8Array {
|
||||
if (bytes.length >= length) {
|
||||
return bytes;
|
||||
}
|
||||
const padded = new Uint8Array(length);
|
||||
padded.set(bytes, length - bytes.length);
|
||||
return padded;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
export {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
ScopeLabels,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
export {
|
||||
AuthUser,
|
||||
AuthService,
|
||||
AUTH_SERVICE,
|
||||
MockAuthService,
|
||||
} from './auth.service';
|
||||
|
||||
export {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
ScopeLabels,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
export {
|
||||
AuthUser,
|
||||
AuthService,
|
||||
AUTH_SERVICE,
|
||||
MockAuthService,
|
||||
} from './auth.service';
|
||||
|
||||
export {
|
||||
requireAuthGuard,
|
||||
requireScopesGuard,
|
||||
@@ -32,34 +32,34 @@ export {
|
||||
requirePolicyAuditGuard,
|
||||
requireAnalyticsViewerGuard,
|
||||
} from './auth.guard';
|
||||
|
||||
export {
|
||||
TenantActivationService,
|
||||
TenantScope,
|
||||
AuthDecision,
|
||||
DenyReason,
|
||||
AuthDecisionAudit,
|
||||
ScopeCheckResult,
|
||||
TenantContext,
|
||||
JwtClaims,
|
||||
} from './tenant-activation.service';
|
||||
|
||||
export {
|
||||
TenantHttpInterceptor,
|
||||
TENANT_HEADERS,
|
||||
} from './tenant-http.interceptor';
|
||||
|
||||
export {
|
||||
TenantPersistenceService,
|
||||
PersistenceAuditMetadata,
|
||||
TenantPersistenceCheck,
|
||||
TenantStoragePath,
|
||||
PersistenceAuditEvent,
|
||||
} from './tenant-persistence.service';
|
||||
|
||||
export {
|
||||
AbacService,
|
||||
AbacMode,
|
||||
AbacConfig,
|
||||
AbacAuthResult,
|
||||
} from './abac.service';
|
||||
|
||||
export {
|
||||
TenantActivationService,
|
||||
TenantScope,
|
||||
AuthDecision,
|
||||
DenyReason,
|
||||
AuthDecisionAudit,
|
||||
ScopeCheckResult,
|
||||
TenantContext,
|
||||
JwtClaims,
|
||||
} from './tenant-activation.service';
|
||||
|
||||
export {
|
||||
TenantHttpInterceptor,
|
||||
TENANT_HEADERS,
|
||||
} from './tenant-http.interceptor';
|
||||
|
||||
export {
|
||||
TenantPersistenceService,
|
||||
PersistenceAuditMetadata,
|
||||
TenantPersistenceCheck,
|
||||
TenantStoragePath,
|
||||
PersistenceAuditEvent,
|
||||
} from './tenant-persistence.service';
|
||||
|
||||
export {
|
||||
AbacService,
|
||||
AbacMode,
|
||||
AbacConfig,
|
||||
AbacAuthResult,
|
||||
} from './abac.service';
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { base64UrlEncode, sha256 } from './dpop/jose-utilities';
|
||||
|
||||
export interface PkcePair {
|
||||
readonly verifier: string;
|
||||
readonly challenge: string;
|
||||
readonly method: 'S256';
|
||||
}
|
||||
|
||||
const VERIFIER_BYTE_LENGTH = 32;
|
||||
|
||||
export async function createPkcePair(): Promise<PkcePair> {
|
||||
const verifierBytes = new Uint8Array(VERIFIER_BYTE_LENGTH);
|
||||
crypto.getRandomValues(verifierBytes);
|
||||
|
||||
const verifier = base64UrlEncode(verifierBytes);
|
||||
const challengeBytes = await sha256(new TextEncoder().encode(verifier));
|
||||
const challenge = base64UrlEncode(challengeBytes);
|
||||
|
||||
return {
|
||||
verifier,
|
||||
challenge,
|
||||
method: 'S256',
|
||||
};
|
||||
}
|
||||
import { base64UrlEncode, sha256 } from './dpop/jose-utilities';
|
||||
|
||||
export interface PkcePair {
|
||||
readonly verifier: string;
|
||||
readonly challenge: string;
|
||||
readonly method: 'S256';
|
||||
}
|
||||
|
||||
const VERIFIER_BYTE_LENGTH = 32;
|
||||
|
||||
export async function createPkcePair(): Promise<PkcePair> {
|
||||
const verifierBytes = new Uint8Array(VERIFIER_BYTE_LENGTH);
|
||||
crypto.getRandomValues(verifierBytes);
|
||||
|
||||
const verifier = base64UrlEncode(verifierBytes);
|
||||
const challengeBytes = await sha256(new TextEncoder().encode(verifier));
|
||||
const challenge = base64UrlEncode(challengeBytes);
|
||||
|
||||
return {
|
||||
verifier,
|
||||
challenge,
|
||||
method: 'S256',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
/**
|
||||
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
|
||||
*
|
||||
* This is a stub implementation to unblock Graph Explorer development.
|
||||
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
|
||||
*
|
||||
* @see docs/modules/platform/architecture-overview.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* All available StellaOps OAuth2 scopes.
|
||||
*/
|
||||
export const StellaOpsScopes = {
|
||||
// Graph scopes
|
||||
GRAPH_READ: 'graph:read',
|
||||
GRAPH_WRITE: 'graph:write',
|
||||
GRAPH_ADMIN: 'graph:admin',
|
||||
GRAPH_EXPORT: 'graph:export',
|
||||
GRAPH_SIMULATE: 'graph:simulate',
|
||||
|
||||
// SBOM scopes
|
||||
SBOM_READ: 'sbom:read',
|
||||
SBOM_WRITE: 'sbom:write',
|
||||
SBOM_ATTEST: 'sbom:attest',
|
||||
|
||||
// Scanner scopes
|
||||
SCANNER_READ: 'scanner:read',
|
||||
SCANNER_WRITE: 'scanner:write',
|
||||
SCANNER_SCAN: 'scanner:scan',
|
||||
SCANNER_EXPORT: 'scanner:export',
|
||||
|
||||
// Policy scopes (full Policy Studio workflow - UI-POLICY-20-003)
|
||||
POLICY_READ: 'policy:read',
|
||||
POLICY_WRITE: 'policy:write',
|
||||
POLICY_EVALUATE: 'policy:evaluate',
|
||||
POLICY_SIMULATE: 'policy:simulate',
|
||||
// Policy Studio authoring & review workflow
|
||||
POLICY_AUTHOR: 'policy:author',
|
||||
POLICY_EDIT: 'policy:edit',
|
||||
POLICY_REVIEW: 'policy:review',
|
||||
POLICY_SUBMIT: 'policy:submit',
|
||||
POLICY_APPROVE: 'policy:approve',
|
||||
// Policy operations & execution
|
||||
POLICY_OPERATE: 'policy:operate',
|
||||
POLICY_ACTIVATE: 'policy:activate',
|
||||
POLICY_RUN: 'policy:run',
|
||||
POLICY_PUBLISH: 'policy:publish', // Requires interactive auth
|
||||
POLICY_PROMOTE: 'policy:promote', // Requires interactive auth
|
||||
POLICY_AUDIT: 'policy:audit',
|
||||
|
||||
// Exception scopes
|
||||
EXCEPTION_READ: 'exception:read',
|
||||
EXCEPTION_WRITE: 'exception:write',
|
||||
EXCEPTION_APPROVE: 'exception:approve',
|
||||
|
||||
// Advisory scopes
|
||||
ADVISORY_READ: 'advisory:read',
|
||||
|
||||
// VEX scopes
|
||||
VEX_READ: 'vex:read',
|
||||
VEX_EXPORT: 'vex:export',
|
||||
|
||||
/**
|
||||
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
|
||||
*
|
||||
* This is a stub implementation to unblock Graph Explorer development.
|
||||
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
|
||||
*
|
||||
* @see docs/modules/platform/architecture-overview.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* All available StellaOps OAuth2 scopes.
|
||||
*/
|
||||
export const StellaOpsScopes = {
|
||||
// Graph scopes
|
||||
GRAPH_READ: 'graph:read',
|
||||
GRAPH_WRITE: 'graph:write',
|
||||
GRAPH_ADMIN: 'graph:admin',
|
||||
GRAPH_EXPORT: 'graph:export',
|
||||
GRAPH_SIMULATE: 'graph:simulate',
|
||||
|
||||
// SBOM scopes
|
||||
SBOM_READ: 'sbom:read',
|
||||
SBOM_WRITE: 'sbom:write',
|
||||
SBOM_ATTEST: 'sbom:attest',
|
||||
|
||||
// Scanner scopes
|
||||
SCANNER_READ: 'scanner:read',
|
||||
SCANNER_WRITE: 'scanner:write',
|
||||
SCANNER_SCAN: 'scanner:scan',
|
||||
SCANNER_EXPORT: 'scanner:export',
|
||||
|
||||
// Policy scopes (full Policy Studio workflow - UI-POLICY-20-003)
|
||||
POLICY_READ: 'policy:read',
|
||||
POLICY_WRITE: 'policy:write',
|
||||
POLICY_EVALUATE: 'policy:evaluate',
|
||||
POLICY_SIMULATE: 'policy:simulate',
|
||||
// Policy Studio authoring & review workflow
|
||||
POLICY_AUTHOR: 'policy:author',
|
||||
POLICY_EDIT: 'policy:edit',
|
||||
POLICY_REVIEW: 'policy:review',
|
||||
POLICY_SUBMIT: 'policy:submit',
|
||||
POLICY_APPROVE: 'policy:approve',
|
||||
// Policy operations & execution
|
||||
POLICY_OPERATE: 'policy:operate',
|
||||
POLICY_ACTIVATE: 'policy:activate',
|
||||
POLICY_RUN: 'policy:run',
|
||||
POLICY_PUBLISH: 'policy:publish', // Requires interactive auth
|
||||
POLICY_PROMOTE: 'policy:promote', // Requires interactive auth
|
||||
POLICY_AUDIT: 'policy:audit',
|
||||
|
||||
// Exception scopes
|
||||
EXCEPTION_READ: 'exception:read',
|
||||
EXCEPTION_WRITE: 'exception:write',
|
||||
EXCEPTION_APPROVE: 'exception:approve',
|
||||
|
||||
// Advisory scopes
|
||||
ADVISORY_READ: 'advisory:read',
|
||||
|
||||
// VEX scopes
|
||||
VEX_READ: 'vex:read',
|
||||
VEX_EXPORT: 'vex:export',
|
||||
|
||||
// Release scopes
|
||||
RELEASE_READ: 'release:read',
|
||||
RELEASE_WRITE: 'release:write',
|
||||
@@ -72,215 +72,215 @@ export const StellaOpsScopes = {
|
||||
// AOC scopes
|
||||
AOC_READ: 'aoc:read',
|
||||
AOC_VERIFY: 'aoc:verify',
|
||||
|
||||
// Orchestrator scopes (UI-ORCH-32-001)
|
||||
ORCH_READ: 'orch:read',
|
||||
ORCH_OPERATE: 'orch:operate',
|
||||
ORCH_QUOTA: 'orch:quota',
|
||||
ORCH_BACKFILL: 'orch:backfill',
|
||||
|
||||
// UI scopes
|
||||
UI_READ: 'ui.read',
|
||||
UI_ADMIN: 'ui.admin',
|
||||
|
||||
// Admin scopes
|
||||
ADMIN: 'admin',
|
||||
TENANT_ADMIN: 'tenant:admin',
|
||||
|
||||
// Authority admin scopes
|
||||
AUTHORITY_TENANTS_READ: 'authority:tenants.read',
|
||||
AUTHORITY_TENANTS_WRITE: 'authority:tenants.write',
|
||||
AUTHORITY_USERS_READ: 'authority:users.read',
|
||||
AUTHORITY_USERS_WRITE: 'authority:users.write',
|
||||
AUTHORITY_ROLES_READ: 'authority:roles.read',
|
||||
AUTHORITY_ROLES_WRITE: 'authority:roles.write',
|
||||
AUTHORITY_CLIENTS_READ: 'authority:clients.read',
|
||||
AUTHORITY_CLIENTS_WRITE: 'authority:clients.write',
|
||||
AUTHORITY_TOKENS_READ: 'authority:tokens.read',
|
||||
AUTHORITY_TOKENS_REVOKE: 'authority:tokens.revoke',
|
||||
AUTHORITY_BRANDING_READ: 'authority:branding.read',
|
||||
AUTHORITY_BRANDING_WRITE: 'authority:branding.write',
|
||||
AUTHORITY_AUDIT_READ: 'authority:audit.read',
|
||||
|
||||
// Scheduler scopes
|
||||
SCHEDULER_READ: 'scheduler:read',
|
||||
SCHEDULER_OPERATE: 'scheduler:operate',
|
||||
SCHEDULER_ADMIN: 'scheduler:admin',
|
||||
|
||||
// Attestor scopes
|
||||
ATTEST_CREATE: 'attest:create',
|
||||
ATTEST_ADMIN: 'attest:admin',
|
||||
|
||||
// Signer scopes
|
||||
SIGNER_READ: 'signer:read',
|
||||
SIGNER_SIGN: 'signer:sign',
|
||||
SIGNER_ROTATE: 'signer:rotate',
|
||||
SIGNER_ADMIN: 'signer:admin',
|
||||
|
||||
// Zastava scopes
|
||||
ZASTAVA_READ: 'zastava:read',
|
||||
ZASTAVA_TRIGGER: 'zastava:trigger',
|
||||
ZASTAVA_ADMIN: 'zastava:admin',
|
||||
|
||||
// Exceptions scopes
|
||||
EXCEPTIONS_READ: 'exceptions:read',
|
||||
EXCEPTIONS_WRITE: 'exceptions:write',
|
||||
|
||||
// Findings scope
|
||||
FINDINGS_READ: 'findings:read',
|
||||
} as const;
|
||||
|
||||
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
|
||||
|
||||
/**
|
||||
* Scope groupings for common use cases.
|
||||
*/
|
||||
export const ScopeGroups = {
|
||||
GRAPH_VIEWER: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
] as const,
|
||||
|
||||
GRAPH_EDITOR: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.SBOM_WRITE,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
GRAPH_ADMIN: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_ADMIN,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
] as const,
|
||||
|
||||
RELEASE_MANAGER: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.RELEASE_WRITE,
|
||||
StellaOpsScopes.RELEASE_PUBLISH,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
SECURITY_ADMIN: [
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
StellaOpsScopes.EXCEPTION_APPROVE,
|
||||
StellaOpsScopes.RELEASE_BYPASS,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_WRITE,
|
||||
] as const,
|
||||
|
||||
// Orchestrator scope groups (UI-ORCH-32-001)
|
||||
ORCH_VIEWER: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
ORCH_OPERATOR: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
ORCH_ADMIN: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.ORCH_QUOTA,
|
||||
StellaOpsScopes.ORCH_BACKFILL,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
// Policy Studio scope groups (UI-POLICY-20-003)
|
||||
POLICY_VIEWER: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_AUTHOR: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_AUTHOR,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_REVIEWER: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_APPROVER: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_APPROVE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_OPERATOR: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_OPERATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_ADMIN: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_AUTHOR,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_APPROVE,
|
||||
StellaOpsScopes.POLICY_OPERATE,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Human-readable labels for scopes.
|
||||
*/
|
||||
export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||
'graph:read': 'View Graph',
|
||||
'graph:write': 'Edit Graph',
|
||||
'graph:admin': 'Administer Graph',
|
||||
'graph:export': 'Export Graph Data',
|
||||
'graph:simulate': 'Run Graph Simulations',
|
||||
'sbom:read': 'View SBOMs',
|
||||
'sbom:write': 'Create/Edit SBOMs',
|
||||
'sbom:attest': 'Attest SBOMs',
|
||||
'scanner:read': 'View Scan Results',
|
||||
'scanner:write': 'Configure Scanner',
|
||||
'scanner:scan': 'Trigger Scans',
|
||||
'scanner:export': 'Export Scan Results',
|
||||
'policy:read': 'View Policies',
|
||||
'policy:write': 'Edit Policies',
|
||||
'policy:evaluate': 'Evaluate Policies',
|
||||
'policy:simulate': 'Simulate Policy Changes',
|
||||
// Policy Studio workflow scopes (UI-POLICY-20-003)
|
||||
'policy:author': 'Author Policy Drafts',
|
||||
'policy:edit': 'Edit Policy Configuration',
|
||||
'policy:review': 'Review Policy Drafts',
|
||||
'policy:submit': 'Submit Policies for Review',
|
||||
'policy:approve': 'Approve/Reject Policies',
|
||||
'policy:operate': 'Operate Policy Promotions',
|
||||
'policy:activate': 'Activate Policies',
|
||||
'policy:run': 'Trigger Policy Runs',
|
||||
'policy:publish': 'Publish Policy Versions',
|
||||
'policy:promote': 'Promote Between Environments',
|
||||
'policy:audit': 'Audit Policy Activity',
|
||||
'exception:read': 'View Exceptions',
|
||||
'exception:write': 'Create Exceptions',
|
||||
'exception:approve': 'Approve Exceptions',
|
||||
'advisory:read': 'View Advisories',
|
||||
'vex:read': 'View VEX Evidence',
|
||||
'vex:export': 'Export VEX Evidence',
|
||||
|
||||
// Orchestrator scopes (UI-ORCH-32-001)
|
||||
ORCH_READ: 'orch:read',
|
||||
ORCH_OPERATE: 'orch:operate',
|
||||
ORCH_QUOTA: 'orch:quota',
|
||||
ORCH_BACKFILL: 'orch:backfill',
|
||||
|
||||
// UI scopes
|
||||
UI_READ: 'ui.read',
|
||||
UI_ADMIN: 'ui.admin',
|
||||
|
||||
// Admin scopes
|
||||
ADMIN: 'admin',
|
||||
TENANT_ADMIN: 'tenant:admin',
|
||||
|
||||
// Authority admin scopes
|
||||
AUTHORITY_TENANTS_READ: 'authority:tenants.read',
|
||||
AUTHORITY_TENANTS_WRITE: 'authority:tenants.write',
|
||||
AUTHORITY_USERS_READ: 'authority:users.read',
|
||||
AUTHORITY_USERS_WRITE: 'authority:users.write',
|
||||
AUTHORITY_ROLES_READ: 'authority:roles.read',
|
||||
AUTHORITY_ROLES_WRITE: 'authority:roles.write',
|
||||
AUTHORITY_CLIENTS_READ: 'authority:clients.read',
|
||||
AUTHORITY_CLIENTS_WRITE: 'authority:clients.write',
|
||||
AUTHORITY_TOKENS_READ: 'authority:tokens.read',
|
||||
AUTHORITY_TOKENS_REVOKE: 'authority:tokens.revoke',
|
||||
AUTHORITY_BRANDING_READ: 'authority:branding.read',
|
||||
AUTHORITY_BRANDING_WRITE: 'authority:branding.write',
|
||||
AUTHORITY_AUDIT_READ: 'authority:audit.read',
|
||||
|
||||
// Scheduler scopes
|
||||
SCHEDULER_READ: 'scheduler:read',
|
||||
SCHEDULER_OPERATE: 'scheduler:operate',
|
||||
SCHEDULER_ADMIN: 'scheduler:admin',
|
||||
|
||||
// Attestor scopes
|
||||
ATTEST_CREATE: 'attest:create',
|
||||
ATTEST_ADMIN: 'attest:admin',
|
||||
|
||||
// Signer scopes
|
||||
SIGNER_READ: 'signer:read',
|
||||
SIGNER_SIGN: 'signer:sign',
|
||||
SIGNER_ROTATE: 'signer:rotate',
|
||||
SIGNER_ADMIN: 'signer:admin',
|
||||
|
||||
// Zastava scopes
|
||||
ZASTAVA_READ: 'zastava:read',
|
||||
ZASTAVA_TRIGGER: 'zastava:trigger',
|
||||
ZASTAVA_ADMIN: 'zastava:admin',
|
||||
|
||||
// Exceptions scopes
|
||||
EXCEPTIONS_READ: 'exceptions:read',
|
||||
EXCEPTIONS_WRITE: 'exceptions:write',
|
||||
|
||||
// Findings scope
|
||||
FINDINGS_READ: 'findings:read',
|
||||
} as const;
|
||||
|
||||
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
|
||||
|
||||
/**
|
||||
* Scope groupings for common use cases.
|
||||
*/
|
||||
export const ScopeGroups = {
|
||||
GRAPH_VIEWER: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
] as const,
|
||||
|
||||
GRAPH_EDITOR: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.SBOM_WRITE,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
GRAPH_ADMIN: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_ADMIN,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
] as const,
|
||||
|
||||
RELEASE_MANAGER: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.RELEASE_WRITE,
|
||||
StellaOpsScopes.RELEASE_PUBLISH,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
SECURITY_ADMIN: [
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
StellaOpsScopes.EXCEPTION_APPROVE,
|
||||
StellaOpsScopes.RELEASE_BYPASS,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_WRITE,
|
||||
] as const,
|
||||
|
||||
// Orchestrator scope groups (UI-ORCH-32-001)
|
||||
ORCH_VIEWER: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
ORCH_OPERATOR: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
ORCH_ADMIN: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.ORCH_QUOTA,
|
||||
StellaOpsScopes.ORCH_BACKFILL,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
// Policy Studio scope groups (UI-POLICY-20-003)
|
||||
POLICY_VIEWER: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_AUTHOR: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_AUTHOR,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_REVIEWER: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_APPROVER: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_APPROVE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_OPERATOR: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_OPERATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
POLICY_ADMIN: [
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_AUTHOR,
|
||||
StellaOpsScopes.POLICY_REVIEW,
|
||||
StellaOpsScopes.POLICY_APPROVE,
|
||||
StellaOpsScopes.POLICY_OPERATE,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Human-readable labels for scopes.
|
||||
*/
|
||||
export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||
'graph:read': 'View Graph',
|
||||
'graph:write': 'Edit Graph',
|
||||
'graph:admin': 'Administer Graph',
|
||||
'graph:export': 'Export Graph Data',
|
||||
'graph:simulate': 'Run Graph Simulations',
|
||||
'sbom:read': 'View SBOMs',
|
||||
'sbom:write': 'Create/Edit SBOMs',
|
||||
'sbom:attest': 'Attest SBOMs',
|
||||
'scanner:read': 'View Scan Results',
|
||||
'scanner:write': 'Configure Scanner',
|
||||
'scanner:scan': 'Trigger Scans',
|
||||
'scanner:export': 'Export Scan Results',
|
||||
'policy:read': 'View Policies',
|
||||
'policy:write': 'Edit Policies',
|
||||
'policy:evaluate': 'Evaluate Policies',
|
||||
'policy:simulate': 'Simulate Policy Changes',
|
||||
// Policy Studio workflow scopes (UI-POLICY-20-003)
|
||||
'policy:author': 'Author Policy Drafts',
|
||||
'policy:edit': 'Edit Policy Configuration',
|
||||
'policy:review': 'Review Policy Drafts',
|
||||
'policy:submit': 'Submit Policies for Review',
|
||||
'policy:approve': 'Approve/Reject Policies',
|
||||
'policy:operate': 'Operate Policy Promotions',
|
||||
'policy:activate': 'Activate Policies',
|
||||
'policy:run': 'Trigger Policy Runs',
|
||||
'policy:publish': 'Publish Policy Versions',
|
||||
'policy:promote': 'Promote Between Environments',
|
||||
'policy:audit': 'Audit Policy Activity',
|
||||
'exception:read': 'View Exceptions',
|
||||
'exception:write': 'Create Exceptions',
|
||||
'exception:approve': 'Approve Exceptions',
|
||||
'advisory:read': 'View Advisories',
|
||||
'vex:read': 'View VEX Evidence',
|
||||
'vex:export': 'Export VEX Evidence',
|
||||
'release:read': 'View Releases',
|
||||
'release:write': 'Create Releases',
|
||||
'release:publish': 'Publish Releases',
|
||||
@@ -288,82 +288,82 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||
'analytics.read': 'View Analytics',
|
||||
'aoc:read': 'View AOC Status',
|
||||
'aoc:verify': 'Trigger AOC Verification',
|
||||
// Orchestrator scope labels (UI-ORCH-32-001)
|
||||
'orch:read': 'View Orchestrator Jobs',
|
||||
'orch:operate': 'Operate Orchestrator',
|
||||
'orch:quota': 'Manage Orchestrator Quotas',
|
||||
'orch:backfill': 'Initiate Backfill Runs',
|
||||
// UI scope labels
|
||||
'ui.read': 'Console Access',
|
||||
'ui.admin': 'Console Admin Access',
|
||||
// Admin scope labels
|
||||
'admin': 'System Administrator',
|
||||
'tenant:admin': 'Tenant Administrator',
|
||||
// Authority admin scope labels
|
||||
'authority:tenants.read': 'View Tenants',
|
||||
'authority:tenants.write': 'Manage Tenants',
|
||||
'authority:users.read': 'View Users',
|
||||
'authority:users.write': 'Manage Users',
|
||||
'authority:roles.read': 'View Roles',
|
||||
'authority:roles.write': 'Manage Roles',
|
||||
'authority:clients.read': 'View Clients',
|
||||
'authority:clients.write': 'Manage Clients',
|
||||
'authority:tokens.read': 'View Tokens',
|
||||
'authority:tokens.revoke': 'Revoke Tokens',
|
||||
'authority:branding.read': 'View Branding',
|
||||
'authority:branding.write': 'Manage Branding',
|
||||
'authority:audit.read': 'View Audit Log',
|
||||
// Scheduler scope labels
|
||||
'scheduler:read': 'View Scheduler Jobs',
|
||||
'scheduler:operate': 'Operate Scheduler',
|
||||
'scheduler:admin': 'Administer Scheduler',
|
||||
// Attestor scope labels
|
||||
'attest:create': 'Create Attestations',
|
||||
'attest:admin': 'Administer Attestor',
|
||||
// Signer scope labels
|
||||
'signer:read': 'View Signer Configuration',
|
||||
'signer:sign': 'Create Signatures',
|
||||
'signer:rotate': 'Rotate Signing Keys',
|
||||
'signer:admin': 'Administer Signer',
|
||||
// Zastava scope labels
|
||||
'zastava:read': 'View Zastava State',
|
||||
'zastava:trigger': 'Trigger Zastava Processing',
|
||||
'zastava:admin': 'Administer Zastava',
|
||||
// Exception scope labels
|
||||
'exceptions:read': 'View Exceptions',
|
||||
'exceptions:write': 'Create Exceptions',
|
||||
// Findings scope label
|
||||
'findings:read': 'View Policy Findings',
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes a required scope.
|
||||
*/
|
||||
export function hasScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScope: StellaOpsScope
|
||||
): boolean {
|
||||
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes all required scopes.
|
||||
*/
|
||||
export function hasAllScopes(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.every((scope) => userScopes.includes(scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes any of the required scopes.
|
||||
*/
|
||||
export function hasAnyScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.some((scope) => userScopes.includes(scope));
|
||||
}
|
||||
// Orchestrator scope labels (UI-ORCH-32-001)
|
||||
'orch:read': 'View Orchestrator Jobs',
|
||||
'orch:operate': 'Operate Orchestrator',
|
||||
'orch:quota': 'Manage Orchestrator Quotas',
|
||||
'orch:backfill': 'Initiate Backfill Runs',
|
||||
// UI scope labels
|
||||
'ui.read': 'Console Access',
|
||||
'ui.admin': 'Console Admin Access',
|
||||
// Admin scope labels
|
||||
'admin': 'System Administrator',
|
||||
'tenant:admin': 'Tenant Administrator',
|
||||
// Authority admin scope labels
|
||||
'authority:tenants.read': 'View Tenants',
|
||||
'authority:tenants.write': 'Manage Tenants',
|
||||
'authority:users.read': 'View Users',
|
||||
'authority:users.write': 'Manage Users',
|
||||
'authority:roles.read': 'View Roles',
|
||||
'authority:roles.write': 'Manage Roles',
|
||||
'authority:clients.read': 'View Clients',
|
||||
'authority:clients.write': 'Manage Clients',
|
||||
'authority:tokens.read': 'View Tokens',
|
||||
'authority:tokens.revoke': 'Revoke Tokens',
|
||||
'authority:branding.read': 'View Branding',
|
||||
'authority:branding.write': 'Manage Branding',
|
||||
'authority:audit.read': 'View Audit Log',
|
||||
// Scheduler scope labels
|
||||
'scheduler:read': 'View Scheduler Jobs',
|
||||
'scheduler:operate': 'Operate Scheduler',
|
||||
'scheduler:admin': 'Administer Scheduler',
|
||||
// Attestor scope labels
|
||||
'attest:create': 'Create Attestations',
|
||||
'attest:admin': 'Administer Attestor',
|
||||
// Signer scope labels
|
||||
'signer:read': 'View Signer Configuration',
|
||||
'signer:sign': 'Create Signatures',
|
||||
'signer:rotate': 'Rotate Signing Keys',
|
||||
'signer:admin': 'Administer Signer',
|
||||
// Zastava scope labels
|
||||
'zastava:read': 'View Zastava State',
|
||||
'zastava:trigger': 'Trigger Zastava Processing',
|
||||
'zastava:admin': 'Administer Zastava',
|
||||
// Exception scope labels
|
||||
'exceptions:read': 'View Exceptions',
|
||||
'exceptions:write': 'Create Exceptions',
|
||||
// Findings scope label
|
||||
'findings:read': 'View Policy Findings',
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes a required scope.
|
||||
*/
|
||||
export function hasScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScope: StellaOpsScope
|
||||
): boolean {
|
||||
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes all required scopes.
|
||||
*/
|
||||
export function hasAllScopes(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.every((scope) => userScopes.includes(scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes any of the required scopes.
|
||||
*/
|
||||
export function hasAnyScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.some((scope) => userScopes.includes(scope));
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export type DPoPAlgorithm = 'ES256' | 'ES384' | 'EdDSA';
|
||||
|
||||
export interface AuthorityConfig {
|
||||
readonly issuer: string;
|
||||
readonly clientId: string;
|
||||
readonly authorizeEndpoint: string;
|
||||
readonly tokenEndpoint: string;
|
||||
readonly logoutEndpoint?: string;
|
||||
readonly redirectUri: string;
|
||||
readonly postLogoutRedirectUri?: string;
|
||||
readonly scope: string;
|
||||
readonly audience: string;
|
||||
/**
|
||||
* Preferred algorithms for DPoP proofs, in order of preference.
|
||||
* Defaults to ES256 if omitted.
|
||||
*/
|
||||
readonly dpopAlgorithms?: readonly DPoPAlgorithm[];
|
||||
/**
|
||||
* Seconds of leeway before access token expiry that should trigger a proactive refresh.
|
||||
* Defaults to 60.
|
||||
*/
|
||||
readonly refreshLeewaySeconds?: number;
|
||||
}
|
||||
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export type DPoPAlgorithm = 'ES256' | 'ES384' | 'EdDSA';
|
||||
|
||||
export interface AuthorityConfig {
|
||||
readonly issuer: string;
|
||||
readonly clientId: string;
|
||||
readonly authorizeEndpoint: string;
|
||||
readonly tokenEndpoint: string;
|
||||
readonly logoutEndpoint?: string;
|
||||
readonly redirectUri: string;
|
||||
readonly postLogoutRedirectUri?: string;
|
||||
readonly scope: string;
|
||||
readonly audience: string;
|
||||
/**
|
||||
* Preferred algorithms for DPoP proofs, in order of preference.
|
||||
* Defaults to ES256 if omitted.
|
||||
*/
|
||||
readonly dpopAlgorithms?: readonly DPoPAlgorithm[];
|
||||
/**
|
||||
* Seconds of leeway before access token expiry that should trigger a proactive refresh.
|
||||
* Defaults to 60.
|
||||
*/
|
||||
readonly refreshLeewaySeconds?: number;
|
||||
}
|
||||
|
||||
export interface ApiBaseUrlConfig {
|
||||
/**
|
||||
* Optional API gateway base URL for cross-cutting endpoints.
|
||||
@@ -38,11 +38,11 @@ export interface ApiBaseUrlConfig {
|
||||
readonly concelier: string;
|
||||
readonly excitor?: string;
|
||||
readonly attestor: string;
|
||||
readonly authority: string;
|
||||
readonly notify?: string;
|
||||
readonly scheduler?: string;
|
||||
}
|
||||
|
||||
readonly authority: string;
|
||||
readonly notify?: string;
|
||||
readonly scheduler?: string;
|
||||
}
|
||||
|
||||
export interface TelemetryConfig {
|
||||
readonly otlpEndpoint?: string;
|
||||
readonly sampleRate?: number;
|
||||
|
||||
@@ -6,15 +6,15 @@ import {
|
||||
computed,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
APP_CONFIG,
|
||||
AppConfig,
|
||||
AuthorityConfig,
|
||||
DPoPAlgorithm,
|
||||
} from './app-config.model';
|
||||
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
APP_CONFIG,
|
||||
AppConfig,
|
||||
AuthorityConfig,
|
||||
DPoPAlgorithm,
|
||||
} from './app-config.model';
|
||||
|
||||
const DEFAULT_CONFIG_URL = '/config.json';
|
||||
const DEFAULT_DPOP_ALG: DPoPAlgorithm = 'ES256';
|
||||
const DEFAULT_REFRESH_LEEWAY_SECONDS = 60;
|
||||
@@ -41,53 +41,53 @@ export class AppConfigService {
|
||||
// that themselves depend on AppConfigService.
|
||||
this.http = new HttpClient(httpBackend);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads application configuration either from the injected static value or via HTTP fetch.
|
||||
* Must be called during application bootstrap (see APP_INITIALIZER wiring).
|
||||
*/
|
||||
async load(configUrl: string = DEFAULT_CONFIG_URL): Promise<void> {
|
||||
if (this.configSignal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.staticConfig ?? (await this.fetchConfig(configUrl));
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows tests to short-circuit configuration loading.
|
||||
*/
|
||||
setConfigForTesting(config: AppConfig): void {
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
}
|
||||
|
||||
get config(): AppConfig {
|
||||
const current = this.configSignal();
|
||||
if (!current) {
|
||||
throw new Error('App configuration has not been loaded yet.');
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
get authority(): AuthorityConfig {
|
||||
const authority = this.authoritySignal();
|
||||
if (!authority) {
|
||||
throw new Error('Authority configuration has not been loaded yet.');
|
||||
}
|
||||
return authority;
|
||||
}
|
||||
|
||||
private async fetchConfig(configUrl: string): Promise<AppConfig> {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<AppConfig>(configUrl, {
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
withCredentials: false,
|
||||
})
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Loads application configuration either from the injected static value or via HTTP fetch.
|
||||
* Must be called during application bootstrap (see APP_INITIALIZER wiring).
|
||||
*/
|
||||
async load(configUrl: string = DEFAULT_CONFIG_URL): Promise<void> {
|
||||
if (this.configSignal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.staticConfig ?? (await this.fetchConfig(configUrl));
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows tests to short-circuit configuration loading.
|
||||
*/
|
||||
setConfigForTesting(config: AppConfig): void {
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
}
|
||||
|
||||
get config(): AppConfig {
|
||||
const current = this.configSignal();
|
||||
if (!current) {
|
||||
throw new Error('App configuration has not been loaded yet.');
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
get authority(): AuthorityConfig {
|
||||
const authority = this.authoritySignal();
|
||||
if (!authority) {
|
||||
throw new Error('Authority configuration has not been loaded yet.');
|
||||
}
|
||||
return authority;
|
||||
}
|
||||
|
||||
private async fetchConfig(configUrl: string): Promise<AppConfig> {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<AppConfig>(configUrl, {
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
withCredentials: false,
|
||||
})
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
private normalizeConfig(config: AppConfig): AppConfig {
|
||||
const authority = {
|
||||
...config.authority,
|
||||
|
||||
@@ -1,139 +1,139 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import {
|
||||
AUTHORITY_CONSOLE_API,
|
||||
AuthorityConsoleApi,
|
||||
TenantCatalogResponseDto,
|
||||
} from '../api/authority-console.client';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { ConsoleSessionService } from './console-session.service';
|
||||
import { ConsoleSessionStore } from './console-session.store';
|
||||
|
||||
class MockConsoleApi implements AuthorityConsoleApi {
|
||||
private createTenantResponse(): TenantCatalogResponseDto {
|
||||
return {
|
||||
tenants: [
|
||||
{
|
||||
id: 'tenant-default',
|
||||
displayName: 'Tenant Default',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.console'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
listTenants() {
|
||||
return of(this.createTenantResponse());
|
||||
}
|
||||
|
||||
getProfile() {
|
||||
return of({
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'Console User',
|
||||
tenant: 'tenant-default',
|
||||
sessionId: 'session-1',
|
||||
roles: ['role.console'],
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: '2025-10-31T12:00:00Z',
|
||||
authenticationTime: '2025-10-31T12:00:00Z',
|
||||
expiresAt: '2025-10-31T12:10:00Z',
|
||||
freshAuth: true,
|
||||
});
|
||||
}
|
||||
|
||||
introspectToken() {
|
||||
return of({
|
||||
active: true,
|
||||
tenant: 'tenant-default',
|
||||
subject: 'user-1',
|
||||
clientId: 'console-web',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
issuedAt: '2025-10-31T12:00:00Z',
|
||||
authenticationTime: '2025-10-31T12:00:00Z',
|
||||
expiresAt: '2025-10-31T12:10:00Z',
|
||||
freshAuth: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class MockAuthSessionStore {
|
||||
private tenantIdValue: string | null = 'tenant-default';
|
||||
private readonly sessionValue = {
|
||||
tenantId: 'tenant-default',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationTimeEpochMs: Date.parse('2025-10-31T12:00:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAtEpochMs: Date.parse('2025-10-31T12:05:00Z'),
|
||||
};
|
||||
|
||||
session = () => this.sessionValue as any;
|
||||
|
||||
getActiveTenantId(): string | null {
|
||||
return this.tenantIdValue;
|
||||
}
|
||||
|
||||
setTenantId(tenantId: string | null): void {
|
||||
this.tenantIdValue = tenantId;
|
||||
this.sessionValue.tenantId = tenantId ?? 'tenant-default';
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConsoleSessionService', () => {
|
||||
let service: ConsoleSessionService;
|
||||
let store: ConsoleSessionStore;
|
||||
let authStore: MockAuthSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ConsoleSessionStore,
|
||||
ConsoleSessionService,
|
||||
{ provide: AUTHORITY_CONSOLE_API, useClass: MockConsoleApi },
|
||||
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(ConsoleSessionService);
|
||||
store = TestBed.inject(ConsoleSessionStore);
|
||||
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
|
||||
});
|
||||
|
||||
it('loads console context for active tenant', async () => {
|
||||
await service.loadConsoleContext();
|
||||
|
||||
expect(store.tenants().length).toBe(1);
|
||||
expect(store.selectedTenantId()).toBe('tenant-default');
|
||||
expect(store.profile()?.displayName).toBe('Console User');
|
||||
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
||||
});
|
||||
|
||||
it('clears store when no tenant available', async () => {
|
||||
authStore.setTenantId(null);
|
||||
store.setTenants(
|
||||
[
|
||||
{
|
||||
id: 'existing',
|
||||
displayName: 'Existing',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: [],
|
||||
},
|
||||
],
|
||||
'existing'
|
||||
);
|
||||
|
||||
await service.loadConsoleContext();
|
||||
|
||||
expect(store.tenants().length).toBe(0);
|
||||
expect(store.selectedTenantId()).toBeNull();
|
||||
});
|
||||
});
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import {
|
||||
AUTHORITY_CONSOLE_API,
|
||||
AuthorityConsoleApi,
|
||||
TenantCatalogResponseDto,
|
||||
} from '../api/authority-console.client';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { ConsoleSessionService } from './console-session.service';
|
||||
import { ConsoleSessionStore } from './console-session.store';
|
||||
|
||||
class MockConsoleApi implements AuthorityConsoleApi {
|
||||
private createTenantResponse(): TenantCatalogResponseDto {
|
||||
return {
|
||||
tenants: [
|
||||
{
|
||||
id: 'tenant-default',
|
||||
displayName: 'Tenant Default',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.console'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
listTenants() {
|
||||
return of(this.createTenantResponse());
|
||||
}
|
||||
|
||||
getProfile() {
|
||||
return of({
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'Console User',
|
||||
tenant: 'tenant-default',
|
||||
sessionId: 'session-1',
|
||||
roles: ['role.console'],
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: '2025-10-31T12:00:00Z',
|
||||
authenticationTime: '2025-10-31T12:00:00Z',
|
||||
expiresAt: '2025-10-31T12:10:00Z',
|
||||
freshAuth: true,
|
||||
});
|
||||
}
|
||||
|
||||
introspectToken() {
|
||||
return of({
|
||||
active: true,
|
||||
tenant: 'tenant-default',
|
||||
subject: 'user-1',
|
||||
clientId: 'console-web',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
issuedAt: '2025-10-31T12:00:00Z',
|
||||
authenticationTime: '2025-10-31T12:00:00Z',
|
||||
expiresAt: '2025-10-31T12:10:00Z',
|
||||
freshAuth: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class MockAuthSessionStore {
|
||||
private tenantIdValue: string | null = 'tenant-default';
|
||||
private readonly sessionValue = {
|
||||
tenantId: 'tenant-default',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationTimeEpochMs: Date.parse('2025-10-31T12:00:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAtEpochMs: Date.parse('2025-10-31T12:05:00Z'),
|
||||
};
|
||||
|
||||
session = () => this.sessionValue as any;
|
||||
|
||||
getActiveTenantId(): string | null {
|
||||
return this.tenantIdValue;
|
||||
}
|
||||
|
||||
setTenantId(tenantId: string | null): void {
|
||||
this.tenantIdValue = tenantId;
|
||||
this.sessionValue.tenantId = tenantId ?? 'tenant-default';
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConsoleSessionService', () => {
|
||||
let service: ConsoleSessionService;
|
||||
let store: ConsoleSessionStore;
|
||||
let authStore: MockAuthSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ConsoleSessionStore,
|
||||
ConsoleSessionService,
|
||||
{ provide: AUTHORITY_CONSOLE_API, useClass: MockConsoleApi },
|
||||
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(ConsoleSessionService);
|
||||
store = TestBed.inject(ConsoleSessionStore);
|
||||
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
|
||||
});
|
||||
|
||||
it('loads console context for active tenant', async () => {
|
||||
await service.loadConsoleContext();
|
||||
|
||||
expect(store.tenants().length).toBe(1);
|
||||
expect(store.selectedTenantId()).toBe('tenant-default');
|
||||
expect(store.profile()?.displayName).toBe('Console User');
|
||||
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
||||
});
|
||||
|
||||
it('clears store when no tenant available', async () => {
|
||||
authStore.setTenantId(null);
|
||||
store.setTenants(
|
||||
[
|
||||
{
|
||||
id: 'existing',
|
||||
displayName: 'Existing',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: [],
|
||||
},
|
||||
],
|
||||
'existing'
|
||||
);
|
||||
|
||||
await service.loadConsoleContext();
|
||||
|
||||
expect(store.tenants().length).toBe(0);
|
||||
expect(store.selectedTenantId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,161 +1,161 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
AUTHORITY_CONSOLE_API,
|
||||
AuthorityConsoleApi,
|
||||
AuthorityTenantViewDto,
|
||||
ConsoleProfileDto,
|
||||
ConsoleTokenIntrospectionDto,
|
||||
} from '../api/authority-console.client';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import {
|
||||
ConsoleProfile,
|
||||
ConsoleSessionStore,
|
||||
ConsoleTenant,
|
||||
ConsoleTokenInfo,
|
||||
} from './console-session.store';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsoleSessionService {
|
||||
private readonly api = inject<AuthorityConsoleApi>(AUTHORITY_CONSOLE_API);
|
||||
private readonly store = inject(ConsoleSessionStore);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
|
||||
async loadConsoleContext(tenantId?: string | null): Promise<void> {
|
||||
const activeTenant =
|
||||
(tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||
|
||||
if (!activeTenant) {
|
||||
this.store.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.setSelectedTenant(activeTenant);
|
||||
this.store.setLoading(true);
|
||||
this.store.setError(null);
|
||||
|
||||
try {
|
||||
const tenantResponse = await firstValueFrom(
|
||||
this.api.listTenants(activeTenant)
|
||||
);
|
||||
const tenants = (tenantResponse.tenants ?? []).map((tenant) =>
|
||||
this.mapTenant(tenant)
|
||||
);
|
||||
|
||||
const [profileDto, tokenDto] = await Promise.all([
|
||||
firstValueFrom(this.api.getProfile(activeTenant)),
|
||||
firstValueFrom(this.api.introspectToken(activeTenant)),
|
||||
]);
|
||||
|
||||
const profile = this.mapProfile(profileDto);
|
||||
const tokenInfo = this.mapTokenInfo(tokenDto);
|
||||
|
||||
this.store.setContext({
|
||||
tenants,
|
||||
profile,
|
||||
token: tokenInfo,
|
||||
selectedTenantId: activeTenant,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load console context', error);
|
||||
this.store.setError('Unable to load console context.');
|
||||
} finally {
|
||||
this.store.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async switchTenant(tenantId: string): Promise<void> {
|
||||
if (!tenantId || tenantId === this.store.selectedTenantId()) {
|
||||
return this.loadConsoleContext(tenantId);
|
||||
}
|
||||
|
||||
this.store.setSelectedTenant(tenantId);
|
||||
await this.loadConsoleContext(tenantId);
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
await this.loadConsoleContext(this.store.selectedTenantId());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
private mapTenant(dto: AuthorityTenantViewDto): ConsoleTenant {
|
||||
const roles = Array.isArray(dto.defaultRoles)
|
||||
? dto.defaultRoles
|
||||
.map((role) => role.trim())
|
||||
.filter((role) => role.length > 0)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: dto.id,
|
||||
displayName: dto.displayName || dto.id,
|
||||
status: dto.status ?? 'active',
|
||||
isolationMode: dto.isolationMode ?? 'shared',
|
||||
defaultRoles: roles,
|
||||
};
|
||||
}
|
||||
|
||||
private mapProfile(dto: ConsoleProfileDto): ConsoleProfile {
|
||||
return {
|
||||
subjectId: dto.subjectId ?? null,
|
||||
username: dto.username ?? null,
|
||||
displayName: dto.displayName ?? dto.username ?? dto.subjectId ?? null,
|
||||
tenant: dto.tenant,
|
||||
sessionId: dto.sessionId ?? null,
|
||||
roles: [...(dto.roles ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
),
|
||||
authenticationMethods: [...(dto.authenticationMethods ?? [])],
|
||||
issuedAt: this.parseInstant(dto.issuedAt),
|
||||
authenticationTime: this.parseInstant(dto.authenticationTime),
|
||||
expiresAt: this.parseInstant(dto.expiresAt),
|
||||
freshAuth: !!dto.freshAuth,
|
||||
};
|
||||
}
|
||||
|
||||
private mapTokenInfo(dto: ConsoleTokenIntrospectionDto): ConsoleTokenInfo {
|
||||
const session = this.authSession.session();
|
||||
const freshAuthExpiresAt =
|
||||
session?.freshAuthExpiresAtEpochMs != null
|
||||
? new Date(session.freshAuthExpiresAtEpochMs)
|
||||
: null;
|
||||
|
||||
const authenticationTime =
|
||||
session?.authenticationTimeEpochMs != null
|
||||
? new Date(session.authenticationTimeEpochMs)
|
||||
: this.parseInstant(dto.authenticationTime);
|
||||
|
||||
return {
|
||||
active: !!dto.active,
|
||||
tenant: dto.tenant,
|
||||
subject: dto.subject ?? null,
|
||||
clientId: dto.clientId ?? null,
|
||||
tokenId: dto.tokenId ?? null,
|
||||
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
),
|
||||
issuedAt: this.parseInstant(dto.issuedAt),
|
||||
authenticationTime,
|
||||
expiresAt: this.parseInstant(dto.expiresAt),
|
||||
freshAuthActive: session?.freshAuthActive ?? !!dto.freshAuth,
|
||||
freshAuthExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
private parseInstant(value: string | null | undefined): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
}
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
AUTHORITY_CONSOLE_API,
|
||||
AuthorityConsoleApi,
|
||||
AuthorityTenantViewDto,
|
||||
ConsoleProfileDto,
|
||||
ConsoleTokenIntrospectionDto,
|
||||
} from '../api/authority-console.client';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import {
|
||||
ConsoleProfile,
|
||||
ConsoleSessionStore,
|
||||
ConsoleTenant,
|
||||
ConsoleTokenInfo,
|
||||
} from './console-session.store';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsoleSessionService {
|
||||
private readonly api = inject<AuthorityConsoleApi>(AUTHORITY_CONSOLE_API);
|
||||
private readonly store = inject(ConsoleSessionStore);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
|
||||
async loadConsoleContext(tenantId?: string | null): Promise<void> {
|
||||
const activeTenant =
|
||||
(tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||
|
||||
if (!activeTenant) {
|
||||
this.store.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.setSelectedTenant(activeTenant);
|
||||
this.store.setLoading(true);
|
||||
this.store.setError(null);
|
||||
|
||||
try {
|
||||
const tenantResponse = await firstValueFrom(
|
||||
this.api.listTenants(activeTenant)
|
||||
);
|
||||
const tenants = (tenantResponse.tenants ?? []).map((tenant) =>
|
||||
this.mapTenant(tenant)
|
||||
);
|
||||
|
||||
const [profileDto, tokenDto] = await Promise.all([
|
||||
firstValueFrom(this.api.getProfile(activeTenant)),
|
||||
firstValueFrom(this.api.introspectToken(activeTenant)),
|
||||
]);
|
||||
|
||||
const profile = this.mapProfile(profileDto);
|
||||
const tokenInfo = this.mapTokenInfo(tokenDto);
|
||||
|
||||
this.store.setContext({
|
||||
tenants,
|
||||
profile,
|
||||
token: tokenInfo,
|
||||
selectedTenantId: activeTenant,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load console context', error);
|
||||
this.store.setError('Unable to load console context.');
|
||||
} finally {
|
||||
this.store.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async switchTenant(tenantId: string): Promise<void> {
|
||||
if (!tenantId || tenantId === this.store.selectedTenantId()) {
|
||||
return this.loadConsoleContext(tenantId);
|
||||
}
|
||||
|
||||
this.store.setSelectedTenant(tenantId);
|
||||
await this.loadConsoleContext(tenantId);
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
await this.loadConsoleContext(this.store.selectedTenantId());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
private mapTenant(dto: AuthorityTenantViewDto): ConsoleTenant {
|
||||
const roles = Array.isArray(dto.defaultRoles)
|
||||
? dto.defaultRoles
|
||||
.map((role) => role.trim())
|
||||
.filter((role) => role.length > 0)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: dto.id,
|
||||
displayName: dto.displayName || dto.id,
|
||||
status: dto.status ?? 'active',
|
||||
isolationMode: dto.isolationMode ?? 'shared',
|
||||
defaultRoles: roles,
|
||||
};
|
||||
}
|
||||
|
||||
private mapProfile(dto: ConsoleProfileDto): ConsoleProfile {
|
||||
return {
|
||||
subjectId: dto.subjectId ?? null,
|
||||
username: dto.username ?? null,
|
||||
displayName: dto.displayName ?? dto.username ?? dto.subjectId ?? null,
|
||||
tenant: dto.tenant,
|
||||
sessionId: dto.sessionId ?? null,
|
||||
roles: [...(dto.roles ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
),
|
||||
authenticationMethods: [...(dto.authenticationMethods ?? [])],
|
||||
issuedAt: this.parseInstant(dto.issuedAt),
|
||||
authenticationTime: this.parseInstant(dto.authenticationTime),
|
||||
expiresAt: this.parseInstant(dto.expiresAt),
|
||||
freshAuth: !!dto.freshAuth,
|
||||
};
|
||||
}
|
||||
|
||||
private mapTokenInfo(dto: ConsoleTokenIntrospectionDto): ConsoleTokenInfo {
|
||||
const session = this.authSession.session();
|
||||
const freshAuthExpiresAt =
|
||||
session?.freshAuthExpiresAtEpochMs != null
|
||||
? new Date(session.freshAuthExpiresAtEpochMs)
|
||||
: null;
|
||||
|
||||
const authenticationTime =
|
||||
session?.authenticationTimeEpochMs != null
|
||||
? new Date(session.authenticationTimeEpochMs)
|
||||
: this.parseInstant(dto.authenticationTime);
|
||||
|
||||
return {
|
||||
active: !!dto.active,
|
||||
tenant: dto.tenant,
|
||||
subject: dto.subject ?? null,
|
||||
clientId: dto.clientId ?? null,
|
||||
tokenId: dto.tokenId ?? null,
|
||||
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
),
|
||||
issuedAt: this.parseInstant(dto.issuedAt),
|
||||
authenticationTime,
|
||||
expiresAt: this.parseInstant(dto.expiresAt),
|
||||
freshAuthActive: session?.freshAuthActive ?? !!dto.freshAuth,
|
||||
freshAuthExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
private parseInstant(value: string | null | undefined): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +1,123 @@
|
||||
import { ConsoleSessionStore } from './console-session.store';
|
||||
|
||||
describe('ConsoleSessionStore', () => {
|
||||
let store: ConsoleSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new ConsoleSessionStore();
|
||||
});
|
||||
|
||||
it('tracks tenants and selection', () => {
|
||||
const tenants = [
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.a'],
|
||||
},
|
||||
{
|
||||
id: 'tenant-b',
|
||||
displayName: 'Tenant B',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.b'],
|
||||
},
|
||||
];
|
||||
|
||||
const selected = store.setTenants(tenants, 'tenant-b');
|
||||
expect(selected).toBe('tenant-b');
|
||||
expect(store.selectedTenantId()).toBe('tenant-b');
|
||||
expect(store.tenants().length).toBe(2);
|
||||
});
|
||||
|
||||
it('sets context with profile and token info', () => {
|
||||
const tenants = [
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.a'],
|
||||
},
|
||||
];
|
||||
|
||||
store.setContext({
|
||||
tenants,
|
||||
selectedTenantId: 'tenant-a',
|
||||
profile: {
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'User Example',
|
||||
tenant: 'tenant-a',
|
||||
sessionId: 'session-123',
|
||||
roles: ['role.a'],
|
||||
scopes: ['scope.a'],
|
||||
audiences: ['aud'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuth: true,
|
||||
},
|
||||
token: {
|
||||
active: true,
|
||||
tenant: 'tenant-a',
|
||||
subject: 'user-1',
|
||||
clientId: 'client',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['scope.a'],
|
||||
audiences: ['aud'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.selectedTenantId()).toBe('tenant-a');
|
||||
expect(store.profile()?.displayName).toBe('User Example');
|
||||
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
||||
expect(store.hasContext()).toBeTrue();
|
||||
});
|
||||
|
||||
it('clears state', () => {
|
||||
store.setTenants(
|
||||
[
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: [],
|
||||
},
|
||||
],
|
||||
'tenant-a'
|
||||
);
|
||||
store.setProfile({
|
||||
subjectId: null,
|
||||
username: null,
|
||||
displayName: null,
|
||||
tenant: 'tenant-a',
|
||||
sessionId: null,
|
||||
roles: [],
|
||||
scopes: [],
|
||||
audiences: [],
|
||||
authenticationMethods: [],
|
||||
issuedAt: null,
|
||||
authenticationTime: null,
|
||||
expiresAt: null,
|
||||
freshAuth: false,
|
||||
});
|
||||
|
||||
store.clear();
|
||||
|
||||
expect(store.tenants().length).toBe(0);
|
||||
expect(store.selectedTenantId()).toBeNull();
|
||||
expect(store.profile()).toBeNull();
|
||||
expect(store.tokenInfo()).toBeNull();
|
||||
expect(store.loading()).toBeFalse();
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
});
|
||||
import { ConsoleSessionStore } from './console-session.store';
|
||||
|
||||
describe('ConsoleSessionStore', () => {
|
||||
let store: ConsoleSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new ConsoleSessionStore();
|
||||
});
|
||||
|
||||
it('tracks tenants and selection', () => {
|
||||
const tenants = [
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.a'],
|
||||
},
|
||||
{
|
||||
id: 'tenant-b',
|
||||
displayName: 'Tenant B',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.b'],
|
||||
},
|
||||
];
|
||||
|
||||
const selected = store.setTenants(tenants, 'tenant-b');
|
||||
expect(selected).toBe('tenant-b');
|
||||
expect(store.selectedTenantId()).toBe('tenant-b');
|
||||
expect(store.tenants().length).toBe(2);
|
||||
});
|
||||
|
||||
it('sets context with profile and token info', () => {
|
||||
const tenants = [
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.a'],
|
||||
},
|
||||
];
|
||||
|
||||
store.setContext({
|
||||
tenants,
|
||||
selectedTenantId: 'tenant-a',
|
||||
profile: {
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'User Example',
|
||||
tenant: 'tenant-a',
|
||||
sessionId: 'session-123',
|
||||
roles: ['role.a'],
|
||||
scopes: ['scope.a'],
|
||||
audiences: ['aud'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuth: true,
|
||||
},
|
||||
token: {
|
||||
active: true,
|
||||
tenant: 'tenant-a',
|
||||
subject: 'user-1',
|
||||
clientId: 'client',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['scope.a'],
|
||||
audiences: ['aud'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.selectedTenantId()).toBe('tenant-a');
|
||||
expect(store.profile()?.displayName).toBe('User Example');
|
||||
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
||||
expect(store.hasContext()).toBeTrue();
|
||||
});
|
||||
|
||||
it('clears state', () => {
|
||||
store.setTenants(
|
||||
[
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: [],
|
||||
},
|
||||
],
|
||||
'tenant-a'
|
||||
);
|
||||
store.setProfile({
|
||||
subjectId: null,
|
||||
username: null,
|
||||
displayName: null,
|
||||
tenant: 'tenant-a',
|
||||
sessionId: null,
|
||||
roles: [],
|
||||
scopes: [],
|
||||
audiences: [],
|
||||
authenticationMethods: [],
|
||||
issuedAt: null,
|
||||
authenticationTime: null,
|
||||
expiresAt: null,
|
||||
freshAuth: false,
|
||||
});
|
||||
|
||||
store.clear();
|
||||
|
||||
expect(store.tenants().length).toBe(0);
|
||||
expect(store.selectedTenantId()).toBeNull();
|
||||
expect(store.profile()).toBeNull();
|
||||
expect(store.tokenInfo()).toBeNull();
|
||||
expect(store.loading()).toBeFalse();
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,137 +1,137 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
export interface ConsoleTenant {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly status: string;
|
||||
readonly isolationMode: string;
|
||||
readonly defaultRoles: readonly string[];
|
||||
}
|
||||
|
||||
export interface ConsoleProfile {
|
||||
readonly subjectId: string | null;
|
||||
readonly username: string | null;
|
||||
readonly displayName: string | null;
|
||||
readonly tenant: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationMethods: readonly string[];
|
||||
readonly issuedAt: Date | null;
|
||||
readonly authenticationTime: Date | null;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleTokenInfo {
|
||||
readonly active: boolean;
|
||||
readonly tenant: string;
|
||||
readonly subject: string | null;
|
||||
readonly clientId: string | null;
|
||||
readonly tokenId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly issuedAt: Date | null;
|
||||
readonly authenticationTime: Date | null;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly freshAuthActive: boolean;
|
||||
readonly freshAuthExpiresAt: Date | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsoleSessionStore {
|
||||
private readonly tenantsSignal = signal<ConsoleTenant[]>([]);
|
||||
private readonly selectedTenantIdSignal = signal<string | null>(null);
|
||||
private readonly profileSignal = signal<ConsoleProfile | null>(null);
|
||||
private readonly tokenSignal = signal<ConsoleTokenInfo | null>(null);
|
||||
private readonly loadingSignal = signal(false);
|
||||
private readonly errorSignal = signal<string | null>(null);
|
||||
|
||||
readonly tenants = computed(() => this.tenantsSignal());
|
||||
readonly selectedTenantId = computed(() => this.selectedTenantIdSignal());
|
||||
readonly profile = computed(() => this.profileSignal());
|
||||
readonly tokenInfo = computed(() => this.tokenSignal());
|
||||
readonly loading = computed(() => this.loadingSignal());
|
||||
readonly error = computed(() => this.errorSignal());
|
||||
readonly currentTenant = computed(() => {
|
||||
const tenantId = this.selectedTenantIdSignal();
|
||||
if (!tenantId) return null;
|
||||
return this.tenantsSignal().find(tenant => tenant.id === tenantId) ?? null;
|
||||
});
|
||||
readonly hasContext = computed(
|
||||
() =>
|
||||
this.tenantsSignal().length > 0 ||
|
||||
this.profileSignal() !== null ||
|
||||
this.tokenSignal() !== null
|
||||
);
|
||||
|
||||
setLoading(loading: boolean): void {
|
||||
this.loadingSignal.set(loading);
|
||||
}
|
||||
|
||||
setError(message: string | null): void {
|
||||
this.errorSignal.set(message);
|
||||
}
|
||||
|
||||
setContext(context: {
|
||||
tenants: ConsoleTenant[];
|
||||
profile: ConsoleProfile | null;
|
||||
token: ConsoleTokenInfo | null;
|
||||
selectedTenantId?: string | null;
|
||||
}): void {
|
||||
const selected = this.setTenants(context.tenants, context.selectedTenantId);
|
||||
this.profileSignal.set(context.profile);
|
||||
this.tokenSignal.set(context.token);
|
||||
this.selectedTenantIdSignal.set(selected);
|
||||
}
|
||||
|
||||
setProfile(profile: ConsoleProfile | null): void {
|
||||
this.profileSignal.set(profile);
|
||||
}
|
||||
|
||||
setTokenInfo(token: ConsoleTokenInfo | null): void {
|
||||
this.tokenSignal.set(token);
|
||||
}
|
||||
|
||||
setTenants(
|
||||
tenants: ConsoleTenant[],
|
||||
preferredTenantId?: string | null
|
||||
): string | null {
|
||||
this.tenantsSignal.set(tenants);
|
||||
const currentSelection = this.selectedTenantIdSignal();
|
||||
const fallbackSelection =
|
||||
tenants.length > 0 ? tenants[0].id : null;
|
||||
|
||||
const nextSelection =
|
||||
(preferredTenantId &&
|
||||
tenants.some((tenant) => tenant.id === preferredTenantId) &&
|
||||
preferredTenantId) ||
|
||||
(currentSelection &&
|
||||
tenants.some((tenant) => tenant.id === currentSelection) &&
|
||||
currentSelection) ||
|
||||
fallbackSelection;
|
||||
|
||||
this.selectedTenantIdSignal.set(nextSelection);
|
||||
return nextSelection;
|
||||
}
|
||||
|
||||
setSelectedTenant(tenantId: string | null): void {
|
||||
this.selectedTenantIdSignal.set(tenantId);
|
||||
}
|
||||
|
||||
currentTenantSnapshot(): ConsoleTenant | null {
|
||||
return this.currentTenant();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tenantsSignal.set([]);
|
||||
this.selectedTenantIdSignal.set(null);
|
||||
this.profileSignal.set(null);
|
||||
this.tokenSignal.set(null);
|
||||
this.loadingSignal.set(false);
|
||||
this.errorSignal.set(null);
|
||||
}
|
||||
}
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
export interface ConsoleTenant {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly status: string;
|
||||
readonly isolationMode: string;
|
||||
readonly defaultRoles: readonly string[];
|
||||
}
|
||||
|
||||
export interface ConsoleProfile {
|
||||
readonly subjectId: string | null;
|
||||
readonly username: string | null;
|
||||
readonly displayName: string | null;
|
||||
readonly tenant: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationMethods: readonly string[];
|
||||
readonly issuedAt: Date | null;
|
||||
readonly authenticationTime: Date | null;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleTokenInfo {
|
||||
readonly active: boolean;
|
||||
readonly tenant: string;
|
||||
readonly subject: string | null;
|
||||
readonly clientId: string | null;
|
||||
readonly tokenId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly issuedAt: Date | null;
|
||||
readonly authenticationTime: Date | null;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly freshAuthActive: boolean;
|
||||
readonly freshAuthExpiresAt: Date | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsoleSessionStore {
|
||||
private readonly tenantsSignal = signal<ConsoleTenant[]>([]);
|
||||
private readonly selectedTenantIdSignal = signal<string | null>(null);
|
||||
private readonly profileSignal = signal<ConsoleProfile | null>(null);
|
||||
private readonly tokenSignal = signal<ConsoleTokenInfo | null>(null);
|
||||
private readonly loadingSignal = signal(false);
|
||||
private readonly errorSignal = signal<string | null>(null);
|
||||
|
||||
readonly tenants = computed(() => this.tenantsSignal());
|
||||
readonly selectedTenantId = computed(() => this.selectedTenantIdSignal());
|
||||
readonly profile = computed(() => this.profileSignal());
|
||||
readonly tokenInfo = computed(() => this.tokenSignal());
|
||||
readonly loading = computed(() => this.loadingSignal());
|
||||
readonly error = computed(() => this.errorSignal());
|
||||
readonly currentTenant = computed(() => {
|
||||
const tenantId = this.selectedTenantIdSignal();
|
||||
if (!tenantId) return null;
|
||||
return this.tenantsSignal().find(tenant => tenant.id === tenantId) ?? null;
|
||||
});
|
||||
readonly hasContext = computed(
|
||||
() =>
|
||||
this.tenantsSignal().length > 0 ||
|
||||
this.profileSignal() !== null ||
|
||||
this.tokenSignal() !== null
|
||||
);
|
||||
|
||||
setLoading(loading: boolean): void {
|
||||
this.loadingSignal.set(loading);
|
||||
}
|
||||
|
||||
setError(message: string | null): void {
|
||||
this.errorSignal.set(message);
|
||||
}
|
||||
|
||||
setContext(context: {
|
||||
tenants: ConsoleTenant[];
|
||||
profile: ConsoleProfile | null;
|
||||
token: ConsoleTokenInfo | null;
|
||||
selectedTenantId?: string | null;
|
||||
}): void {
|
||||
const selected = this.setTenants(context.tenants, context.selectedTenantId);
|
||||
this.profileSignal.set(context.profile);
|
||||
this.tokenSignal.set(context.token);
|
||||
this.selectedTenantIdSignal.set(selected);
|
||||
}
|
||||
|
||||
setProfile(profile: ConsoleProfile | null): void {
|
||||
this.profileSignal.set(profile);
|
||||
}
|
||||
|
||||
setTokenInfo(token: ConsoleTokenInfo | null): void {
|
||||
this.tokenSignal.set(token);
|
||||
}
|
||||
|
||||
setTenants(
|
||||
tenants: ConsoleTenant[],
|
||||
preferredTenantId?: string | null
|
||||
): string | null {
|
||||
this.tenantsSignal.set(tenants);
|
||||
const currentSelection = this.selectedTenantIdSignal();
|
||||
const fallbackSelection =
|
||||
tenants.length > 0 ? tenants[0].id : null;
|
||||
|
||||
const nextSelection =
|
||||
(preferredTenantId &&
|
||||
tenants.some((tenant) => tenant.id === preferredTenantId) &&
|
||||
preferredTenantId) ||
|
||||
(currentSelection &&
|
||||
tenants.some((tenant) => tenant.id === currentSelection) &&
|
||||
currentSelection) ||
|
||||
fallbackSelection;
|
||||
|
||||
this.selectedTenantIdSignal.set(nextSelection);
|
||||
return nextSelection;
|
||||
}
|
||||
|
||||
setSelectedTenant(tenantId: string | null): void {
|
||||
this.selectedTenantIdSignal.set(tenantId);
|
||||
}
|
||||
|
||||
currentTenantSnapshot(): ConsoleTenant | null {
|
||||
return this.currentTenant();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tenantsSignal.set([]);
|
||||
this.selectedTenantIdSignal.set(null);
|
||||
this.profileSignal.set(null);
|
||||
this.tokenSignal.set(null);
|
||||
this.loadingSignal.set(false);
|
||||
this.errorSignal.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export class NavigationService {
|
||||
const _ = this.activeRoute(); // Subscribe to route changes
|
||||
this._mobileMenuOpen.set(false);
|
||||
this._activeDropdown.set(null);
|
||||
});
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface OperatorContext {
|
||||
readonly reason: string;
|
||||
readonly ticket: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class OperatorContextService {
|
||||
private readonly contextSignal = signal<OperatorContext | null>(null);
|
||||
|
||||
readonly context = this.contextSignal.asReadonly();
|
||||
|
||||
setContext(reason: string, ticket: string): void {
|
||||
const normalizedReason = reason.trim();
|
||||
const normalizedTicket = ticket.trim();
|
||||
if (!normalizedReason || !normalizedTicket) {
|
||||
throw new Error(
|
||||
'operator_reason and operator_ticket must be provided for orchestrator control actions.'
|
||||
);
|
||||
}
|
||||
|
||||
this.contextSignal.set({ reason: normalizedReason, ticket: normalizedTicket });
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.contextSignal.set(null);
|
||||
}
|
||||
|
||||
snapshot(): OperatorContext | null {
|
||||
return this.contextSignal();
|
||||
}
|
||||
}
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface OperatorContext {
|
||||
readonly reason: string;
|
||||
readonly ticket: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class OperatorContextService {
|
||||
private readonly contextSignal = signal<OperatorContext | null>(null);
|
||||
|
||||
readonly context = this.contextSignal.asReadonly();
|
||||
|
||||
setContext(reason: string, ticket: string): void {
|
||||
const normalizedReason = reason.trim();
|
||||
const normalizedTicket = ticket.trim();
|
||||
if (!normalizedReason || !normalizedTicket) {
|
||||
throw new Error(
|
||||
'operator_reason and operator_ticket must be provided for orchestrator control actions.'
|
||||
);
|
||||
}
|
||||
|
||||
this.contextSignal.set({ reason: normalizedReason, ticket: normalizedTicket });
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.contextSignal.set(null);
|
||||
}
|
||||
|
||||
snapshot(): OperatorContext | null {
|
||||
return this.contextSignal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import {
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { OperatorContextService } from './operator-context.service';
|
||||
|
||||
export const OPERATOR_METADATA_SENTINEL_HEADER = 'X-Stella-Require-Operator';
|
||||
export const OPERATOR_REASON_HEADER = 'X-Stella-Operator-Reason';
|
||||
export const OPERATOR_TICKET_HEADER = 'X-Stella-Operator-Ticket';
|
||||
|
||||
@Injectable()
|
||||
export class OperatorMetadataInterceptor implements HttpInterceptor {
|
||||
constructor(private readonly context: OperatorContextService) {}
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
if (!request.headers.has(OPERATOR_METADATA_SENTINEL_HEADER)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
const current = this.context.snapshot();
|
||||
const headers = request.headers.delete(OPERATOR_METADATA_SENTINEL_HEADER);
|
||||
|
||||
if (!current) {
|
||||
return next.handle(request.clone({ headers }));
|
||||
}
|
||||
|
||||
const enriched = headers
|
||||
.set(OPERATOR_REASON_HEADER, current.reason)
|
||||
.set(OPERATOR_TICKET_HEADER, current.ticket);
|
||||
|
||||
return next.handle(request.clone({ headers: enriched }));
|
||||
}
|
||||
}
|
||||
import {
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { OperatorContextService } from './operator-context.service';
|
||||
|
||||
export const OPERATOR_METADATA_SENTINEL_HEADER = 'X-Stella-Require-Operator';
|
||||
export const OPERATOR_REASON_HEADER = 'X-Stella-Operator-Reason';
|
||||
export const OPERATOR_TICKET_HEADER = 'X-Stella-Operator-Ticket';
|
||||
|
||||
@Injectable()
|
||||
export class OperatorMetadataInterceptor implements HttpInterceptor {
|
||||
constructor(private readonly context: OperatorContextService) {}
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
if (!request.headers.has(OPERATOR_METADATA_SENTINEL_HEADER)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
const current = this.context.snapshot();
|
||||
const headers = request.headers.delete(OPERATOR_METADATA_SENTINEL_HEADER);
|
||||
|
||||
if (!current) {
|
||||
return next.handle(request.clone({ headers }));
|
||||
}
|
||||
|
||||
const enriched = headers
|
||||
.set(OPERATOR_REASON_HEADER, current.reason)
|
||||
.set(OPERATOR_TICKET_HEADER, current.ticket);
|
||||
|
||||
return next.handle(request.clone({ headers: enriched }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ import {
|
||||
.stat-value.failed { color: #dc2626; }
|
||||
.stat-value.pending { color: #d97706; }
|
||||
.stat-value.throttled { color: #2563eb; }
|
||||
.stat-value.rate { color: #6366f1; }
|
||||
.stat-value.rate { color: #D4920A; }
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -239,7 +239,7 @@ interface ConfigSubTab {
|
||||
.sent-icon { background: #10b981; }
|
||||
.failed-icon { background: #ef4444; }
|
||||
.pending-icon { background: #f59e0b; }
|
||||
.rate-icon { background: #6366f1; }
|
||||
.rate-icon { background: #D4920A; }
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
|
||||
@@ -214,7 +214,7 @@ export const OBJECT_LINK_METADATA: Record<ObjectLinkType, { icon: string; color:
|
||||
reach: { icon: 'git-branch', color: '#8b5cf6', label: 'Reachability' },
|
||||
runtime: { icon: 'activity', color: '#f59e0b', label: 'Runtime' },
|
||||
vex: { icon: 'shield', color: '#10b981', label: 'VEX' },
|
||||
attest: { icon: 'file-signature', color: '#6366f1', label: 'Attestation' },
|
||||
attest: { icon: 'file-signature', color: '#D4920A', label: 'Attestation' },
|
||||
auth: { icon: 'key', color: '#ef4444', label: 'Auth' },
|
||||
docs: { icon: 'book', color: '#64748b', label: 'Docs' },
|
||||
finding: { icon: 'alert-triangle', color: '#f97316', label: 'Finding' },
|
||||
|
||||
@@ -147,7 +147,7 @@ import {
|
||||
.chip--reach { --chip-color: #8b5cf6; --chip-bg: rgba(139, 92, 246, 0.1); --chip-border: rgba(139, 92, 246, 0.2); }
|
||||
.chip--runtime { --chip-color: #f59e0b; --chip-bg: rgba(245, 158, 11, 0.1); --chip-border: rgba(245, 158, 11, 0.2); }
|
||||
.chip--vex { --chip-color: #10b981; --chip-bg: rgba(16, 185, 129, 0.1); --chip-border: rgba(16, 185, 129, 0.2); }
|
||||
.chip--attest { --chip-color: #6366f1; --chip-bg: rgba(99, 102, 241, 0.1); --chip-border: rgba(99, 102, 241, 0.2); }
|
||||
.chip--attest { --chip-color: #D4920A; --chip-bg: rgba(245, 166, 35, 0.1); --chip-border: rgba(245, 166, 35, 0.2); }
|
||||
.chip--auth { --chip-color: #ef4444; --chip-bg: rgba(239, 68, 68, 0.1); --chip-border: rgba(239, 68, 68, 0.2); }
|
||||
.chip--docs { --chip-color: #64748b; --chip-bg: rgba(100, 116, 139, 0.1); --chip-border: rgba(100, 116, 139, 0.2); }
|
||||
.chip--finding { --chip-color: #f97316; --chip-bg: rgba(249, 115, 22, 0.1); --chip-border: rgba(249, 115, 22, 0.2); }
|
||||
|
||||
@@ -207,7 +207,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
}
|
||||
|
||||
.evidence-type-badge.type-patch {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
|
||||
@@ -471,7 +471,7 @@ import type {
|
||||
}
|
||||
|
||||
.citation-type.type-patch {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
|
||||
@@ -549,7 +549,7 @@ import type {
|
||||
}
|
||||
|
||||
.step-type.type-vex_document {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,184 +1,184 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { AocClient } from '../../core/api/aoc.client';
|
||||
import {
|
||||
AocVerificationRequest,
|
||||
AocVerificationResult,
|
||||
AocViolationDetail,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
type VerifyState = 'idle' | 'running' | 'completed' | 'error';
|
||||
|
||||
export interface CliParityGuidance {
|
||||
command: string;
|
||||
description: string;
|
||||
flags: { flag: string; description: string }[];
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-verify-action',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './verify-action.component.html',
|
||||
styleUrls: ['./verify-action.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class VerifyActionComponent {
|
||||
private readonly aocClient = inject(AocClient);
|
||||
|
||||
/** Tenant ID to verify */
|
||||
readonly tenantId = input.required<string>();
|
||||
|
||||
/** Time window in hours (default 24h) */
|
||||
readonly windowHours = input(24);
|
||||
|
||||
/** Maximum documents to check */
|
||||
readonly limit = input(10000);
|
||||
|
||||
/** Emits when verification completes */
|
||||
readonly verified = output<AocVerificationResult>();
|
||||
|
||||
/** Emits when user clicks on a violation */
|
||||
readonly selectViolation = output<AocViolationDetail>();
|
||||
|
||||
readonly state = signal<VerifyState>('idle');
|
||||
readonly result = signal<AocVerificationResult | null>(null);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly progress = signal(0);
|
||||
readonly showCliGuidance = signal(false);
|
||||
|
||||
readonly statusIcon = computed(() => {
|
||||
switch (this.state()) {
|
||||
case 'idle':
|
||||
return '[ ]';
|
||||
case 'running':
|
||||
return '[~]';
|
||||
case 'completed':
|
||||
return this.result()?.status === 'passed' ? '[+]' : '[!]';
|
||||
case 'error':
|
||||
return '[X]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
});
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
switch (this.state()) {
|
||||
case 'idle':
|
||||
return 'Ready to verify';
|
||||
case 'running':
|
||||
return 'Verification in progress...';
|
||||
case 'completed':
|
||||
const r = this.result();
|
||||
if (!r) return 'Completed';
|
||||
return r.status === 'passed'
|
||||
? 'Verification passed'
|
||||
: r.status === 'failed'
|
||||
? 'Verification failed'
|
||||
: 'Verification completed with warnings';
|
||||
case 'error':
|
||||
return 'Verification error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
readonly resultSummary = computed(() => {
|
||||
const r = this.result();
|
||||
if (!r) return null;
|
||||
return {
|
||||
passRate: ((r.passedCount / r.checkedCount) * 100).toFixed(2),
|
||||
violationCount: r.violations.length,
|
||||
uniqueCodes: [...new Set(r.violations.map((v) => v.violationCode))],
|
||||
};
|
||||
});
|
||||
|
||||
readonly cliGuidance: CliParityGuidance = {
|
||||
command: 'stella aoc verify',
|
||||
description:
|
||||
'Run the same verification from CLI for automation, CI/CD pipelines, or detailed output.',
|
||||
flags: [
|
||||
{ flag: '--tenant', description: 'Tenant ID to verify' },
|
||||
{ flag: '--since', description: 'Start time (ISO8601 or duration like "24h")' },
|
||||
{ flag: '--limit', description: 'Maximum documents to check' },
|
||||
{ flag: '--output', description: 'Output format: json, table, summary' },
|
||||
{ flag: '--fail-on-violation', description: 'Exit with code 1 if any violations found' },
|
||||
{ flag: '--verbose', description: 'Show detailed violation information' },
|
||||
],
|
||||
examples: [
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h',
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h --output json > report.json',
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h --fail-on-violation',
|
||||
],
|
||||
};
|
||||
|
||||
async runVerification(): Promise<void> {
|
||||
if (this.state() === 'running') return;
|
||||
|
||||
this.state.set('running');
|
||||
this.error.set(null);
|
||||
this.result.set(null);
|
||||
this.progress.set(0);
|
||||
|
||||
// Simulate progress updates
|
||||
const progressInterval = setInterval(() => {
|
||||
this.progress.update((p) => Math.min(p + Math.random() * 15, 90));
|
||||
}, 200);
|
||||
|
||||
const since = new Date();
|
||||
since.setHours(since.getHours() - this.windowHours());
|
||||
|
||||
const request: AocVerificationRequest = {
|
||||
tenantId: this.tenantId(),
|
||||
since: since.toISOString(),
|
||||
limit: this.limit(),
|
||||
};
|
||||
|
||||
this.aocClient.verify(request).subscribe({
|
||||
next: (result) => {
|
||||
clearInterval(progressInterval);
|
||||
this.progress.set(100);
|
||||
this.result.set(result);
|
||||
this.state.set('completed');
|
||||
this.verified.emit(result);
|
||||
},
|
||||
error: (err) => {
|
||||
clearInterval(progressInterval);
|
||||
this.state.set('error');
|
||||
this.error.set(err.message || 'Verification failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state.set('idle');
|
||||
this.result.set(null);
|
||||
this.error.set(null);
|
||||
this.progress.set(0);
|
||||
}
|
||||
|
||||
toggleCliGuidance(): void {
|
||||
this.showCliGuidance.update((v) => !v);
|
||||
}
|
||||
|
||||
onSelectViolation(violation: AocViolationDetail): void {
|
||||
this.selectViolation.emit(violation);
|
||||
}
|
||||
|
||||
copyCommand(command: string): void {
|
||||
navigator.clipboard.writeText(command);
|
||||
}
|
||||
|
||||
getCliCommand(): string {
|
||||
return `stella aoc verify --tenant ${this.tenantId()} --since ${this.windowHours()}h`;
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { AocClient } from '../../core/api/aoc.client';
|
||||
import {
|
||||
AocVerificationRequest,
|
||||
AocVerificationResult,
|
||||
AocViolationDetail,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
type VerifyState = 'idle' | 'running' | 'completed' | 'error';
|
||||
|
||||
export interface CliParityGuidance {
|
||||
command: string;
|
||||
description: string;
|
||||
flags: { flag: string; description: string }[];
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-verify-action',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './verify-action.component.html',
|
||||
styleUrls: ['./verify-action.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class VerifyActionComponent {
|
||||
private readonly aocClient = inject(AocClient);
|
||||
|
||||
/** Tenant ID to verify */
|
||||
readonly tenantId = input.required<string>();
|
||||
|
||||
/** Time window in hours (default 24h) */
|
||||
readonly windowHours = input(24);
|
||||
|
||||
/** Maximum documents to check */
|
||||
readonly limit = input(10000);
|
||||
|
||||
/** Emits when verification completes */
|
||||
readonly verified = output<AocVerificationResult>();
|
||||
|
||||
/** Emits when user clicks on a violation */
|
||||
readonly selectViolation = output<AocViolationDetail>();
|
||||
|
||||
readonly state = signal<VerifyState>('idle');
|
||||
readonly result = signal<AocVerificationResult | null>(null);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly progress = signal(0);
|
||||
readonly showCliGuidance = signal(false);
|
||||
|
||||
readonly statusIcon = computed(() => {
|
||||
switch (this.state()) {
|
||||
case 'idle':
|
||||
return '[ ]';
|
||||
case 'running':
|
||||
return '[~]';
|
||||
case 'completed':
|
||||
return this.result()?.status === 'passed' ? '[+]' : '[!]';
|
||||
case 'error':
|
||||
return '[X]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
});
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
switch (this.state()) {
|
||||
case 'idle':
|
||||
return 'Ready to verify';
|
||||
case 'running':
|
||||
return 'Verification in progress...';
|
||||
case 'completed':
|
||||
const r = this.result();
|
||||
if (!r) return 'Completed';
|
||||
return r.status === 'passed'
|
||||
? 'Verification passed'
|
||||
: r.status === 'failed'
|
||||
? 'Verification failed'
|
||||
: 'Verification completed with warnings';
|
||||
case 'error':
|
||||
return 'Verification error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
readonly resultSummary = computed(() => {
|
||||
const r = this.result();
|
||||
if (!r) return null;
|
||||
return {
|
||||
passRate: ((r.passedCount / r.checkedCount) * 100).toFixed(2),
|
||||
violationCount: r.violations.length,
|
||||
uniqueCodes: [...new Set(r.violations.map((v) => v.violationCode))],
|
||||
};
|
||||
});
|
||||
|
||||
readonly cliGuidance: CliParityGuidance = {
|
||||
command: 'stella aoc verify',
|
||||
description:
|
||||
'Run the same verification from CLI for automation, CI/CD pipelines, or detailed output.',
|
||||
flags: [
|
||||
{ flag: '--tenant', description: 'Tenant ID to verify' },
|
||||
{ flag: '--since', description: 'Start time (ISO8601 or duration like "24h")' },
|
||||
{ flag: '--limit', description: 'Maximum documents to check' },
|
||||
{ flag: '--output', description: 'Output format: json, table, summary' },
|
||||
{ flag: '--fail-on-violation', description: 'Exit with code 1 if any violations found' },
|
||||
{ flag: '--verbose', description: 'Show detailed violation information' },
|
||||
],
|
||||
examples: [
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h',
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h --output json > report.json',
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h --fail-on-violation',
|
||||
],
|
||||
};
|
||||
|
||||
async runVerification(): Promise<void> {
|
||||
if (this.state() === 'running') return;
|
||||
|
||||
this.state.set('running');
|
||||
this.error.set(null);
|
||||
this.result.set(null);
|
||||
this.progress.set(0);
|
||||
|
||||
// Simulate progress updates
|
||||
const progressInterval = setInterval(() => {
|
||||
this.progress.update((p) => Math.min(p + Math.random() * 15, 90));
|
||||
}, 200);
|
||||
|
||||
const since = new Date();
|
||||
since.setHours(since.getHours() - this.windowHours());
|
||||
|
||||
const request: AocVerificationRequest = {
|
||||
tenantId: this.tenantId(),
|
||||
since: since.toISOString(),
|
||||
limit: this.limit(),
|
||||
};
|
||||
|
||||
this.aocClient.verify(request).subscribe({
|
||||
next: (result) => {
|
||||
clearInterval(progressInterval);
|
||||
this.progress.set(100);
|
||||
this.result.set(result);
|
||||
this.state.set('completed');
|
||||
this.verified.emit(result);
|
||||
},
|
||||
error: (err) => {
|
||||
clearInterval(progressInterval);
|
||||
this.state.set('error');
|
||||
this.error.set(err.message || 'Verification failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state.set('idle');
|
||||
this.result.set(null);
|
||||
this.error.set(null);
|
||||
this.progress.set(0);
|
||||
}
|
||||
|
||||
toggleCliGuidance(): void {
|
||||
this.showCliGuidance.update((v) => !v);
|
||||
}
|
||||
|
||||
onSelectViolation(violation: AocViolationDetail): void {
|
||||
this.selectViolation.emit(violation);
|
||||
}
|
||||
|
||||
copyCommand(command: string): void {
|
||||
navigator.clipboard.writeText(command);
|
||||
}
|
||||
|
||||
getCliCommand(): string {
|
||||
return `stella aoc verify --tenant ${this.tenantId()} --since ${this.windowHours()}h`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,182 +1,182 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
AocViolationDetail,
|
||||
AocViolationGroup,
|
||||
AocDocumentView,
|
||||
AocProvenance,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
type ViewMode = 'by-violation' | 'by-document';
|
||||
|
||||
@Component({
|
||||
selector: 'app-violation-drilldown',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './violation-drilldown.component.html',
|
||||
styleUrls: ['./violation-drilldown.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ViolationDrilldownComponent {
|
||||
/** Violation groups to display */
|
||||
readonly violationGroups = input.required<AocViolationGroup[]>();
|
||||
|
||||
/** Document views for by-document mode */
|
||||
readonly documentViews = input<AocDocumentView[]>([]);
|
||||
|
||||
/** Emits when user clicks on a document */
|
||||
readonly selectDocument = output<string>();
|
||||
|
||||
/** Emits when user wants to view raw document */
|
||||
readonly viewRawDocument = output<string>();
|
||||
|
||||
/** Current view mode */
|
||||
readonly viewMode = signal<ViewMode>('by-violation');
|
||||
|
||||
/** Currently expanded violation code */
|
||||
readonly expandedCode = signal<string | null>(null);
|
||||
|
||||
/** Currently expanded document ID */
|
||||
readonly expandedDocId = signal<string | null>(null);
|
||||
|
||||
/** Search filter */
|
||||
readonly searchFilter = signal('');
|
||||
|
||||
readonly filteredGroups = computed(() => {
|
||||
const filter = this.searchFilter().toLowerCase();
|
||||
if (!filter) return this.violationGroups();
|
||||
return this.violationGroups().filter(
|
||||
(g) =>
|
||||
g.code.toLowerCase().includes(filter) ||
|
||||
g.description.toLowerCase().includes(filter) ||
|
||||
g.violations.some(
|
||||
(v) =>
|
||||
v.documentId.toLowerCase().includes(filter) ||
|
||||
v.field?.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
readonly filteredDocuments = computed(() => {
|
||||
const filter = this.searchFilter().toLowerCase();
|
||||
if (!filter) return this.documentViews();
|
||||
return this.documentViews().filter(
|
||||
(d) =>
|
||||
d.documentId.toLowerCase().includes(filter) ||
|
||||
d.documentType.toLowerCase().includes(filter) ||
|
||||
d.violations.some(
|
||||
(v) =>
|
||||
v.violationCode.toLowerCase().includes(filter) ||
|
||||
v.field?.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
readonly totalViolations = computed(() =>
|
||||
this.violationGroups().reduce((sum, g) => sum + g.violations.length, 0)
|
||||
);
|
||||
|
||||
readonly totalDocuments = computed(() => {
|
||||
const docIds = new Set<string>();
|
||||
for (const group of this.violationGroups()) {
|
||||
for (const v of group.violations) {
|
||||
docIds.add(v.documentId);
|
||||
}
|
||||
}
|
||||
return docIds.size;
|
||||
});
|
||||
|
||||
readonly severityCounts = computed(() => {
|
||||
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
for (const group of this.violationGroups()) {
|
||||
counts[group.severity] += group.violations.length;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
toggleGroup(code: string): void {
|
||||
this.expandedCode.update((current) => (current === code ? null : code));
|
||||
}
|
||||
|
||||
toggleDocument(docId: string): void {
|
||||
this.expandedDocId.update((current) => (current === docId ? null : docId));
|
||||
}
|
||||
|
||||
onSearch(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchFilter.set(input.value);
|
||||
}
|
||||
|
||||
onSelectDocument(docId: string): void {
|
||||
this.selectDocument.emit(docId);
|
||||
}
|
||||
|
||||
onViewRaw(docId: string): void {
|
||||
this.viewRawDocument.emit(docId);
|
||||
}
|
||||
|
||||
getSeverityIcon(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return '!!';
|
||||
case 'high':
|
||||
return '!';
|
||||
case 'medium':
|
||||
return '~';
|
||||
default:
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
getSourceTypeIcon(sourceType?: string): string {
|
||||
switch (sourceType) {
|
||||
case 'registry':
|
||||
return '[R]';
|
||||
case 'git':
|
||||
return '[G]';
|
||||
case 'upload':
|
||||
return '[U]';
|
||||
case 'api':
|
||||
return '[A]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
}
|
||||
|
||||
formatDigest(digest: string, length = 12): string {
|
||||
if (digest.length <= length) return digest;
|
||||
return digest.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
isFieldHighlighted(doc: AocDocumentView, field: string): boolean {
|
||||
return doc.highlightedFields.includes(field);
|
||||
}
|
||||
|
||||
getFieldValue(content: Record<string, unknown> | undefined, path: string): string {
|
||||
if (!content) return 'N/A';
|
||||
const parts = path.split('.');
|
||||
let current: unknown = content;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return 'N/A';
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
if (current == null) return 'null';
|
||||
if (typeof current === 'object') return JSON.stringify(current);
|
||||
return String(current);
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
AocViolationDetail,
|
||||
AocViolationGroup,
|
||||
AocDocumentView,
|
||||
AocProvenance,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
type ViewMode = 'by-violation' | 'by-document';
|
||||
|
||||
@Component({
|
||||
selector: 'app-violation-drilldown',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './violation-drilldown.component.html',
|
||||
styleUrls: ['./violation-drilldown.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ViolationDrilldownComponent {
|
||||
/** Violation groups to display */
|
||||
readonly violationGroups = input.required<AocViolationGroup[]>();
|
||||
|
||||
/** Document views for by-document mode */
|
||||
readonly documentViews = input<AocDocumentView[]>([]);
|
||||
|
||||
/** Emits when user clicks on a document */
|
||||
readonly selectDocument = output<string>();
|
||||
|
||||
/** Emits when user wants to view raw document */
|
||||
readonly viewRawDocument = output<string>();
|
||||
|
||||
/** Current view mode */
|
||||
readonly viewMode = signal<ViewMode>('by-violation');
|
||||
|
||||
/** Currently expanded violation code */
|
||||
readonly expandedCode = signal<string | null>(null);
|
||||
|
||||
/** Currently expanded document ID */
|
||||
readonly expandedDocId = signal<string | null>(null);
|
||||
|
||||
/** Search filter */
|
||||
readonly searchFilter = signal('');
|
||||
|
||||
readonly filteredGroups = computed(() => {
|
||||
const filter = this.searchFilter().toLowerCase();
|
||||
if (!filter) return this.violationGroups();
|
||||
return this.violationGroups().filter(
|
||||
(g) =>
|
||||
g.code.toLowerCase().includes(filter) ||
|
||||
g.description.toLowerCase().includes(filter) ||
|
||||
g.violations.some(
|
||||
(v) =>
|
||||
v.documentId.toLowerCase().includes(filter) ||
|
||||
v.field?.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
readonly filteredDocuments = computed(() => {
|
||||
const filter = this.searchFilter().toLowerCase();
|
||||
if (!filter) return this.documentViews();
|
||||
return this.documentViews().filter(
|
||||
(d) =>
|
||||
d.documentId.toLowerCase().includes(filter) ||
|
||||
d.documentType.toLowerCase().includes(filter) ||
|
||||
d.violations.some(
|
||||
(v) =>
|
||||
v.violationCode.toLowerCase().includes(filter) ||
|
||||
v.field?.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
readonly totalViolations = computed(() =>
|
||||
this.violationGroups().reduce((sum, g) => sum + g.violations.length, 0)
|
||||
);
|
||||
|
||||
readonly totalDocuments = computed(() => {
|
||||
const docIds = new Set<string>();
|
||||
for (const group of this.violationGroups()) {
|
||||
for (const v of group.violations) {
|
||||
docIds.add(v.documentId);
|
||||
}
|
||||
}
|
||||
return docIds.size;
|
||||
});
|
||||
|
||||
readonly severityCounts = computed(() => {
|
||||
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
for (const group of this.violationGroups()) {
|
||||
counts[group.severity] += group.violations.length;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
toggleGroup(code: string): void {
|
||||
this.expandedCode.update((current) => (current === code ? null : code));
|
||||
}
|
||||
|
||||
toggleDocument(docId: string): void {
|
||||
this.expandedDocId.update((current) => (current === docId ? null : docId));
|
||||
}
|
||||
|
||||
onSearch(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchFilter.set(input.value);
|
||||
}
|
||||
|
||||
onSelectDocument(docId: string): void {
|
||||
this.selectDocument.emit(docId);
|
||||
}
|
||||
|
||||
onViewRaw(docId: string): void {
|
||||
this.viewRawDocument.emit(docId);
|
||||
}
|
||||
|
||||
getSeverityIcon(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return '!!';
|
||||
case 'high':
|
||||
return '!';
|
||||
case 'medium':
|
||||
return '~';
|
||||
default:
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
getSourceTypeIcon(sourceType?: string): string {
|
||||
switch (sourceType) {
|
||||
case 'registry':
|
||||
return '[R]';
|
||||
case 'git':
|
||||
return '[G]';
|
||||
case 'upload':
|
||||
return '[U]';
|
||||
case 'api':
|
||||
return '[A]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
}
|
||||
|
||||
formatDigest(digest: string, length = 12): string {
|
||||
if (digest.length <= length) return digest;
|
||||
return digest.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
isFieldHighlighted(doc: AocDocumentView, field: string): boolean {
|
||||
return doc.highlightedFields.includes(field);
|
||||
}
|
||||
|
||||
getFieldValue(content: Record<string, unknown> | undefined, path: string): string {
|
||||
if (!content) return 'N/A';
|
||||
const parts = path.split('.');
|
||||
let current: unknown = content;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return 'N/A';
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
if (current == null) return 'null';
|
||||
if (typeof current === 'object') return JSON.stringify(current);
|
||||
return String(current);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
|
||||
.stat-card.authority { border-left: 4px solid #8b5cf6; }
|
||||
.stat-card.vex { border-left: 4px solid #10b981; }
|
||||
.stat-card.integrations { border-left: 4px solid #f59e0b; }
|
||||
.stat-card.orchestrator { border-left: 4px solid #6366f1; }
|
||||
.stat-card.orchestrator { border-left: 4px solid #D4920A; }
|
||||
.anomaly-alerts { margin-bottom: 2rem; }
|
||||
.anomaly-alerts h2 { margin: 0 0 1rem; font-size: 1.1rem; }
|
||||
.alert-list { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth-callback',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<section class="auth-callback">
|
||||
<p *ngIf="state() === 'processing'">Completing sign-in…</p>
|
||||
<p *ngIf="state() === 'error'" class="error">
|
||||
We were unable to complete the sign-in flow. Please try again.
|
||||
</p>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.auth-callback {
|
||||
margin: 4rem auto;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AuthCallbackComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly auth = inject(AuthorityAuthService);
|
||||
|
||||
readonly state = signal<'processing' | 'error'>('processing');
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const params = this.route.snapshot.queryParamMap;
|
||||
const searchParams = new URLSearchParams();
|
||||
params.keys.forEach((key) => {
|
||||
const value = params.get(key);
|
||||
if (value != null) {
|
||||
searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.auth.completeLoginFromRedirect(searchParams);
|
||||
const returnUrl = result.returnUrl ?? '/';
|
||||
await this.router.navigateByUrl(returnUrl, { replaceUrl: true });
|
||||
} catch {
|
||||
this.state.set('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth-callback',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<section class="auth-callback">
|
||||
<p *ngIf="state() === 'processing'">Completing sign-in…</p>
|
||||
<p *ngIf="state() === 'error'" class="error">
|
||||
We were unable to complete the sign-in flow. Please try again.
|
||||
</p>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.auth-callback {
|
||||
margin: 4rem auto;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AuthCallbackComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly auth = inject(AuthorityAuthService);
|
||||
|
||||
readonly state = signal<'processing' | 'error'>('processing');
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const params = this.route.snapshot.queryParamMap;
|
||||
const searchParams = new URLSearchParams();
|
||||
params.keys.forEach((key) => {
|
||||
const value = params.get(key);
|
||||
if (value != null) {
|
||||
searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.auth.completeLoginFromRedirect(searchParams);
|
||||
const returnUrl = result.returnUrl ?? '/';
|
||||
await this.router.navigateByUrl(returnUrl, { replaceUrl: true });
|
||||
} catch {
|
||||
this.state.set('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,226 +1,226 @@
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.console-profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.console-profile__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.console-profile__subtitle {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
|
||||
box-shadow var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
background: var(--color-brand-primary-hover);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.75;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.console-profile__loading {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-brand-light);
|
||||
color: var(--color-brand-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.console-profile__error {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
border: 1px solid var(--color-status-error);
|
||||
}
|
||||
|
||||
.console-profile__card {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-4);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chip,
|
||||
.tenant-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background-color: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.chip--active {
|
||||
background-color: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.chip--inactive {
|
||||
background-color: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.tenant-chip {
|
||||
background-color: var(--color-brand-light);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.tenant-count {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tenant-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: var(--space-2-5);
|
||||
}
|
||||
|
||||
.tenant-list__item--active button {
|
||||
border-color: var(--color-brand-primary);
|
||||
background-color: var(--color-brand-light);
|
||||
}
|
||||
|
||||
.tenant-list button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
box-shadow var(--motion-duration-fast) var(--motion-ease-default),
|
||||
transform var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-list__heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tenant-meta {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.fresh-auth {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.fresh-auth--active {
|
||||
background-color: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.fresh-auth--stale {
|
||||
background-color: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
.console-profile__empty {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.console-profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.console-profile__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.console-profile__subtitle {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
|
||||
box-shadow var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
background: var(--color-brand-primary-hover);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.75;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.console-profile__loading {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-brand-light);
|
||||
color: var(--color-brand-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.console-profile__error {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
border: 1px solid var(--color-status-error);
|
||||
}
|
||||
|
||||
.console-profile__card {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-4);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chip,
|
||||
.tenant-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background-color: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.chip--active {
|
||||
background-color: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.chip--inactive {
|
||||
background-color: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.tenant-chip {
|
||||
background-color: var(--color-brand-light);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.tenant-count {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tenant-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: var(--space-2-5);
|
||||
}
|
||||
|
||||
.tenant-list__item--active button {
|
||||
border-color: var(--color-brand-primary);
|
||||
background-color: var(--color-brand-light);
|
||||
}
|
||||
|
||||
.tenant-list button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
box-shadow var(--motion-duration-fast) var(--motion-ease-default),
|
||||
transform var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-list__heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tenant-meta {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.fresh-auth {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.fresh-auth--active {
|
||||
background-color: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.fresh-auth--stale {
|
||||
background-color: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
.console-profile__empty {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -1,110 +1,110 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||
import { ConsoleProfileComponent } from './console-profile.component';
|
||||
|
||||
class MockConsoleSessionService {
|
||||
loadConsoleContext = jasmine
|
||||
.createSpy('loadConsoleContext')
|
||||
.and.returnValue(Promise.resolve());
|
||||
refresh = jasmine
|
||||
.createSpy('refresh')
|
||||
.and.returnValue(Promise.resolve());
|
||||
switchTenant = jasmine
|
||||
.createSpy('switchTenant')
|
||||
.and.returnValue(Promise.resolve());
|
||||
}
|
||||
|
||||
describe('ConsoleProfileComponent', () => {
|
||||
let fixture: ComponentFixture<ConsoleProfileComponent>;
|
||||
let service: MockConsoleSessionService;
|
||||
let store: ConsoleSessionStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ConsoleProfileComponent],
|
||||
providers: [
|
||||
ConsoleSessionStore,
|
||||
{ provide: ConsoleSessionService, useClass: MockConsoleSessionService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
service = TestBed.inject(
|
||||
ConsoleSessionService
|
||||
) as unknown as MockConsoleSessionService;
|
||||
store = TestBed.inject(ConsoleSessionStore);
|
||||
fixture = TestBed.createComponent(ConsoleProfileComponent);
|
||||
});
|
||||
|
||||
it('renders profile and tenant information', async () => {
|
||||
store.setContext({
|
||||
tenants: [
|
||||
{
|
||||
id: 'tenant-default',
|
||||
displayName: 'Tenant Default',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.console'],
|
||||
},
|
||||
],
|
||||
selectedTenantId: 'tenant-default',
|
||||
profile: {
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'Console User',
|
||||
tenant: 'tenant-default',
|
||||
sessionId: 'session-1',
|
||||
roles: ['role.console'],
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuth: true,
|
||||
},
|
||||
token: {
|
||||
active: true,
|
||||
tenant: 'tenant-default',
|
||||
subject: 'user-1',
|
||||
clientId: 'console-web',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain(
|
||||
'Console Session'
|
||||
);
|
||||
expect(compiled.querySelector('.tenant-name')?.textContent).toContain(
|
||||
'Tenant Default'
|
||||
);
|
||||
expect(compiled.querySelector('dd')?.textContent).toContain('Console User');
|
||||
expect(service.loadConsoleContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invokes refresh on demand', async () => {
|
||||
store.clear();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const button = fixture.nativeElement.querySelector(
|
||||
'button[type="button"]'
|
||||
) as HTMLButtonElement;
|
||||
button.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(service.refresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||
import { ConsoleProfileComponent } from './console-profile.component';
|
||||
|
||||
class MockConsoleSessionService {
|
||||
loadConsoleContext = jasmine
|
||||
.createSpy('loadConsoleContext')
|
||||
.and.returnValue(Promise.resolve());
|
||||
refresh = jasmine
|
||||
.createSpy('refresh')
|
||||
.and.returnValue(Promise.resolve());
|
||||
switchTenant = jasmine
|
||||
.createSpy('switchTenant')
|
||||
.and.returnValue(Promise.resolve());
|
||||
}
|
||||
|
||||
describe('ConsoleProfileComponent', () => {
|
||||
let fixture: ComponentFixture<ConsoleProfileComponent>;
|
||||
let service: MockConsoleSessionService;
|
||||
let store: ConsoleSessionStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ConsoleProfileComponent],
|
||||
providers: [
|
||||
ConsoleSessionStore,
|
||||
{ provide: ConsoleSessionService, useClass: MockConsoleSessionService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
service = TestBed.inject(
|
||||
ConsoleSessionService
|
||||
) as unknown as MockConsoleSessionService;
|
||||
store = TestBed.inject(ConsoleSessionStore);
|
||||
fixture = TestBed.createComponent(ConsoleProfileComponent);
|
||||
});
|
||||
|
||||
it('renders profile and tenant information', async () => {
|
||||
store.setContext({
|
||||
tenants: [
|
||||
{
|
||||
id: 'tenant-default',
|
||||
displayName: 'Tenant Default',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.console'],
|
||||
},
|
||||
],
|
||||
selectedTenantId: 'tenant-default',
|
||||
profile: {
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'Console User',
|
||||
tenant: 'tenant-default',
|
||||
sessionId: 'session-1',
|
||||
roles: ['role.console'],
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuth: true,
|
||||
},
|
||||
token: {
|
||||
active: true,
|
||||
tenant: 'tenant-default',
|
||||
subject: 'user-1',
|
||||
clientId: 'console-web',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain(
|
||||
'Console Session'
|
||||
);
|
||||
expect(compiled.querySelector('.tenant-name')?.textContent).toContain(
|
||||
'Tenant Default'
|
||||
);
|
||||
expect(compiled.querySelector('dd')?.textContent).toContain('Console User');
|
||||
expect(service.loadConsoleContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invokes refresh on demand', async () => {
|
||||
store.clear();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const button = fixture.nativeElement.querySelector(
|
||||
'button[type="button"]'
|
||||
) as HTMLButtonElement;
|
||||
button.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(service.refresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
|
||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-console-profile',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './console-profile.component.html',
|
||||
styleUrls: ['./console-profile.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ConsoleProfileComponent implements OnInit {
|
||||
private readonly store = inject(ConsoleSessionStore);
|
||||
private readonly service = inject(ConsoleSessionService);
|
||||
|
||||
readonly loading = this.store.loading;
|
||||
readonly error = this.store.error;
|
||||
readonly profile = this.store.profile;
|
||||
readonly tokenInfo = this.store.tokenInfo;
|
||||
readonly tenants = this.store.tenants;
|
||||
readonly selectedTenantId = this.store.selectedTenantId;
|
||||
|
||||
readonly hasProfile = computed(() => this.profile() !== null);
|
||||
readonly tenantCount = computed(() => this.tenants().length);
|
||||
readonly freshAuthState = computed(() => {
|
||||
const token = this.tokenInfo();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
active: token.freshAuthActive,
|
||||
expiresAt: token.freshAuthExpiresAt,
|
||||
};
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
if (!this.store.hasContext()) {
|
||||
try {
|
||||
await this.service.loadConsoleContext();
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
await this.service.refresh();
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
|
||||
async selectTenant(tenantId: string): Promise<void> {
|
||||
try {
|
||||
await this.service.switchTenant(tenantId);
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
|
||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-console-profile',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './console-profile.component.html',
|
||||
styleUrls: ['./console-profile.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ConsoleProfileComponent implements OnInit {
|
||||
private readonly store = inject(ConsoleSessionStore);
|
||||
private readonly service = inject(ConsoleSessionService);
|
||||
|
||||
readonly loading = this.store.loading;
|
||||
readonly error = this.store.error;
|
||||
readonly profile = this.store.profile;
|
||||
readonly tokenInfo = this.store.tokenInfo;
|
||||
readonly tenants = this.store.tenants;
|
||||
readonly selectedTenantId = this.store.selectedTenantId;
|
||||
|
||||
readonly hasProfile = computed(() => this.profile() !== null);
|
||||
readonly tenantCount = computed(() => this.tenants().length);
|
||||
readonly freshAuthState = computed(() => {
|
||||
const token = this.tokenInfo();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
active: token.freshAuthActive,
|
||||
expiresAt: token.freshAuthExpiresAt,
|
||||
};
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
if (!this.store.hasContext()) {
|
||||
try {
|
||||
await this.service.loadConsoleContext();
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
await this.service.refresh();
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
|
||||
async selectTenant(tenantId: string): Promise<void> {
|
||||
try {
|
||||
await this.service.switchTenant(tenantId);
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ export interface DashboardAiData {
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background: #4f46e5;
|
||||
background: #F5A623;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
@@ -293,7 +293,7 @@ export interface DashboardAiData {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
@@ -302,7 +302,7 @@ export interface DashboardAiData {
|
||||
.ai-risk-drivers__evidence-link:hover,
|
||||
.ai-risk-drivers__action:hover {
|
||||
background: #eef2ff;
|
||||
border-color: #a5b4fc;
|
||||
border-color: #FFCF70;
|
||||
}
|
||||
|
||||
.ai-risk-drivers__empty {
|
||||
|
||||
@@ -1,350 +1,350 @@
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.sources-dashboard {
|
||||
padding: var(--space-6);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-6);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-base);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&-primary {
|
||||
background-color: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-brand-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&-secondary {
|
||||
background-color: transparent;
|
||||
border-color: var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-surface-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-12);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--color-border-primary);
|
||||
border-top-color: var(--color-brand-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--color-status-error);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.tile {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
overflow: hidden;
|
||||
|
||||
&-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin: 0;
|
||||
background: var(--color-surface-secondary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&-content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.tile-pass-fail {
|
||||
&.excellent .metric-large .value { color: var(--color-status-success); }
|
||||
&.good .metric-large .value { color: var(--color-status-success); }
|
||||
&.warning .metric-large .value { color: var(--color-status-warning); }
|
||||
&.critical .metric-large .value { color: var(--color-status-error); }
|
||||
}
|
||||
|
||||
.metric-large {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
.value {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: 1;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-details {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
&.pass { color: var(--color-status-success); }
|
||||
&.fail { color: var(--color-status-error); }
|
||||
&.total { color: var(--color-text-primary); }
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.violations-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.violation-item {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: var(--space-2);
|
||||
background: var(--color-surface-secondary);
|
||||
|
||||
&.severity-critical { border-left: 3px solid var(--color-severity-critical); }
|
||||
&.severity-high { border-left: 3px solid var(--color-severity-high); }
|
||||
&.severity-medium { border-left: 3px solid var(--color-severity-medium); }
|
||||
&.severity-low { border-left: 3px solid var(--color-severity-low); }
|
||||
}
|
||||
|
||||
.violation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.violation-code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.violation-count {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.violation-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.violation-time {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.throughput-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.throughput-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.value {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.tile-throughput {
|
||||
&.critical .throughput-item .value { color: var(--color-status-error); }
|
||||
&.warning .throughput-item .value { color: var(--color-status-warning); }
|
||||
}
|
||||
|
||||
.verification-result {
|
||||
margin-top: var(--space-6);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
&.status-passed { background: var(--color-status-success-bg); border-color: var(--color-status-success); }
|
||||
&.status-failed { background: var(--color-status-error-bg); border-color: var(--color-status-error); }
|
||||
&.status-partial { background: var(--color-status-warning-bg); border-color: var(--color-status-warning); }
|
||||
|
||||
h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&.status-passed .status-badge { background: var(--color-status-success); color: var(--color-text-inverse); }
|
||||
&.status-failed .status-badge { background: var(--color-status-error); color: var(--color-text-inverse); }
|
||||
&.status-partial .status-badge { background: var(--color-status-warning); color: var(--color-text-inverse); }
|
||||
}
|
||||
|
||||
.violations-details {
|
||||
margin: var(--space-3) 0;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-brand-primary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.violation-list {
|
||||
margin-top: var(--space-2);
|
||||
padding-left: var(--space-5);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
li {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cli-hint {
|
||||
margin: var(--space-3) 0 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
code {
|
||||
background: var(--color-surface-tertiary);
|
||||
padding: var(--space-0-5) var(--space-1-5);
|
||||
border-radius: var(--radius-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.time-window {
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@include screen-below-md {
|
||||
.sources-dashboard {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.sources-dashboard {
|
||||
padding: var(--space-6);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-6);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-base);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&-primary {
|
||||
background-color: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-brand-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&-secondary {
|
||||
background-color: transparent;
|
||||
border-color: var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-surface-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-12);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--color-border-primary);
|
||||
border-top-color: var(--color-brand-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--color-status-error);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.tile {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
overflow: hidden;
|
||||
|
||||
&-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin: 0;
|
||||
background: var(--color-surface-secondary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&-content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.tile-pass-fail {
|
||||
&.excellent .metric-large .value { color: var(--color-status-success); }
|
||||
&.good .metric-large .value { color: var(--color-status-success); }
|
||||
&.warning .metric-large .value { color: var(--color-status-warning); }
|
||||
&.critical .metric-large .value { color: var(--color-status-error); }
|
||||
}
|
||||
|
||||
.metric-large {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
.value {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: 1;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-details {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
&.pass { color: var(--color-status-success); }
|
||||
&.fail { color: var(--color-status-error); }
|
||||
&.total { color: var(--color-text-primary); }
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.violations-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.violation-item {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: var(--space-2);
|
||||
background: var(--color-surface-secondary);
|
||||
|
||||
&.severity-critical { border-left: 3px solid var(--color-severity-critical); }
|
||||
&.severity-high { border-left: 3px solid var(--color-severity-high); }
|
||||
&.severity-medium { border-left: 3px solid var(--color-severity-medium); }
|
||||
&.severity-low { border-left: 3px solid var(--color-severity-low); }
|
||||
}
|
||||
|
||||
.violation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.violation-code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.violation-count {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.violation-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.violation-time {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.throughput-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.throughput-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.value {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.tile-throughput {
|
||||
&.critical .throughput-item .value { color: var(--color-status-error); }
|
||||
&.warning .throughput-item .value { color: var(--color-status-warning); }
|
||||
}
|
||||
|
||||
.verification-result {
|
||||
margin-top: var(--space-6);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
&.status-passed { background: var(--color-status-success-bg); border-color: var(--color-status-success); }
|
||||
&.status-failed { background: var(--color-status-error-bg); border-color: var(--color-status-error); }
|
||||
&.status-partial { background: var(--color-status-warning-bg); border-color: var(--color-status-warning); }
|
||||
|
||||
h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&.status-passed .status-badge { background: var(--color-status-success); color: var(--color-text-inverse); }
|
||||
&.status-failed .status-badge { background: var(--color-status-error); color: var(--color-text-inverse); }
|
||||
&.status-partial .status-badge { background: var(--color-status-warning); color: var(--color-text-inverse); }
|
||||
}
|
||||
|
||||
.violations-details {
|
||||
margin: var(--space-3) 0;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-brand-primary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.violation-list {
|
||||
margin-top: var(--space-2);
|
||||
padding-left: var(--space-5);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
li {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cli-hint {
|
||||
margin: var(--space-3) 0 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
code {
|
||||
background: var(--color-surface-tertiary);
|
||||
padding: var(--space-0-5) var(--space-1-5);
|
||||
border-radius: var(--radius-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.time-window {
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@include screen-below-md {
|
||||
.sources-dashboard {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +1,111 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { AocClient } from '../../core/api/aoc.client';
|
||||
import {
|
||||
AocMetrics,
|
||||
AocViolationSummary,
|
||||
AocVerificationResult,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sources-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './sources-dashboard.component.html',
|
||||
styleUrls: ['./sources-dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SourcesDashboardComponent implements OnInit {
|
||||
private readonly aocClient = inject(AocClient);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly metrics = signal<AocMetrics | null>(null);
|
||||
readonly verifying = signal(false);
|
||||
readonly verificationResult = signal<AocVerificationResult | null>(null);
|
||||
|
||||
readonly passRate = computed(() => {
|
||||
const m = this.metrics();
|
||||
return m ? m.passRate.toFixed(2) : '0.00';
|
||||
});
|
||||
|
||||
readonly passRateClass = computed(() => {
|
||||
const m = this.metrics();
|
||||
if (!m) return 'neutral';
|
||||
if (m.passRate >= 99.5) return 'excellent';
|
||||
if (m.passRate >= 95) return 'good';
|
||||
if (m.passRate >= 90) return 'warning';
|
||||
return 'critical';
|
||||
});
|
||||
|
||||
readonly throughputStatus = computed(() => {
|
||||
const m = this.metrics();
|
||||
if (!m) return 'neutral';
|
||||
if (m.ingestThroughput.queueDepth > 100) return 'critical';
|
||||
if (m.ingestThroughput.queueDepth > 50) return 'warning';
|
||||
return 'good';
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMetrics();
|
||||
}
|
||||
|
||||
loadMetrics(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.aocClient.getMetrics('default').subscribe({
|
||||
next: (metrics) => {
|
||||
this.metrics.set(metrics);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load AOC metrics');
|
||||
this.loading.set(false);
|
||||
console.error('AOC metrics error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onVerifyLast24h(): void {
|
||||
this.verifying.set(true);
|
||||
this.verificationResult.set(null);
|
||||
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
this.aocClient.verify({ tenantId: 'default', since }).subscribe({
|
||||
next: (result) => {
|
||||
this.verificationResult.set(result);
|
||||
this.verifying.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.verifying.set(false);
|
||||
console.error('AOC verification error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getSeverityClass(severity: AocViolationSummary['severity']): string {
|
||||
return 'severity-' + severity;
|
||||
}
|
||||
|
||||
formatRelativeTime(isoDate: string): string {
|
||||
const date = new Date(isoDate);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return diffMins + 'm ago';
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return diffHours + 'h ago';
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return diffDays + 'd ago';
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { AocClient } from '../../core/api/aoc.client';
|
||||
import {
|
||||
AocMetrics,
|
||||
AocViolationSummary,
|
||||
AocVerificationResult,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sources-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './sources-dashboard.component.html',
|
||||
styleUrls: ['./sources-dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SourcesDashboardComponent implements OnInit {
|
||||
private readonly aocClient = inject(AocClient);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly metrics = signal<AocMetrics | null>(null);
|
||||
readonly verifying = signal(false);
|
||||
readonly verificationResult = signal<AocVerificationResult | null>(null);
|
||||
|
||||
readonly passRate = computed(() => {
|
||||
const m = this.metrics();
|
||||
return m ? m.passRate.toFixed(2) : '0.00';
|
||||
});
|
||||
|
||||
readonly passRateClass = computed(() => {
|
||||
const m = this.metrics();
|
||||
if (!m) return 'neutral';
|
||||
if (m.passRate >= 99.5) return 'excellent';
|
||||
if (m.passRate >= 95) return 'good';
|
||||
if (m.passRate >= 90) return 'warning';
|
||||
return 'critical';
|
||||
});
|
||||
|
||||
readonly throughputStatus = computed(() => {
|
||||
const m = this.metrics();
|
||||
if (!m) return 'neutral';
|
||||
if (m.ingestThroughput.queueDepth > 100) return 'critical';
|
||||
if (m.ingestThroughput.queueDepth > 50) return 'warning';
|
||||
return 'good';
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMetrics();
|
||||
}
|
||||
|
||||
loadMetrics(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.aocClient.getMetrics('default').subscribe({
|
||||
next: (metrics) => {
|
||||
this.metrics.set(metrics);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load AOC metrics');
|
||||
this.loading.set(false);
|
||||
console.error('AOC metrics error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onVerifyLast24h(): void {
|
||||
this.verifying.set(true);
|
||||
this.verificationResult.set(null);
|
||||
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
this.aocClient.verify({ tenantId: 'default', since }).subscribe({
|
||||
next: (result) => {
|
||||
this.verificationResult.set(result);
|
||||
this.verifying.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.verifying.set(false);
|
||||
console.error('AOC verification error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getSeverityClass(severity: AocViolationSummary['severity']): string {
|
||||
return 'severity-' + severity;
|
||||
}
|
||||
|
||||
formatRelativeTime(isoDate: string): string {
|
||||
const date = new Date(isoDate);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return diffMins + 'm ago';
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return diffHours + 'h ago';
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return diffDays + 'd ago';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,200 +1,200 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { EvidenceData } from '../../core/api/evidence.models';
|
||||
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
|
||||
import { EvidencePanelComponent } from './evidence-panel.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-evidence-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, EvidencePanelComponent],
|
||||
providers: [
|
||||
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
|
||||
],
|
||||
template: `
|
||||
<div class="evidence-page">
|
||||
@if (loading()) {
|
||||
<div class="evidence-page__loading">
|
||||
<div class="spinner" aria-label="Loading evidence"></div>
|
||||
<p>Loading evidence for {{ advisoryId() }}...</p>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="evidence-page__error" role="alert">
|
||||
<h2>Error Loading Evidence</h2>
|
||||
<p>{{ error() }}</p>
|
||||
<button type="button" (click)="reload()">Retry</button>
|
||||
</div>
|
||||
} @else if (evidenceData()) {
|
||||
<app-evidence-panel
|
||||
[advisoryId]="advisoryId()"
|
||||
[evidenceData]="evidenceData()"
|
||||
(close)="onClose()"
|
||||
(downloadDocument)="onDownload($event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="evidence-page__empty">
|
||||
<h2>No Advisory ID</h2>
|
||||
<p>Please provide an advisory ID to view evidence.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.evidence-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.evidence-page__loading,
|
||||
.evidence-page__error,
|
||||
.evidence-page__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.evidence-page__loading .spinner {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.evidence-page__loading p {
|
||||
margin-top: 1rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.evidence-page__error {
|
||||
border: 1px solid #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.evidence-page__error h2 {
|
||||
color: #dc2626;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__error p {
|
||||
color: #991b1b;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.evidence-page__error button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #dc2626;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.evidence-page__error button:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.evidence-page__empty h2 {
|
||||
color: #374151;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__empty p {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EvidencePageComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly evidenceApi = inject(EVIDENCE_API);
|
||||
|
||||
readonly advisoryId = signal<string>('');
|
||||
readonly evidenceData = signal<EvidenceData | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
// React to route param changes
|
||||
effect(() => {
|
||||
const params = this.route.snapshot.paramMap;
|
||||
const id = params.get('advisoryId');
|
||||
if (id) {
|
||||
this.advisoryId.set(id);
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
private loadEvidence(advisoryId: string): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
|
||||
next: (data) => {
|
||||
this.evidenceData.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Failed to load evidence');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
const id = this.advisoryId();
|
||||
if (id) {
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.router.navigate(['/vulnerabilities']);
|
||||
}
|
||||
|
||||
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
|
||||
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${event.type}-${event.id}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Download failed:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { EvidenceData } from '../../core/api/evidence.models';
|
||||
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
|
||||
import { EvidencePanelComponent } from './evidence-panel.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-evidence-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, EvidencePanelComponent],
|
||||
providers: [
|
||||
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
|
||||
],
|
||||
template: `
|
||||
<div class="evidence-page">
|
||||
@if (loading()) {
|
||||
<div class="evidence-page__loading">
|
||||
<div class="spinner" aria-label="Loading evidence"></div>
|
||||
<p>Loading evidence for {{ advisoryId() }}...</p>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="evidence-page__error" role="alert">
|
||||
<h2>Error Loading Evidence</h2>
|
||||
<p>{{ error() }}</p>
|
||||
<button type="button" (click)="reload()">Retry</button>
|
||||
</div>
|
||||
} @else if (evidenceData()) {
|
||||
<app-evidence-panel
|
||||
[advisoryId]="advisoryId()"
|
||||
[evidenceData]="evidenceData()"
|
||||
(close)="onClose()"
|
||||
(downloadDocument)="onDownload($event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="evidence-page__empty">
|
||||
<h2>No Advisory ID</h2>
|
||||
<p>Please provide an advisory ID to view evidence.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.evidence-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.evidence-page__loading,
|
||||
.evidence-page__error,
|
||||
.evidence-page__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.evidence-page__loading .spinner {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.evidence-page__loading p {
|
||||
margin-top: 1rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.evidence-page__error {
|
||||
border: 1px solid #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.evidence-page__error h2 {
|
||||
color: #dc2626;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__error p {
|
||||
color: #991b1b;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.evidence-page__error button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #dc2626;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.evidence-page__error button:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.evidence-page__empty h2 {
|
||||
color: #374151;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__empty p {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EvidencePageComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly evidenceApi = inject(EVIDENCE_API);
|
||||
|
||||
readonly advisoryId = signal<string>('');
|
||||
readonly evidenceData = signal<EvidenceData | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
// React to route param changes
|
||||
effect(() => {
|
||||
const params = this.route.snapshot.paramMap;
|
||||
const id = params.get('advisoryId');
|
||||
if (id) {
|
||||
this.advisoryId.set(id);
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
private loadEvidence(advisoryId: string): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
|
||||
next: (data) => {
|
||||
this.evidenceData.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Failed to load evidence');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
const id = this.advisoryId();
|
||||
if (id) {
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.router.navigate(['/vulnerabilities']);
|
||||
}
|
||||
|
||||
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
|
||||
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${event.type}-${event.id}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Download failed:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,2 @@
|
||||
export { EvidencePanelComponent } from './evidence-panel.component';
|
||||
export { EvidencePageComponent } from './evidence-page.component';
|
||||
export { EvidencePanelComponent } from './evidence-panel.component';
|
||||
export { EvidencePageComponent } from './evidence-page.component';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,278 +1,278 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Exception,
|
||||
ExceptionStatus,
|
||||
ExceptionType,
|
||||
ExceptionFilter,
|
||||
ExceptionSortOption,
|
||||
ExceptionTransition,
|
||||
EXCEPTION_TRANSITIONS,
|
||||
KANBAN_COLUMNS,
|
||||
} from '../../core/api/exception.models';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-center',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './exception-center.component.html',
|
||||
styleUrls: ['./exception-center.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ExceptionCenterComponent {
|
||||
/** All exceptions */
|
||||
readonly exceptions = input.required<Exception[]>();
|
||||
|
||||
/** Current user role for transition permissions */
|
||||
readonly userRole = input<string>('user');
|
||||
|
||||
/** Emits when creating new exception */
|
||||
readonly create = output<void>();
|
||||
|
||||
/** Emits when selecting an exception */
|
||||
readonly select = output<Exception>();
|
||||
|
||||
/** Emits when performing a workflow transition */
|
||||
readonly transition = output<{ exception: Exception; to: ExceptionStatus }>();
|
||||
|
||||
/** Emits when viewing audit log */
|
||||
readonly viewAudit = output<Exception>();
|
||||
|
||||
readonly viewMode = signal<ViewMode>('list');
|
||||
readonly filter = signal<ExceptionFilter>({});
|
||||
readonly sort = signal<ExceptionSortOption>({ field: 'updatedAt', direction: 'desc' });
|
||||
readonly expandedId = signal<string | null>(null);
|
||||
readonly showFilters = signal(false);
|
||||
|
||||
readonly kanbanColumns = KANBAN_COLUMNS;
|
||||
|
||||
readonly filteredExceptions = computed(() => {
|
||||
let result = [...this.exceptions()];
|
||||
const f = this.filter();
|
||||
|
||||
// Apply filters
|
||||
if (f.status && f.status.length > 0) {
|
||||
result = result.filter((e) => f.status!.includes(e.status));
|
||||
}
|
||||
if (f.type && f.type.length > 0) {
|
||||
result = result.filter((e) => f.type!.includes(e.type));
|
||||
}
|
||||
if (f.severity && f.severity.length > 0) {
|
||||
result = result.filter((e) => f.severity!.includes(e.severity));
|
||||
}
|
||||
if (f.search) {
|
||||
const search = f.search.toLowerCase();
|
||||
result = result.filter(
|
||||
(e) =>
|
||||
e.title.toLowerCase().includes(search) ||
|
||||
e.justification.toLowerCase().includes(search) ||
|
||||
e.id.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
if (f.tags && f.tags.length > 0) {
|
||||
result = result.filter((e) => f.tags!.some((t) => e.tags.includes(t)));
|
||||
}
|
||||
if (f.expiringSoon) {
|
||||
result = result.filter((e) => e.timebox.isWarning && !e.timebox.isExpired);
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
const s = this.sort();
|
||||
result.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (s.field) {
|
||||
case 'createdAt':
|
||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
break;
|
||||
case 'updatedAt':
|
||||
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
break;
|
||||
case 'expiresAt':
|
||||
cmp = new Date(a.timebox.expiresAt).getTime() - new Date(b.timebox.expiresAt).getTime();
|
||||
break;
|
||||
case 'severity':
|
||||
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
cmp = sevOrder[a.severity] - sevOrder[b.severity];
|
||||
break;
|
||||
case 'title':
|
||||
cmp = a.title.localeCompare(b.title);
|
||||
break;
|
||||
}
|
||||
return s.direction === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
readonly exceptionsByStatus = computed(() => {
|
||||
const byStatus = new Map<ExceptionStatus, Exception[]>();
|
||||
for (const col of KANBAN_COLUMNS) {
|
||||
byStatus.set(col.status, []);
|
||||
}
|
||||
for (const exc of this.filteredExceptions()) {
|
||||
const list = byStatus.get(exc.status) || [];
|
||||
list.push(exc);
|
||||
byStatus.set(exc.status, list);
|
||||
}
|
||||
return byStatus;
|
||||
});
|
||||
|
||||
readonly statusCounts = computed(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const exc of this.exceptions()) {
|
||||
counts[exc.status] = (counts[exc.status] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
readonly allTags = computed(() => {
|
||||
const tags = new Set<string>();
|
||||
for (const exc of this.exceptions()) {
|
||||
for (const tag of exc.tags) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
return Array.from(tags).sort();
|
||||
});
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
toggleFilters(): void {
|
||||
this.showFilters.update((v) => !v);
|
||||
}
|
||||
|
||||
toggleStatusFilter(status: ExceptionStatus): void {
|
||||
const current = this.filter().status || [];
|
||||
const newStatuses = current.includes(status)
|
||||
? current.filter((s) => s !== status)
|
||||
: [...current, status];
|
||||
this.updateFilter('status', newStatuses.length > 0 ? newStatuses : undefined);
|
||||
}
|
||||
|
||||
toggleTypeFilter(type: ExceptionType): void {
|
||||
const current = this.filter().type || [];
|
||||
const newTypes = current.includes(type)
|
||||
? current.filter((t) => t !== type)
|
||||
: [...current, type];
|
||||
this.updateFilter('type', newTypes.length > 0 ? newTypes : undefined);
|
||||
}
|
||||
|
||||
toggleSeverityFilter(severity: string): void {
|
||||
const current = this.filter().severity || [];
|
||||
const newSeverities = current.includes(severity)
|
||||
? current.filter((s) => s !== severity)
|
||||
: [...current, severity];
|
||||
this.updateFilter('severity', newSeverities.length > 0 ? newSeverities : undefined);
|
||||
}
|
||||
|
||||
toggleTagFilter(tag: string): void {
|
||||
const current = this.filter().tags || [];
|
||||
const newTags = current.includes(tag)
|
||||
? current.filter((t) => t !== tag)
|
||||
: [...current, tag];
|
||||
this.updateFilter('tags', newTags.length > 0 ? newTags : undefined);
|
||||
}
|
||||
|
||||
updateFilter(key: keyof ExceptionFilter, value: unknown): void {
|
||||
this.filter.update((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filter.set({});
|
||||
}
|
||||
|
||||
setSort(field: ExceptionSortOption['field']): void {
|
||||
this.sort.update((s) => ({
|
||||
field,
|
||||
direction: s.field === field && s.direction === 'desc' ? 'asc' : 'desc',
|
||||
}));
|
||||
}
|
||||
|
||||
toggleExpand(id: string): void {
|
||||
this.expandedId.update((current) => (current === id ? null : id));
|
||||
}
|
||||
|
||||
onCreate(): void {
|
||||
this.create.emit();
|
||||
}
|
||||
|
||||
onSelect(exc: Exception): void {
|
||||
this.select.emit(exc);
|
||||
}
|
||||
|
||||
onTransition(exc: Exception, to: ExceptionStatus): void {
|
||||
this.transition.emit({ exception: exc, to });
|
||||
}
|
||||
|
||||
onViewAudit(exc: Exception): void {
|
||||
this.viewAudit.emit(exc);
|
||||
}
|
||||
|
||||
getAvailableTransitions(exc: Exception): ExceptionTransition[] {
|
||||
return EXCEPTION_TRANSITIONS.filter(
|
||||
(t) => t.from === exc.status && t.allowedRoles.includes(this.userRole())
|
||||
);
|
||||
}
|
||||
|
||||
getStatusIcon(status: ExceptionStatus): string {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return '[D]';
|
||||
case 'pending_review':
|
||||
return '[?]';
|
||||
case 'approved':
|
||||
return '[+]';
|
||||
case 'rejected':
|
||||
return '[~]';
|
||||
case 'expired':
|
||||
return '[X]';
|
||||
case 'revoked':
|
||||
return '[!]';
|
||||
default:
|
||||
return '[-]';
|
||||
}
|
||||
}
|
||||
|
||||
getTypeIcon(type: ExceptionType): string {
|
||||
switch (type) {
|
||||
case 'vulnerability':
|
||||
return 'V';
|
||||
case 'license':
|
||||
return 'L';
|
||||
case 'policy':
|
||||
return 'P';
|
||||
case 'entropy':
|
||||
return 'E';
|
||||
case 'determinism':
|
||||
return 'D';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
getSeverityClass(severity: string): string {
|
||||
return 'severity-' + severity;
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatRemainingDays(days: number): string {
|
||||
if (days < 0) return 'Expired';
|
||||
if (days === 0) return 'Expires today';
|
||||
if (days === 1) return '1 day left';
|
||||
return days + ' days left';
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Exception,
|
||||
ExceptionStatus,
|
||||
ExceptionType,
|
||||
ExceptionFilter,
|
||||
ExceptionSortOption,
|
||||
ExceptionTransition,
|
||||
EXCEPTION_TRANSITIONS,
|
||||
KANBAN_COLUMNS,
|
||||
} from '../../core/api/exception.models';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-center',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './exception-center.component.html',
|
||||
styleUrls: ['./exception-center.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ExceptionCenterComponent {
|
||||
/** All exceptions */
|
||||
readonly exceptions = input.required<Exception[]>();
|
||||
|
||||
/** Current user role for transition permissions */
|
||||
readonly userRole = input<string>('user');
|
||||
|
||||
/** Emits when creating new exception */
|
||||
readonly create = output<void>();
|
||||
|
||||
/** Emits when selecting an exception */
|
||||
readonly select = output<Exception>();
|
||||
|
||||
/** Emits when performing a workflow transition */
|
||||
readonly transition = output<{ exception: Exception; to: ExceptionStatus }>();
|
||||
|
||||
/** Emits when viewing audit log */
|
||||
readonly viewAudit = output<Exception>();
|
||||
|
||||
readonly viewMode = signal<ViewMode>('list');
|
||||
readonly filter = signal<ExceptionFilter>({});
|
||||
readonly sort = signal<ExceptionSortOption>({ field: 'updatedAt', direction: 'desc' });
|
||||
readonly expandedId = signal<string | null>(null);
|
||||
readonly showFilters = signal(false);
|
||||
|
||||
readonly kanbanColumns = KANBAN_COLUMNS;
|
||||
|
||||
readonly filteredExceptions = computed(() => {
|
||||
let result = [...this.exceptions()];
|
||||
const f = this.filter();
|
||||
|
||||
// Apply filters
|
||||
if (f.status && f.status.length > 0) {
|
||||
result = result.filter((e) => f.status!.includes(e.status));
|
||||
}
|
||||
if (f.type && f.type.length > 0) {
|
||||
result = result.filter((e) => f.type!.includes(e.type));
|
||||
}
|
||||
if (f.severity && f.severity.length > 0) {
|
||||
result = result.filter((e) => f.severity!.includes(e.severity));
|
||||
}
|
||||
if (f.search) {
|
||||
const search = f.search.toLowerCase();
|
||||
result = result.filter(
|
||||
(e) =>
|
||||
e.title.toLowerCase().includes(search) ||
|
||||
e.justification.toLowerCase().includes(search) ||
|
||||
e.id.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
if (f.tags && f.tags.length > 0) {
|
||||
result = result.filter((e) => f.tags!.some((t) => e.tags.includes(t)));
|
||||
}
|
||||
if (f.expiringSoon) {
|
||||
result = result.filter((e) => e.timebox.isWarning && !e.timebox.isExpired);
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
const s = this.sort();
|
||||
result.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (s.field) {
|
||||
case 'createdAt':
|
||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
break;
|
||||
case 'updatedAt':
|
||||
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
break;
|
||||
case 'expiresAt':
|
||||
cmp = new Date(a.timebox.expiresAt).getTime() - new Date(b.timebox.expiresAt).getTime();
|
||||
break;
|
||||
case 'severity':
|
||||
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
cmp = sevOrder[a.severity] - sevOrder[b.severity];
|
||||
break;
|
||||
case 'title':
|
||||
cmp = a.title.localeCompare(b.title);
|
||||
break;
|
||||
}
|
||||
return s.direction === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
readonly exceptionsByStatus = computed(() => {
|
||||
const byStatus = new Map<ExceptionStatus, Exception[]>();
|
||||
for (const col of KANBAN_COLUMNS) {
|
||||
byStatus.set(col.status, []);
|
||||
}
|
||||
for (const exc of this.filteredExceptions()) {
|
||||
const list = byStatus.get(exc.status) || [];
|
||||
list.push(exc);
|
||||
byStatus.set(exc.status, list);
|
||||
}
|
||||
return byStatus;
|
||||
});
|
||||
|
||||
readonly statusCounts = computed(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const exc of this.exceptions()) {
|
||||
counts[exc.status] = (counts[exc.status] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
readonly allTags = computed(() => {
|
||||
const tags = new Set<string>();
|
||||
for (const exc of this.exceptions()) {
|
||||
for (const tag of exc.tags) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
return Array.from(tags).sort();
|
||||
});
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
toggleFilters(): void {
|
||||
this.showFilters.update((v) => !v);
|
||||
}
|
||||
|
||||
toggleStatusFilter(status: ExceptionStatus): void {
|
||||
const current = this.filter().status || [];
|
||||
const newStatuses = current.includes(status)
|
||||
? current.filter((s) => s !== status)
|
||||
: [...current, status];
|
||||
this.updateFilter('status', newStatuses.length > 0 ? newStatuses : undefined);
|
||||
}
|
||||
|
||||
toggleTypeFilter(type: ExceptionType): void {
|
||||
const current = this.filter().type || [];
|
||||
const newTypes = current.includes(type)
|
||||
? current.filter((t) => t !== type)
|
||||
: [...current, type];
|
||||
this.updateFilter('type', newTypes.length > 0 ? newTypes : undefined);
|
||||
}
|
||||
|
||||
toggleSeverityFilter(severity: string): void {
|
||||
const current = this.filter().severity || [];
|
||||
const newSeverities = current.includes(severity)
|
||||
? current.filter((s) => s !== severity)
|
||||
: [...current, severity];
|
||||
this.updateFilter('severity', newSeverities.length > 0 ? newSeverities : undefined);
|
||||
}
|
||||
|
||||
toggleTagFilter(tag: string): void {
|
||||
const current = this.filter().tags || [];
|
||||
const newTags = current.includes(tag)
|
||||
? current.filter((t) => t !== tag)
|
||||
: [...current, tag];
|
||||
this.updateFilter('tags', newTags.length > 0 ? newTags : undefined);
|
||||
}
|
||||
|
||||
updateFilter(key: keyof ExceptionFilter, value: unknown): void {
|
||||
this.filter.update((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filter.set({});
|
||||
}
|
||||
|
||||
setSort(field: ExceptionSortOption['field']): void {
|
||||
this.sort.update((s) => ({
|
||||
field,
|
||||
direction: s.field === field && s.direction === 'desc' ? 'asc' : 'desc',
|
||||
}));
|
||||
}
|
||||
|
||||
toggleExpand(id: string): void {
|
||||
this.expandedId.update((current) => (current === id ? null : id));
|
||||
}
|
||||
|
||||
onCreate(): void {
|
||||
this.create.emit();
|
||||
}
|
||||
|
||||
onSelect(exc: Exception): void {
|
||||
this.select.emit(exc);
|
||||
}
|
||||
|
||||
onTransition(exc: Exception, to: ExceptionStatus): void {
|
||||
this.transition.emit({ exception: exc, to });
|
||||
}
|
||||
|
||||
onViewAudit(exc: Exception): void {
|
||||
this.viewAudit.emit(exc);
|
||||
}
|
||||
|
||||
getAvailableTransitions(exc: Exception): ExceptionTransition[] {
|
||||
return EXCEPTION_TRANSITIONS.filter(
|
||||
(t) => t.from === exc.status && t.allowedRoles.includes(this.userRole())
|
||||
);
|
||||
}
|
||||
|
||||
getStatusIcon(status: ExceptionStatus): string {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return '[D]';
|
||||
case 'pending_review':
|
||||
return '[?]';
|
||||
case 'approved':
|
||||
return '[+]';
|
||||
case 'rejected':
|
||||
return '[~]';
|
||||
case 'expired':
|
||||
return '[X]';
|
||||
case 'revoked':
|
||||
return '[!]';
|
||||
default:
|
||||
return '[-]';
|
||||
}
|
||||
}
|
||||
|
||||
getTypeIcon(type: ExceptionType): string {
|
||||
switch (type) {
|
||||
case 'vulnerability':
|
||||
return 'V';
|
||||
case 'license':
|
||||
return 'L';
|
||||
case 'policy':
|
||||
return 'P';
|
||||
case 'entropy':
|
||||
return 'E';
|
||||
case 'determinism':
|
||||
return 'D';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
getSeverityClass(severity: string): string {
|
||||
return 'severity-' + severity;
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatRemainingDays(days: number): string {
|
||||
if (days < 0) return 'Expired';
|
||||
if (days === 0) return 'Expires today';
|
||||
if (days === 1) return '1 day left';
|
||||
return days + ' days left';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,441 +1,441 @@
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.draft-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
// Header
|
||||
.draft-inline__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border-secondary);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.draft-inline__source {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Error
|
||||
.draft-inline__error {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
border: 1px solid var(--color-status-error);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
// Scope
|
||||
.draft-inline__scope {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.draft-inline__scope-label {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.draft-inline__scope-value {
|
||||
color: var(--color-text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.draft-inline__scope-type {
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
background: var(--color-brand-light);
|
||||
color: var(--color-brand-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// Vulnerabilities preview
|
||||
.draft-inline__vulns,
|
||||
.draft-inline__components {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.draft-inline__vulns-label,
|
||||
.draft-inline__components-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.draft-inline__vulns-list,
|
||||
.draft-inline__components-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.vuln-chip {
|
||||
display: inline-flex;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
|
||||
&--more {
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.component-chip {
|
||||
display: inline-flex;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
background: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&--more {
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
// Form
|
||||
.draft-inline__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3-5);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
&--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-brand-light);
|
||||
}
|
||||
|
||||
&--small {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-brand-light);
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Severity chips
|
||||
.severity-chips {
|
||||
display: flex;
|
||||
gap: var(--space-1-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.severity-option {
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--selected .severity-chip {
|
||||
box-shadow: 0 0 0 2px currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.severity-chip {
|
||||
display: inline-flex;
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
transition: box-shadow var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&--critical {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-severity-critical);
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: var(--color-severity-high-bg);
|
||||
color: var(--color-severity-high);
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: var(--color-severity-medium-bg);
|
||||
color: var(--color-severity-medium);
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
}
|
||||
|
||||
// Template chips
|
||||
.template-chips {
|
||||
display: flex;
|
||||
gap: var(--space-1-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.template-chip {
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
transition: all var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-secondary);
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: var(--color-brand-light);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// Timebox
|
||||
.timebox-quick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.timebox-btn {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-tertiary);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.timebox-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Simulation
|
||||
.draft-inline__simulation {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
.simulation-toggle {
|
||||
padding: var(--space-1-5) var(--space-3);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-brand-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-brand-light);
|
||||
}
|
||||
}
|
||||
|
||||
.simulation-result {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.simulation-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-0-5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.simulation-stat__label {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.simulation-stat__value {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&--high {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
&--moderate {
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
&--low {
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
.draft-inline__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
// Buttons
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-brand-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
&--text {
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@include screen-below-sm {
|
||||
.simulation-result {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.draft-inline__footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.severity-chips,
|
||||
.template-chips {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.draft-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
// Header
|
||||
.draft-inline__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border-secondary);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.draft-inline__source {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Error
|
||||
.draft-inline__error {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
border: 1px solid var(--color-status-error);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
// Scope
|
||||
.draft-inline__scope {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.draft-inline__scope-label {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.draft-inline__scope-value {
|
||||
color: var(--color-text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.draft-inline__scope-type {
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
background: var(--color-brand-light);
|
||||
color: var(--color-brand-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// Vulnerabilities preview
|
||||
.draft-inline__vulns,
|
||||
.draft-inline__components {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.draft-inline__vulns-label,
|
||||
.draft-inline__components-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.draft-inline__vulns-list,
|
||||
.draft-inline__components-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.vuln-chip {
|
||||
display: inline-flex;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
|
||||
&--more {
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.component-chip {
|
||||
display: inline-flex;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
background: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&--more {
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
// Form
|
||||
.draft-inline__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3-5);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
&--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-brand-light);
|
||||
}
|
||||
|
||||
&--small {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-brand-light);
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Severity chips
|
||||
.severity-chips {
|
||||
display: flex;
|
||||
gap: var(--space-1-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.severity-option {
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--selected .severity-chip {
|
||||
box-shadow: 0 0 0 2px currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.severity-chip {
|
||||
display: inline-flex;
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
transition: box-shadow var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&--critical {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-severity-critical);
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: var(--color-severity-high-bg);
|
||||
color: var(--color-severity-high);
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: var(--color-severity-medium-bg);
|
||||
color: var(--color-severity-medium);
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
}
|
||||
|
||||
// Template chips
|
||||
.template-chips {
|
||||
display: flex;
|
||||
gap: var(--space-1-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.template-chip {
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
transition: all var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-secondary);
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: var(--color-brand-light);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// Timebox
|
||||
.timebox-quick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.timebox-btn {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-tertiary);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.timebox-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Simulation
|
||||
.draft-inline__simulation {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
.simulation-toggle {
|
||||
padding: var(--space-1-5) var(--space-3);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-brand-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-brand-light);
|
||||
}
|
||||
}
|
||||
|
||||
.simulation-result {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.simulation-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-0-5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.simulation-stat__label {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.simulation-stat__value {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&--high {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
&--moderate {
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
&--low {
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
.draft-inline__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
// Buttons
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-brand-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
&--text {
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@include screen-below-sm {
|
||||
.simulation-result {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.draft-inline__footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.severity-chips,
|
||||
.template-chips {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
|
||||
import {
|
||||
EXCEPTION_API,
|
||||
ExceptionApi,
|
||||
@@ -27,36 +27,36 @@ import {
|
||||
ExceptionSeverity,
|
||||
ExceptionScopeType,
|
||||
} from '../../core/api/exception.contract.models';
|
||||
|
||||
export interface ExceptionDraftContext {
|
||||
readonly vulnIds?: readonly string[];
|
||||
readonly componentPurls?: readonly string[];
|
||||
readonly assetIds?: readonly string[];
|
||||
readonly tenantId?: string;
|
||||
readonly suggestedName?: string;
|
||||
readonly suggestedSeverity?: ExceptionSeverity;
|
||||
readonly sourceType: 'vulnerability' | 'component' | 'asset' | 'graph';
|
||||
readonly sourceLabel: string;
|
||||
}
|
||||
|
||||
const QUICK_TEMPLATES = [
|
||||
{ id: 'risk-accepted', label: 'Risk Accepted', text: 'Risk has been reviewed and formally accepted.' },
|
||||
{ id: 'compensating-control', label: 'Compensating Control', text: 'Compensating controls in place: ' },
|
||||
{ id: 'false-positive', label: 'False Positive', text: 'This finding is a false positive because: ' },
|
||||
{ id: 'scheduled-fix', label: 'Scheduled Fix', text: 'Fix scheduled for deployment. Timeline: ' },
|
||||
] as const;
|
||||
|
||||
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
|
||||
{ value: 'critical', label: 'Critical' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-draft-inline',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
|
||||
export interface ExceptionDraftContext {
|
||||
readonly vulnIds?: readonly string[];
|
||||
readonly componentPurls?: readonly string[];
|
||||
readonly assetIds?: readonly string[];
|
||||
readonly tenantId?: string;
|
||||
readonly suggestedName?: string;
|
||||
readonly suggestedSeverity?: ExceptionSeverity;
|
||||
readonly sourceType: 'vulnerability' | 'component' | 'asset' | 'graph';
|
||||
readonly sourceLabel: string;
|
||||
}
|
||||
|
||||
const QUICK_TEMPLATES = [
|
||||
{ id: 'risk-accepted', label: 'Risk Accepted', text: 'Risk has been reviewed and formally accepted.' },
|
||||
{ id: 'compensating-control', label: 'Compensating Control', text: 'Compensating controls in place: ' },
|
||||
{ id: 'false-positive', label: 'False Positive', text: 'This finding is a false positive because: ' },
|
||||
{ id: 'scheduled-fix', label: 'Scheduled Fix', text: 'Fix scheduled for deployment. Timeline: ' },
|
||||
] as const;
|
||||
|
||||
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
|
||||
{ value: 'critical', label: 'Critical' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-draft-inline',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './exception-draft-inline.component.html',
|
||||
styleUrls: ['./exception-draft-inline.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -65,148 +65,148 @@ export class ExceptionDraftInlineComponent implements OnInit {
|
||||
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
|
||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
@Input() context!: ExceptionDraftContext;
|
||||
@Output() readonly created = new EventEmitter<Exception>();
|
||||
@Output() readonly cancelled = new EventEmitter<void>();
|
||||
@Output() readonly openFullWizard = new EventEmitter<ExceptionDraftContext>();
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly showSimulation = signal(false);
|
||||
|
||||
readonly quickTemplates = QUICK_TEMPLATES;
|
||||
readonly severityOptions = SEVERITY_OPTIONS;
|
||||
|
||||
readonly draftForm = this.formBuilder.group({
|
||||
name: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(3)],
|
||||
}),
|
||||
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
|
||||
justificationTemplate: this.formBuilder.control('risk-accepted'),
|
||||
justificationText: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(20)],
|
||||
}),
|
||||
timeboxDays: this.formBuilder.control(30),
|
||||
});
|
||||
|
||||
|
||||
@Input() context!: ExceptionDraftContext;
|
||||
@Output() readonly created = new EventEmitter<Exception>();
|
||||
@Output() readonly cancelled = new EventEmitter<void>();
|
||||
@Output() readonly openFullWizard = new EventEmitter<ExceptionDraftContext>();
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly showSimulation = signal(false);
|
||||
|
||||
readonly quickTemplates = QUICK_TEMPLATES;
|
||||
readonly severityOptions = SEVERITY_OPTIONS;
|
||||
|
||||
readonly draftForm = this.formBuilder.group({
|
||||
name: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(3)],
|
||||
}),
|
||||
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
|
||||
justificationTemplate: this.formBuilder.control('risk-accepted'),
|
||||
justificationText: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(20)],
|
||||
}),
|
||||
timeboxDays: this.formBuilder.control(30),
|
||||
});
|
||||
|
||||
readonly scopeType = computed<ExceptionScopeType>(() => {
|
||||
if (this.context?.componentPurls?.length) return 'component';
|
||||
if (this.context?.assetIds?.length) return 'asset';
|
||||
if (this.context?.tenantId) return 'tenant';
|
||||
return 'global';
|
||||
});
|
||||
|
||||
readonly scopeSummary = computed(() => {
|
||||
const ctx = this.context;
|
||||
const items: string[] = [];
|
||||
|
||||
if (ctx?.vulnIds?.length) {
|
||||
items.push(`${ctx.vulnIds.length} vulnerabilit${ctx.vulnIds.length === 1 ? 'y' : 'ies'}`);
|
||||
}
|
||||
if (ctx?.componentPurls?.length) {
|
||||
items.push(`${ctx.componentPurls.length} component${ctx.componentPurls.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (ctx?.assetIds?.length) {
|
||||
items.push(`${ctx.assetIds.length} asset${ctx.assetIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (ctx?.tenantId) {
|
||||
items.push(`Tenant: ${ctx.tenantId}`);
|
||||
}
|
||||
|
||||
return items.length > 0 ? items.join(', ') : 'Global scope';
|
||||
});
|
||||
|
||||
readonly simulationResult = computed(() => {
|
||||
if (!this.showSimulation()) return null;
|
||||
|
||||
const vulnCount = this.context?.vulnIds?.length ?? 0;
|
||||
const componentCount = this.context?.componentPurls?.length ?? 0;
|
||||
|
||||
return {
|
||||
affectedFindings: vulnCount * Math.max(1, componentCount),
|
||||
policyImpact: this.draftForm.controls.severity.value === 'critical' ? 'high' : 'moderate',
|
||||
coverageEstimate: `~${Math.min(100, vulnCount * 15 + componentCount * 10)}%`,
|
||||
};
|
||||
});
|
||||
|
||||
readonly canSubmit = computed(() => {
|
||||
return this.draftForm.valid && !this.loading();
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.context?.suggestedName) {
|
||||
this.draftForm.patchValue({ name: this.context.suggestedName });
|
||||
}
|
||||
if (this.context?.suggestedSeverity) {
|
||||
this.draftForm.patchValue({ severity: this.context.suggestedSeverity });
|
||||
}
|
||||
|
||||
const defaultTemplate = this.quickTemplates[0];
|
||||
this.draftForm.patchValue({ justificationText: defaultTemplate.text });
|
||||
}
|
||||
|
||||
selectTemplate(templateId: string): void {
|
||||
const template = this.quickTemplates.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
this.draftForm.patchValue({
|
||||
justificationTemplate: templateId,
|
||||
justificationText: template.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleSimulation(): void {
|
||||
this.showSimulation.set(!this.showSimulation());
|
||||
}
|
||||
|
||||
async submitDraft(): Promise<void> {
|
||||
if (!this.canSubmit()) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
try {
|
||||
const formValue = this.draftForm.getRawValue();
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() + formValue.timeboxDays);
|
||||
|
||||
const exception: Partial<Exception> = {
|
||||
name: formValue.name,
|
||||
severity: formValue.severity,
|
||||
status: 'draft',
|
||||
scope: {
|
||||
type: this.scopeType(),
|
||||
tenantId: this.context?.tenantId,
|
||||
assetIds: this.context?.assetIds ? [...this.context.assetIds] : undefined,
|
||||
componentPurls: this.context?.componentPurls ? [...this.context.componentPurls] : undefined,
|
||||
vulnIds: this.context?.vulnIds ? [...this.context.vulnIds] : undefined,
|
||||
},
|
||||
justification: {
|
||||
template: formValue.justificationTemplate,
|
||||
text: formValue.justificationText,
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
readonly scopeSummary = computed(() => {
|
||||
const ctx = this.context;
|
||||
const items: string[] = [];
|
||||
|
||||
if (ctx?.vulnIds?.length) {
|
||||
items.push(`${ctx.vulnIds.length} vulnerabilit${ctx.vulnIds.length === 1 ? 'y' : 'ies'}`);
|
||||
}
|
||||
if (ctx?.componentPurls?.length) {
|
||||
items.push(`${ctx.componentPurls.length} component${ctx.componentPurls.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (ctx?.assetIds?.length) {
|
||||
items.push(`${ctx.assetIds.length} asset${ctx.assetIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (ctx?.tenantId) {
|
||||
items.push(`Tenant: ${ctx.tenantId}`);
|
||||
}
|
||||
|
||||
return items.length > 0 ? items.join(', ') : 'Global scope';
|
||||
});
|
||||
|
||||
readonly simulationResult = computed(() => {
|
||||
if (!this.showSimulation()) return null;
|
||||
|
||||
const vulnCount = this.context?.vulnIds?.length ?? 0;
|
||||
const componentCount = this.context?.componentPurls?.length ?? 0;
|
||||
|
||||
return {
|
||||
affectedFindings: vulnCount * Math.max(1, componentCount),
|
||||
policyImpact: this.draftForm.controls.severity.value === 'critical' ? 'high' : 'moderate',
|
||||
coverageEstimate: `~${Math.min(100, vulnCount * 15 + componentCount * 10)}%`,
|
||||
};
|
||||
});
|
||||
|
||||
readonly canSubmit = computed(() => {
|
||||
return this.draftForm.valid && !this.loading();
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.context?.suggestedName) {
|
||||
this.draftForm.patchValue({ name: this.context.suggestedName });
|
||||
}
|
||||
if (this.context?.suggestedSeverity) {
|
||||
this.draftForm.patchValue({ severity: this.context.suggestedSeverity });
|
||||
}
|
||||
|
||||
const defaultTemplate = this.quickTemplates[0];
|
||||
this.draftForm.patchValue({ justificationText: defaultTemplate.text });
|
||||
}
|
||||
|
||||
selectTemplate(templateId: string): void {
|
||||
const template = this.quickTemplates.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
this.draftForm.patchValue({
|
||||
justificationTemplate: templateId,
|
||||
justificationText: template.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleSimulation(): void {
|
||||
this.showSimulation.set(!this.showSimulation());
|
||||
}
|
||||
|
||||
async submitDraft(): Promise<void> {
|
||||
if (!this.canSubmit()) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
try {
|
||||
const formValue = this.draftForm.getRawValue();
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() + formValue.timeboxDays);
|
||||
|
||||
const exception: Partial<Exception> = {
|
||||
name: formValue.name,
|
||||
severity: formValue.severity,
|
||||
status: 'draft',
|
||||
scope: {
|
||||
type: this.scopeType(),
|
||||
tenantId: this.context?.tenantId,
|
||||
assetIds: this.context?.assetIds ? [...this.context.assetIds] : undefined,
|
||||
componentPurls: this.context?.componentPurls ? [...this.context.componentPurls] : undefined,
|
||||
vulnIds: this.context?.vulnIds ? [...this.context.vulnIds] : undefined,
|
||||
},
|
||||
justification: {
|
||||
template: formValue.justificationTemplate,
|
||||
text: formValue.justificationText,
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const created = await firstValueFrom(this.api.createException(exception));
|
||||
this.created.emit(created);
|
||||
this.router.navigate(['/exceptions', created.exceptionId]);
|
||||
} catch (err) {
|
||||
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelled.emit();
|
||||
}
|
||||
|
||||
expandToFullWizard(): void {
|
||||
this.openFullWizard.emit(this.context);
|
||||
}
|
||||
}
|
||||
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelled.emit();
|
||||
}
|
||||
|
||||
expandToFullWizard(): void {
|
||||
this.openFullWizard.emit(this.context);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -379,7 +379,7 @@ import {
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background: #EEF2FF;
|
||||
color: #4338CA;
|
||||
color: #E09115;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -507,7 +507,7 @@ import {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
background: #EEF2FF;
|
||||
color: #4338CA;
|
||||
color: #E09115;
|
||||
border-radius: 3px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -515,14 +515,14 @@ type SbomSourceType = 'file' | 'oci';
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #4338CA;
|
||||
color: #E09115;
|
||||
}
|
||||
|
||||
.remove-pattern {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
color: #6366F1;
|
||||
color: #D4920A;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
|
||||
@@ -206,7 +206,7 @@ const VIEWPORT_PADDING = 100;
|
||||
|
||||
<!-- Selection filter -->
|
||||
<filter id="selection-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#4f46e5" flood-opacity="0.5"/>
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#F5A623" flood-opacity="0.5"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
@@ -390,7 +390,7 @@ const VIEWPORT_PADDING = 100;
|
||||
[attr.width]="viewportBounds().maxX - viewportBounds().minX"
|
||||
[attr.height]="viewportBounds().maxY - viewportBounds().minY"
|
||||
fill="none"
|
||||
stroke="#4f46e5"
|
||||
stroke="#F5A623"
|
||||
stroke-width="8"
|
||||
/>
|
||||
</svg>
|
||||
@@ -413,7 +413,7 @@ const VIEWPORT_PADDING = 100;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #4f46e5;
|
||||
outline: 2px solid #F5A623;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -459,7 +459,7 @@ const VIEWPORT_PADDING = 100;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #4f46e5;
|
||||
outline: 2px solid #F5A623;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -507,16 +507,16 @@ const VIEWPORT_PADDING = 100;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #4f46e5;
|
||||
background: #F5A623;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #4338ca;
|
||||
background: #E09115;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #4f46e5;
|
||||
outline: 2px solid #F5A623;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
@@ -555,7 +555,7 @@ const VIEWPORT_PADDING = 100;
|
||||
|
||||
&--highlighted {
|
||||
stroke-width: 3;
|
||||
stroke: #4f46e5;
|
||||
stroke: #F5A623;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,14 +573,14 @@ const VIEWPORT_PADDING = 100;
|
||||
&--selected {
|
||||
.node-bg {
|
||||
filter: url(#selection-glow);
|
||||
stroke: #4f46e5 !important;
|
||||
stroke: #F5A623 !important;
|
||||
stroke-width: 3 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&--highlighted:not(.node-group--selected) {
|
||||
.node-bg {
|
||||
stroke: #818cf8 !important;
|
||||
stroke: #F5B84A !important;
|
||||
stroke-width: 2 !important;
|
||||
}
|
||||
}
|
||||
@@ -595,7 +595,7 @@ const VIEWPORT_PADDING = 100;
|
||||
outline: none;
|
||||
|
||||
.node-bg {
|
||||
stroke: #4f46e5 !important;
|
||||
stroke: #F5A623 !important;
|
||||
stroke-width: 3 !important;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,476 +1,476 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostListener,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
ExceptionDraftContext,
|
||||
ExceptionDraftInlineComponent,
|
||||
} from '../exceptions/exception-draft-inline.component';
|
||||
import {
|
||||
ExceptionBadgeComponent,
|
||||
ExceptionBadgeData,
|
||||
ExceptionExplainComponent,
|
||||
ExceptionExplainData,
|
||||
} from '../../shared/components';
|
||||
import {
|
||||
AUTH_SERVICE,
|
||||
AuthService,
|
||||
MockAuthService,
|
||||
StellaOpsScopes,
|
||||
} from '../../core/auth';
|
||||
import { GraphCanvasComponent, CanvasNode, CanvasEdge } from './graph-canvas.component';
|
||||
import { GraphOverlaysComponent, GraphOverlayState } from './graph-overlays.component';
|
||||
|
||||
export interface GraphNode {
|
||||
readonly id: string;
|
||||
readonly type: 'asset' | 'component' | 'vulnerability';
|
||||
readonly name: string;
|
||||
readonly purl?: string;
|
||||
readonly version?: string;
|
||||
readonly severity?: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly vulnCount?: number;
|
||||
readonly hasException?: boolean;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly type: 'depends_on' | 'has_vulnerability' | 'child_of';
|
||||
}
|
||||
|
||||
const MOCK_NODES: GraphNode[] = [
|
||||
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
|
||||
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
|
||||
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
|
||||
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1, hasException: true },
|
||||
{ id: 'comp-curl', type: 'component', name: 'curl', purl: 'pkg:deb/debian/curl@7.88.1-10', version: '7.88.1-10', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-nghttp2', type: 'component', name: 'nghttp2', purl: 'pkg:npm/nghttp2@1.55.0', version: '1.55.0', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-golang-net', type: 'component', name: 'golang.org/x/net', purl: 'pkg:golang/golang.org/x/net@0.15.0', version: '0.15.0', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-zlib', type: 'component', name: 'zlib', purl: 'pkg:deb/debian/zlib@1.2.13', version: '1.2.13', severity: 'medium', vulnCount: 1 },
|
||||
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
|
||||
{ id: 'vuln-log4j-dos', type: 'vulnerability', name: 'CVE-2021-45046', severity: 'critical', hasException: true },
|
||||
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical', hasException: true },
|
||||
{ id: 'vuln-http2-reset', type: 'vulnerability', name: 'CVE-2023-44487', severity: 'high' },
|
||||
{ id: 'vuln-curl-heap', type: 'vulnerability', name: 'CVE-2023-38545', severity: 'high' },
|
||||
];
|
||||
|
||||
const MOCK_EDGES: GraphEdge[] = [
|
||||
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-curl', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-nghttp2', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-zlib', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-curl', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-golang-net', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
|
||||
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
|
||||
{ source: 'comp-log4j', target: 'vuln-log4j-dos', type: 'has_vulnerability' },
|
||||
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
|
||||
{ source: 'comp-nghttp2', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||
{ source: 'comp-golang-net', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
|
||||
];
|
||||
|
||||
type ViewMode = 'hierarchy' | 'flat' | 'canvas';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-explorer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent, GraphCanvasComponent, GraphOverlaysComponent],
|
||||
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
|
||||
templateUrl: './graph-explorer.component.html',
|
||||
styleUrls: ['./graph-explorer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GraphExplorerComponent implements OnInit {
|
||||
private readonly authService = inject(AUTH_SERVICE);
|
||||
|
||||
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
|
||||
readonly canViewGraph = computed(() => this.authService.canViewGraph());
|
||||
readonly canEditGraph = computed(() => this.authService.canEditGraph());
|
||||
readonly canExportGraph = computed(() => this.authService.canExportGraph());
|
||||
readonly canSimulate = computed(() => this.authService.canSimulate());
|
||||
readonly canCreateException = computed(() =>
|
||||
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
|
||||
);
|
||||
|
||||
// Current user info
|
||||
readonly currentUser = computed(() => this.authService.user());
|
||||
readonly userScopes = computed(() => this.authService.scopes());
|
||||
|
||||
// View state
|
||||
readonly loading = signal(false);
|
||||
readonly message = signal<string | null>(null);
|
||||
readonly messageType = signal<'success' | 'error' | 'info'>('info');
|
||||
readonly viewMode = signal<ViewMode>('canvas'); // Default to canvas view
|
||||
|
||||
// Data
|
||||
readonly nodes = signal<GraphNode[]>([]);
|
||||
readonly edges = signal<GraphEdge[]>([]);
|
||||
readonly selectedNodeId = signal<string | null>(null);
|
||||
|
||||
// Exception draft state
|
||||
readonly showExceptionDraft = signal(false);
|
||||
|
||||
// Exception explain state
|
||||
readonly showExceptionExplain = signal(false);
|
||||
readonly explainNodeId = signal<string | null>(null);
|
||||
|
||||
// Filters
|
||||
readonly showVulnerabilities = signal(true);
|
||||
readonly showComponents = signal(true);
|
||||
readonly showAssets = signal(true);
|
||||
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
|
||||
|
||||
// Overlay state
|
||||
readonly overlayState = signal<GraphOverlayState | null>(null);
|
||||
readonly simulationMode = signal(false);
|
||||
readonly pathViewState = signal<{ enabled: boolean; type: string }>({ enabled: false, type: 'shortest' });
|
||||
readonly timeTravelState = signal<{ enabled: boolean; snapshot: string }>({ enabled: false, snapshot: 'current' });
|
||||
|
||||
// Computed: node IDs for overlay component
|
||||
readonly nodeIds = computed(() => this.filteredNodes().map(n => n.id));
|
||||
|
||||
// Computed: filtered nodes
|
||||
readonly filteredNodes = computed(() => {
|
||||
let items = [...this.nodes()];
|
||||
const showVulns = this.showVulnerabilities();
|
||||
const showComps = this.showComponents();
|
||||
const showAssetNodes = this.showAssets();
|
||||
const severity = this.filterSeverity();
|
||||
|
||||
items = items.filter((n) => {
|
||||
if (n.type === 'vulnerability' && !showVulns) return false;
|
||||
if (n.type === 'component' && !showComps) return false;
|
||||
if (n.type === 'asset' && !showAssetNodes) return false;
|
||||
if (severity !== 'all' && n.severity && n.severity !== severity) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
// Computed: canvas nodes (filtered for canvas view)
|
||||
readonly canvasNodes = computed<CanvasNode[]>(() => {
|
||||
return this.filteredNodes().map(n => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
name: n.name,
|
||||
purl: n.purl,
|
||||
version: n.version,
|
||||
severity: n.severity,
|
||||
vulnCount: n.vulnCount,
|
||||
hasException: n.hasException,
|
||||
}));
|
||||
});
|
||||
|
||||
// Computed: canvas edges (filtered based on visible nodes)
|
||||
readonly canvasEdges = computed<CanvasEdge[]>(() => {
|
||||
const visibleIds = new Set(this.filteredNodes().map(n => n.id));
|
||||
return this.edges()
|
||||
.filter(e => visibleIds.has(e.source) && visibleIds.has(e.target))
|
||||
.map(e => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: e.type,
|
||||
}));
|
||||
});
|
||||
|
||||
// Computed: assets
|
||||
readonly assets = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'asset');
|
||||
});
|
||||
|
||||
// Computed: components
|
||||
readonly components = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'component');
|
||||
});
|
||||
|
||||
// Computed: vulnerabilities
|
||||
readonly vulnerabilities = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'vulnerability');
|
||||
});
|
||||
|
||||
// Computed: selected node
|
||||
readonly selectedNode = computed(() => {
|
||||
const id = this.selectedNodeId();
|
||||
if (!id) return null;
|
||||
return this.nodes().find((n) => n.id === id) ?? null;
|
||||
});
|
||||
|
||||
// Computed: related nodes for selected
|
||||
readonly relatedNodes = computed(() => {
|
||||
const selectedId = this.selectedNodeId();
|
||||
if (!selectedId) return [];
|
||||
|
||||
const edgeList = this.edges();
|
||||
const relatedIds = new Set<string>();
|
||||
|
||||
edgeList.forEach((e) => {
|
||||
if (e.source === selectedId) relatedIds.add(e.target);
|
||||
if (e.target === selectedId) relatedIds.add(e.source);
|
||||
});
|
||||
|
||||
return this.nodes().filter((n) => relatedIds.has(n.id));
|
||||
});
|
||||
|
||||
// Get exception badge data for a node
|
||||
getExceptionBadgeData(node: GraphNode): ExceptionBadgeData | null {
|
||||
if (!node.hasException) return null;
|
||||
return {
|
||||
exceptionId: `exc-${node.id}`,
|
||||
status: 'approved',
|
||||
severity: node.severity ?? 'medium',
|
||||
name: `${node.name} Exception`,
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
justificationSummary: 'Risk accepted with compensating controls.',
|
||||
approvedBy: 'Security Team',
|
||||
};
|
||||
}
|
||||
|
||||
// Computed: explain data for selected node
|
||||
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
|
||||
const nodeId = this.explainNodeId();
|
||||
if (!nodeId) return null;
|
||||
|
||||
const node = this.nodes().find((n) => n.id === nodeId);
|
||||
if (!node || !node.hasException) return null;
|
||||
|
||||
const relatedComps = this.edges()
|
||||
.filter((e) => e.source === nodeId || e.target === nodeId)
|
||||
.map((e) => (e.source === nodeId ? e.target : e.source))
|
||||
.map((id) => this.nodes().find((n) => n.id === id))
|
||||
.filter((n): n is GraphNode => n !== undefined && n.type === 'component');
|
||||
|
||||
return {
|
||||
exceptionId: `exc-${node.id}`,
|
||||
name: `${node.name} Exception`,
|
||||
status: 'approved',
|
||||
severity: node.severity ?? 'medium',
|
||||
scope: {
|
||||
type: node.type === 'vulnerability' ? 'vulnerability' : 'component',
|
||||
vulnIds: node.type === 'vulnerability' ? [node.name] : undefined,
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
},
|
||||
justification: {
|
||||
template: 'risk-accepted',
|
||||
text: 'Risk accepted with compensating controls in place. The affected item is in a controlled environment.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
autoRenew: false,
|
||||
},
|
||||
approvedBy: 'Security Team',
|
||||
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
impact: {
|
||||
affectedFindings: 1,
|
||||
affectedAssets: 1,
|
||||
policyOverrides: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Computed: exception draft context
|
||||
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
|
||||
const node = this.selectedNode();
|
||||
if (!node) return null;
|
||||
|
||||
if (node.type === 'component') {
|
||||
const relatedVulns = this.relatedNodes().filter((n) => n.type === 'vulnerability');
|
||||
return {
|
||||
componentPurls: node.purl ? [node.purl] : undefined,
|
||||
vulnIds: relatedVulns.map((v) => v.name),
|
||||
suggestedName: `${node.name}-exception`,
|
||||
suggestedSeverity: node.severity ?? 'medium',
|
||||
sourceType: 'component',
|
||||
sourceLabel: `${node.name}@${node.version}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'vulnerability') {
|
||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||
return {
|
||||
vulnIds: [node.name],
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
suggestedName: `${node.name.toLowerCase()}-exception`,
|
||||
suggestedSeverity: node.severity ?? 'medium',
|
||||
sourceType: 'vulnerability',
|
||||
sourceLabel: node.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'asset') {
|
||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||
return {
|
||||
assetIds: [node.name],
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
suggestedName: `${node.name}-exception`,
|
||||
sourceType: 'asset',
|
||||
sourceLabel: node.name,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
loadData(): void {
|
||||
this.loading.set(true);
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
this.nodes.set([...MOCK_NODES]);
|
||||
this.edges.set([...MOCK_EDGES]);
|
||||
this.loading.set(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// View mode
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
// Filters
|
||||
toggleVulnerabilities(): void {
|
||||
this.showVulnerabilities.set(!this.showVulnerabilities());
|
||||
}
|
||||
|
||||
toggleComponents(): void {
|
||||
this.showComponents.set(!this.showComponents());
|
||||
}
|
||||
|
||||
toggleAssets(): void {
|
||||
this.showAssets.set(!this.showAssets());
|
||||
}
|
||||
|
||||
setSeverityFilter(severity: 'all' | 'critical' | 'high' | 'medium' | 'low'): void {
|
||||
this.filterSeverity.set(severity);
|
||||
}
|
||||
|
||||
// Selection
|
||||
selectNode(nodeId: string): void {
|
||||
this.selectedNodeId.set(nodeId);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedNodeId.set(null);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
// Exception drafting
|
||||
startExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(true);
|
||||
}
|
||||
|
||||
cancelExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
onExceptionCreated(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
this.showMessage('Exception draft created successfully', 'success');
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
openFullWizard(): void {
|
||||
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
|
||||
}
|
||||
|
||||
// Exception explain
|
||||
onViewExceptionDetails(exceptionId: string): void {
|
||||
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
|
||||
}
|
||||
|
||||
onExplainException(exceptionId: string): void {
|
||||
// Find the node with this exception ID
|
||||
const node = this.nodes().find((n) => `exc-${n.id}` === exceptionId);
|
||||
if (node) {
|
||||
this.explainNodeId.set(node.id);
|
||||
this.showExceptionExplain.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
closeExplain(): void {
|
||||
this.showExceptionExplain.set(false);
|
||||
this.explainNodeId.set(null);
|
||||
}
|
||||
|
||||
viewExceptionFromExplain(exceptionId: string): void {
|
||||
this.closeExplain();
|
||||
this.onViewExceptionDetails(exceptionId);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
getNodeTypeIcon(type: GraphNode['type']): string {
|
||||
switch (type) {
|
||||
case 'asset':
|
||||
return '📦';
|
||||
case 'component':
|
||||
return '🧩';
|
||||
case 'vulnerability':
|
||||
return '⚠️';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
getSeverityClass(severity: string | undefined): string {
|
||||
if (!severity) return '';
|
||||
return `severity--${severity}`;
|
||||
}
|
||||
|
||||
getNodeClass(node: GraphNode): string {
|
||||
const classes = [`node--${node.type}`];
|
||||
if (node.severity) classes.push(`node--${node.severity}`);
|
||||
if (node.hasException) classes.push('node--excepted');
|
||||
if (this.selectedNodeId() === node.id) classes.push('node--selected');
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
trackByNode = (_: number, item: GraphNode) => item.id;
|
||||
|
||||
// Overlay handlers
|
||||
onOverlayStateChange(state: GraphOverlayState): void {
|
||||
this.overlayState.set(state);
|
||||
}
|
||||
|
||||
onSimulationModeChange(enabled: boolean): void {
|
||||
this.simulationMode.set(enabled);
|
||||
this.showMessage(enabled ? 'Simulation mode enabled' : 'Simulation mode disabled', 'info');
|
||||
}
|
||||
|
||||
onPathViewChange(state: { enabled: boolean; type: string }): void {
|
||||
this.pathViewState.set(state);
|
||||
if (state.enabled) {
|
||||
this.showMessage(`Path view enabled: ${state.type}`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
onTimeTravelChange(state: { enabled: boolean; snapshot: string }): void {
|
||||
this.timeTravelState.set(state);
|
||||
if (state.enabled && state.snapshot !== 'current') {
|
||||
this.showMessage(`Viewing snapshot: ${state.snapshot}`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
onShowDiffRequest(snapshot: string): void {
|
||||
this.showMessage(`Loading diff for ${snapshot}...`, 'info');
|
||||
}
|
||||
|
||||
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
|
||||
this.message.set(text);
|
||||
this.messageType.set(type);
|
||||
setTimeout(() => this.message.set(null), 5000);
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostListener,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
ExceptionDraftContext,
|
||||
ExceptionDraftInlineComponent,
|
||||
} from '../exceptions/exception-draft-inline.component';
|
||||
import {
|
||||
ExceptionBadgeComponent,
|
||||
ExceptionBadgeData,
|
||||
ExceptionExplainComponent,
|
||||
ExceptionExplainData,
|
||||
} from '../../shared/components';
|
||||
import {
|
||||
AUTH_SERVICE,
|
||||
AuthService,
|
||||
MockAuthService,
|
||||
StellaOpsScopes,
|
||||
} from '../../core/auth';
|
||||
import { GraphCanvasComponent, CanvasNode, CanvasEdge } from './graph-canvas.component';
|
||||
import { GraphOverlaysComponent, GraphOverlayState } from './graph-overlays.component';
|
||||
|
||||
export interface GraphNode {
|
||||
readonly id: string;
|
||||
readonly type: 'asset' | 'component' | 'vulnerability';
|
||||
readonly name: string;
|
||||
readonly purl?: string;
|
||||
readonly version?: string;
|
||||
readonly severity?: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly vulnCount?: number;
|
||||
readonly hasException?: boolean;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly type: 'depends_on' | 'has_vulnerability' | 'child_of';
|
||||
}
|
||||
|
||||
const MOCK_NODES: GraphNode[] = [
|
||||
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
|
||||
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
|
||||
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
|
||||
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1, hasException: true },
|
||||
{ id: 'comp-curl', type: 'component', name: 'curl', purl: 'pkg:deb/debian/curl@7.88.1-10', version: '7.88.1-10', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-nghttp2', type: 'component', name: 'nghttp2', purl: 'pkg:npm/nghttp2@1.55.0', version: '1.55.0', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-golang-net', type: 'component', name: 'golang.org/x/net', purl: 'pkg:golang/golang.org/x/net@0.15.0', version: '0.15.0', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-zlib', type: 'component', name: 'zlib', purl: 'pkg:deb/debian/zlib@1.2.13', version: '1.2.13', severity: 'medium', vulnCount: 1 },
|
||||
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
|
||||
{ id: 'vuln-log4j-dos', type: 'vulnerability', name: 'CVE-2021-45046', severity: 'critical', hasException: true },
|
||||
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical', hasException: true },
|
||||
{ id: 'vuln-http2-reset', type: 'vulnerability', name: 'CVE-2023-44487', severity: 'high' },
|
||||
{ id: 'vuln-curl-heap', type: 'vulnerability', name: 'CVE-2023-38545', severity: 'high' },
|
||||
];
|
||||
|
||||
const MOCK_EDGES: GraphEdge[] = [
|
||||
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-curl', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-nghttp2', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-zlib', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-curl', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-golang-net', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
|
||||
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
|
||||
{ source: 'comp-log4j', target: 'vuln-log4j-dos', type: 'has_vulnerability' },
|
||||
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
|
||||
{ source: 'comp-nghttp2', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||
{ source: 'comp-golang-net', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
|
||||
];
|
||||
|
||||
type ViewMode = 'hierarchy' | 'flat' | 'canvas';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-explorer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent, GraphCanvasComponent, GraphOverlaysComponent],
|
||||
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
|
||||
templateUrl: './graph-explorer.component.html',
|
||||
styleUrls: ['./graph-explorer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GraphExplorerComponent implements OnInit {
|
||||
private readonly authService = inject(AUTH_SERVICE);
|
||||
|
||||
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
|
||||
readonly canViewGraph = computed(() => this.authService.canViewGraph());
|
||||
readonly canEditGraph = computed(() => this.authService.canEditGraph());
|
||||
readonly canExportGraph = computed(() => this.authService.canExportGraph());
|
||||
readonly canSimulate = computed(() => this.authService.canSimulate());
|
||||
readonly canCreateException = computed(() =>
|
||||
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
|
||||
);
|
||||
|
||||
// Current user info
|
||||
readonly currentUser = computed(() => this.authService.user());
|
||||
readonly userScopes = computed(() => this.authService.scopes());
|
||||
|
||||
// View state
|
||||
readonly loading = signal(false);
|
||||
readonly message = signal<string | null>(null);
|
||||
readonly messageType = signal<'success' | 'error' | 'info'>('info');
|
||||
readonly viewMode = signal<ViewMode>('canvas'); // Default to canvas view
|
||||
|
||||
// Data
|
||||
readonly nodes = signal<GraphNode[]>([]);
|
||||
readonly edges = signal<GraphEdge[]>([]);
|
||||
readonly selectedNodeId = signal<string | null>(null);
|
||||
|
||||
// Exception draft state
|
||||
readonly showExceptionDraft = signal(false);
|
||||
|
||||
// Exception explain state
|
||||
readonly showExceptionExplain = signal(false);
|
||||
readonly explainNodeId = signal<string | null>(null);
|
||||
|
||||
// Filters
|
||||
readonly showVulnerabilities = signal(true);
|
||||
readonly showComponents = signal(true);
|
||||
readonly showAssets = signal(true);
|
||||
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
|
||||
|
||||
// Overlay state
|
||||
readonly overlayState = signal<GraphOverlayState | null>(null);
|
||||
readonly simulationMode = signal(false);
|
||||
readonly pathViewState = signal<{ enabled: boolean; type: string }>({ enabled: false, type: 'shortest' });
|
||||
readonly timeTravelState = signal<{ enabled: boolean; snapshot: string }>({ enabled: false, snapshot: 'current' });
|
||||
|
||||
// Computed: node IDs for overlay component
|
||||
readonly nodeIds = computed(() => this.filteredNodes().map(n => n.id));
|
||||
|
||||
// Computed: filtered nodes
|
||||
readonly filteredNodes = computed(() => {
|
||||
let items = [...this.nodes()];
|
||||
const showVulns = this.showVulnerabilities();
|
||||
const showComps = this.showComponents();
|
||||
const showAssetNodes = this.showAssets();
|
||||
const severity = this.filterSeverity();
|
||||
|
||||
items = items.filter((n) => {
|
||||
if (n.type === 'vulnerability' && !showVulns) return false;
|
||||
if (n.type === 'component' && !showComps) return false;
|
||||
if (n.type === 'asset' && !showAssetNodes) return false;
|
||||
if (severity !== 'all' && n.severity && n.severity !== severity) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
// Computed: canvas nodes (filtered for canvas view)
|
||||
readonly canvasNodes = computed<CanvasNode[]>(() => {
|
||||
return this.filteredNodes().map(n => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
name: n.name,
|
||||
purl: n.purl,
|
||||
version: n.version,
|
||||
severity: n.severity,
|
||||
vulnCount: n.vulnCount,
|
||||
hasException: n.hasException,
|
||||
}));
|
||||
});
|
||||
|
||||
// Computed: canvas edges (filtered based on visible nodes)
|
||||
readonly canvasEdges = computed<CanvasEdge[]>(() => {
|
||||
const visibleIds = new Set(this.filteredNodes().map(n => n.id));
|
||||
return this.edges()
|
||||
.filter(e => visibleIds.has(e.source) && visibleIds.has(e.target))
|
||||
.map(e => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: e.type,
|
||||
}));
|
||||
});
|
||||
|
||||
// Computed: assets
|
||||
readonly assets = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'asset');
|
||||
});
|
||||
|
||||
// Computed: components
|
||||
readonly components = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'component');
|
||||
});
|
||||
|
||||
// Computed: vulnerabilities
|
||||
readonly vulnerabilities = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'vulnerability');
|
||||
});
|
||||
|
||||
// Computed: selected node
|
||||
readonly selectedNode = computed(() => {
|
||||
const id = this.selectedNodeId();
|
||||
if (!id) return null;
|
||||
return this.nodes().find((n) => n.id === id) ?? null;
|
||||
});
|
||||
|
||||
// Computed: related nodes for selected
|
||||
readonly relatedNodes = computed(() => {
|
||||
const selectedId = this.selectedNodeId();
|
||||
if (!selectedId) return [];
|
||||
|
||||
const edgeList = this.edges();
|
||||
const relatedIds = new Set<string>();
|
||||
|
||||
edgeList.forEach((e) => {
|
||||
if (e.source === selectedId) relatedIds.add(e.target);
|
||||
if (e.target === selectedId) relatedIds.add(e.source);
|
||||
});
|
||||
|
||||
return this.nodes().filter((n) => relatedIds.has(n.id));
|
||||
});
|
||||
|
||||
// Get exception badge data for a node
|
||||
getExceptionBadgeData(node: GraphNode): ExceptionBadgeData | null {
|
||||
if (!node.hasException) return null;
|
||||
return {
|
||||
exceptionId: `exc-${node.id}`,
|
||||
status: 'approved',
|
||||
severity: node.severity ?? 'medium',
|
||||
name: `${node.name} Exception`,
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
justificationSummary: 'Risk accepted with compensating controls.',
|
||||
approvedBy: 'Security Team',
|
||||
};
|
||||
}
|
||||
|
||||
// Computed: explain data for selected node
|
||||
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
|
||||
const nodeId = this.explainNodeId();
|
||||
if (!nodeId) return null;
|
||||
|
||||
const node = this.nodes().find((n) => n.id === nodeId);
|
||||
if (!node || !node.hasException) return null;
|
||||
|
||||
const relatedComps = this.edges()
|
||||
.filter((e) => e.source === nodeId || e.target === nodeId)
|
||||
.map((e) => (e.source === nodeId ? e.target : e.source))
|
||||
.map((id) => this.nodes().find((n) => n.id === id))
|
||||
.filter((n): n is GraphNode => n !== undefined && n.type === 'component');
|
||||
|
||||
return {
|
||||
exceptionId: `exc-${node.id}`,
|
||||
name: `${node.name} Exception`,
|
||||
status: 'approved',
|
||||
severity: node.severity ?? 'medium',
|
||||
scope: {
|
||||
type: node.type === 'vulnerability' ? 'vulnerability' : 'component',
|
||||
vulnIds: node.type === 'vulnerability' ? [node.name] : undefined,
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
},
|
||||
justification: {
|
||||
template: 'risk-accepted',
|
||||
text: 'Risk accepted with compensating controls in place. The affected item is in a controlled environment.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
autoRenew: false,
|
||||
},
|
||||
approvedBy: 'Security Team',
|
||||
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
impact: {
|
||||
affectedFindings: 1,
|
||||
affectedAssets: 1,
|
||||
policyOverrides: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Computed: exception draft context
|
||||
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
|
||||
const node = this.selectedNode();
|
||||
if (!node) return null;
|
||||
|
||||
if (node.type === 'component') {
|
||||
const relatedVulns = this.relatedNodes().filter((n) => n.type === 'vulnerability');
|
||||
return {
|
||||
componentPurls: node.purl ? [node.purl] : undefined,
|
||||
vulnIds: relatedVulns.map((v) => v.name),
|
||||
suggestedName: `${node.name}-exception`,
|
||||
suggestedSeverity: node.severity ?? 'medium',
|
||||
sourceType: 'component',
|
||||
sourceLabel: `${node.name}@${node.version}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'vulnerability') {
|
||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||
return {
|
||||
vulnIds: [node.name],
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
suggestedName: `${node.name.toLowerCase()}-exception`,
|
||||
suggestedSeverity: node.severity ?? 'medium',
|
||||
sourceType: 'vulnerability',
|
||||
sourceLabel: node.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'asset') {
|
||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||
return {
|
||||
assetIds: [node.name],
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
suggestedName: `${node.name}-exception`,
|
||||
sourceType: 'asset',
|
||||
sourceLabel: node.name,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
loadData(): void {
|
||||
this.loading.set(true);
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
this.nodes.set([...MOCK_NODES]);
|
||||
this.edges.set([...MOCK_EDGES]);
|
||||
this.loading.set(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// View mode
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
// Filters
|
||||
toggleVulnerabilities(): void {
|
||||
this.showVulnerabilities.set(!this.showVulnerabilities());
|
||||
}
|
||||
|
||||
toggleComponents(): void {
|
||||
this.showComponents.set(!this.showComponents());
|
||||
}
|
||||
|
||||
toggleAssets(): void {
|
||||
this.showAssets.set(!this.showAssets());
|
||||
}
|
||||
|
||||
setSeverityFilter(severity: 'all' | 'critical' | 'high' | 'medium' | 'low'): void {
|
||||
this.filterSeverity.set(severity);
|
||||
}
|
||||
|
||||
// Selection
|
||||
selectNode(nodeId: string): void {
|
||||
this.selectedNodeId.set(nodeId);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedNodeId.set(null);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
// Exception drafting
|
||||
startExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(true);
|
||||
}
|
||||
|
||||
cancelExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
onExceptionCreated(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
this.showMessage('Exception draft created successfully', 'success');
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
openFullWizard(): void {
|
||||
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
|
||||
}
|
||||
|
||||
// Exception explain
|
||||
onViewExceptionDetails(exceptionId: string): void {
|
||||
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
|
||||
}
|
||||
|
||||
onExplainException(exceptionId: string): void {
|
||||
// Find the node with this exception ID
|
||||
const node = this.nodes().find((n) => `exc-${n.id}` === exceptionId);
|
||||
if (node) {
|
||||
this.explainNodeId.set(node.id);
|
||||
this.showExceptionExplain.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
closeExplain(): void {
|
||||
this.showExceptionExplain.set(false);
|
||||
this.explainNodeId.set(null);
|
||||
}
|
||||
|
||||
viewExceptionFromExplain(exceptionId: string): void {
|
||||
this.closeExplain();
|
||||
this.onViewExceptionDetails(exceptionId);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
getNodeTypeIcon(type: GraphNode['type']): string {
|
||||
switch (type) {
|
||||
case 'asset':
|
||||
return '📦';
|
||||
case 'component':
|
||||
return '🧩';
|
||||
case 'vulnerability':
|
||||
return '⚠️';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
getSeverityClass(severity: string | undefined): string {
|
||||
if (!severity) return '';
|
||||
return `severity--${severity}`;
|
||||
}
|
||||
|
||||
getNodeClass(node: GraphNode): string {
|
||||
const classes = [`node--${node.type}`];
|
||||
if (node.severity) classes.push(`node--${node.severity}`);
|
||||
if (node.hasException) classes.push('node--excepted');
|
||||
if (this.selectedNodeId() === node.id) classes.push('node--selected');
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
trackByNode = (_: number, item: GraphNode) => item.id;
|
||||
|
||||
// Overlay handlers
|
||||
onOverlayStateChange(state: GraphOverlayState): void {
|
||||
this.overlayState.set(state);
|
||||
}
|
||||
|
||||
onSimulationModeChange(enabled: boolean): void {
|
||||
this.simulationMode.set(enabled);
|
||||
this.showMessage(enabled ? 'Simulation mode enabled' : 'Simulation mode disabled', 'info');
|
||||
}
|
||||
|
||||
onPathViewChange(state: { enabled: boolean; type: string }): void {
|
||||
this.pathViewState.set(state);
|
||||
if (state.enabled) {
|
||||
this.showMessage(`Path view enabled: ${state.type}`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
onTimeTravelChange(state: { enabled: boolean; snapshot: string }): void {
|
||||
this.timeTravelState.set(state);
|
||||
if (state.enabled && state.snapshot !== 'current') {
|
||||
this.showMessage(`Viewing snapshot: ${state.snapshot}`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
onShowDiffRequest(snapshot: string): void {
|
||||
this.showMessage(`Loading diff for ${snapshot}...`, 'info');
|
||||
}
|
||||
|
||||
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
|
||||
this.message.set(text);
|
||||
this.messageType.set(type);
|
||||
setTimeout(() => this.message.set(null), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,18 +494,18 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #F5A623;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
background: #F5A623;
|
||||
border-color: #F5A623;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #4338ca;
|
||||
border-color: #4338ca;
|
||||
background: #E09115;
|
||||
border-color: #E09115;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -649,7 +649,7 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -681,8 +681,8 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #F5A623;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -769,8 +769,8 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #F5A623;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -844,7 +844,7 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,11 +872,11 @@ const MOCK_SAVED_VIEWS: SavedView[] = [
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: #4f46e5;
|
||||
background: #F5A623;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #4338ca;
|
||||
background: #E09115;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@@ -156,7 +156,7 @@ import { GraphAccessibilityService, HotkeyBinding } from './graph-accessibility.
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #4f46e5;
|
||||
outline: 2px solid #F5A623;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,18 +606,18 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--overlay-color, #4f46e5);
|
||||
border-color: var(--overlay-color, #F5A623);
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: var(--overlay-color, #4f46e5);
|
||||
background: color-mix(in srgb, var(--overlay-color, #4f46e5) 10%, white);
|
||||
color: var(--overlay-color, #4f46e5);
|
||||
border-color: var(--overlay-color, #F5A623);
|
||||
background: color-mix(in srgb, var(--overlay-color, #F5A623) 10%, white);
|
||||
color: var(--overlay-color, #F5A623);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--overlay-color, #4f46e5);
|
||||
outline: 2px solid var(--overlay-color, #F5A623);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -634,7 +634,7 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--overlay-color, #4f46e5);
|
||||
background: var(--overlay-color, #F5A623);
|
||||
}
|
||||
|
||||
/* Simulation toggle */
|
||||
@@ -869,14 +869,14 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1023,7 +1023,7 @@ export class GraphOverlaysComponent implements OnChanges {
|
||||
|
||||
// Overlay configurations
|
||||
readonly overlayConfigs = signal<OverlayConfig[]>([
|
||||
{ type: 'policy', enabled: false, label: 'Policy', icon: '📋', color: '#4f46e5' },
|
||||
{ type: 'policy', enabled: false, label: 'Policy', icon: '📋', color: '#F5A623' },
|
||||
{ type: 'evidence', enabled: false, label: 'Evidence', icon: '🔍', color: '#0ea5e9' },
|
||||
{ type: 'license', enabled: false, label: 'License', icon: '📜', color: '#22c55e' },
|
||||
{ type: 'exposure', enabled: false, label: 'Exposure', icon: '🌐', color: '#ef4444' },
|
||||
|
||||
@@ -610,7 +610,7 @@ function generateMockDiff(): SbomDiff {
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
background: white;
|
||||
|
||||
&::after {
|
||||
@@ -620,7 +620,7 @@ function generateMockDiff(): SbomDiff {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #4f46e5;
|
||||
background: #F5A623;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -704,8 +704,8 @@ function generateMockDiff(): SbomDiff {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #F5A623;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,7 +748,7 @@ function generateMockDiff(): SbomDiff {
|
||||
font-size: 0.8125rem;
|
||||
|
||||
a {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
@@ -776,7 +776,7 @@ function generateMockDiff(): SbomDiff {
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
@@ -866,11 +866,11 @@ function generateMockDiff(): SbomDiff {
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
@@ -989,11 +989,11 @@ function generateMockDiff(): SbomDiff {
|
||||
cursor: pointer;
|
||||
|
||||
&--primary {
|
||||
background: #4f46e5;
|
||||
background: #F5A623;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #4338ca;
|
||||
background: #E09115;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1003,8 +1003,8 @@ function generateMockDiff(): SbomDiff {
|
||||
color: #475569;
|
||||
|
||||
&:hover {
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #F5A623;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1151,8 +1151,8 @@ function generateMockDiff(): SbomDiff {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
border-color: #F5A623;
|
||||
color: #F5A623;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@@ -52,7 +52,7 @@ import { GateDisplay, GateChangeDisplay, GateType } from '../models/reachability
|
||||
}
|
||||
}
|
||||
|
||||
.gate-chip.auth { border-left-color: #6366f1; }
|
||||
.gate-chip.auth { border-left-color: #D4920A; }
|
||||
.gate-chip.feature-flag { border-left-color: #f59e0b; }
|
||||
.gate-chip.config { border-left-color: #8b5cf6; }
|
||||
.gate-chip.runtime { border-left-color: #ec4899; }
|
||||
|
||||
@@ -1,394 +1,394 @@
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.notify-panel {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.notify-panel__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-6);
|
||||
|
||||
h1 {
|
||||
margin: var(--space-1) 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notify-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.notify-card {
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.notify-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: var(--color-brand-light);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.notify-message {
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.channel-list,
|
||||
.rule-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.channel-item,
|
||||
.rule-item {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-primary);
|
||||
color: inherit;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
transform var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&.active {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: var(--color-brand-light);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.channel-meta,
|
||||
.rule-meta {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.channel-status {
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.channel-status--enabled {
|
||||
border-color: var(--color-status-success);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
label span {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: inherit;
|
||||
padding: var(--space-2);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
input {
|
||||
width: auto;
|
||||
accent-color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.notify-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.notify-actions button {
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--space-1-5) var(--space-4);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
transition: opacity var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-brand-primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.notify-actions .ghost-button {
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.channel-health {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-tertiary);
|
||||
border: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.status-pill--healthy {
|
||||
border-color: var(--color-status-success);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.status-pill--warning {
|
||||
border-color: var(--color-status-warning);
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
.status-pill--error {
|
||||
border-color: var(--color-status-error);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.channel-health__details p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.channel-health__details small {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.test-form h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.test-preview {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface-tertiary);
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: var(--space-1) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
font-family: var(--font-family-mono);
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2-5);
|
||||
}
|
||||
}
|
||||
|
||||
.deliveries-controls {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.deliveries-controls label {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.deliveries-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding-bottom: var(--space-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: var(--space-2) var(--space-1);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.status-badge--sent {
|
||||
border-color: var(--color-status-success);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.status-badge--failed {
|
||||
border-color: var(--color-status-error);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.status-badge--throttled {
|
||||
border-color: var(--color-status-warning);
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
@include screen-below-md {
|
||||
.notify-panel {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.notify-panel__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.notify-panel {
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.notify-panel__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-6);
|
||||
|
||||
h1 {
|
||||
margin: var(--space-1) 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notify-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.notify-card {
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.notify-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: var(--color-brand-light);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.notify-message {
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.channel-list,
|
||||
.rule-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.channel-item,
|
||||
.rule-item {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-primary);
|
||||
color: inherit;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
transform var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&.active {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: var(--color-brand-light);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.channel-meta,
|
||||
.rule-meta {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.channel-status {
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.channel-status--enabled {
|
||||
border-color: var(--color-status-success);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
label span {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: inherit;
|
||||
padding: var(--space-2);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
input {
|
||||
width: auto;
|
||||
accent-color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.notify-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.notify-actions button {
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--space-1-5) var(--space-4);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
transition: opacity var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-brand-primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.notify-actions .ghost-button {
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.channel-health {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-tertiary);
|
||||
border: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.status-pill--healthy {
|
||||
border-color: var(--color-status-success);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.status-pill--warning {
|
||||
border-color: var(--color-status-warning);
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
.status-pill--error {
|
||||
border-color: var(--color-status-error);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.channel-health__details p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.channel-health__details small {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.test-form h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.test-preview {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface-tertiary);
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: var(--space-1) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
font-family: var(--font-family-mono);
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2-5);
|
||||
}
|
||||
}
|
||||
|
||||
.deliveries-controls {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.deliveries-controls label {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.deliveries-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding-bottom: var(--space-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: var(--space-2) var(--space-1);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.status-badge--sent {
|
||||
border-color: var(--color-status-success);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.status-badge--failed {
|
||||
border-color: var(--color-status-error);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.status-badge--throttled {
|
||||
border-color: var(--color-status-warning);
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
@include screen-below-md {
|
||||
.notify-panel {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.notify-panel__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NOTIFY_API } from '../../core/api/notify.client';
|
||||
import { MockNotifyApiService } from '../../testing/mock-notify-api.service';
|
||||
import { NotifyPanelComponent } from './notify-panel.component';
|
||||
|
||||
describe('NotifyPanelComponent', () => {
|
||||
let fixture: ComponentFixture<NotifyPanelComponent>;
|
||||
let component: NotifyPanelComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NotifyPanelComponent],
|
||||
providers: [
|
||||
MockNotifyApiService,
|
||||
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(NotifyPanelComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders channels from the mocked API', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
const items: NodeListOf<HTMLButtonElement> =
|
||||
fixture.nativeElement.querySelectorAll('[data-testid="channel-item"]');
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('persists a new rule via the mocked API', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
|
||||
component.createRuleDraft();
|
||||
component.ruleForm.patchValue({
|
||||
name: 'Notify preview rule',
|
||||
channel: component.channels()[0]?.channelId ?? '',
|
||||
eventKindsText: 'scanner.report.ready',
|
||||
labelsText: 'kev',
|
||||
});
|
||||
|
||||
await component.saveRule();
|
||||
fixture.detectChanges();
|
||||
|
||||
const ruleButtons: HTMLElement[] = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('[data-testid="rule-item"]')
|
||||
);
|
||||
expect(
|
||||
ruleButtons.some((el) => el.textContent?.includes('Notify preview rule'))
|
||||
).toBeTrue();
|
||||
});
|
||||
|
||||
it('shows a test preview after sending', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
|
||||
await component.sendTestPreview();
|
||||
fixture.detectChanges();
|
||||
|
||||
const preview = fixture.nativeElement.querySelector('[data-testid="test-preview"]');
|
||||
expect(preview).toBeTruthy();
|
||||
});
|
||||
});
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NOTIFY_API } from '../../core/api/notify.client';
|
||||
import { MockNotifyApiService } from '../../testing/mock-notify-api.service';
|
||||
import { NotifyPanelComponent } from './notify-panel.component';
|
||||
|
||||
describe('NotifyPanelComponent', () => {
|
||||
let fixture: ComponentFixture<NotifyPanelComponent>;
|
||||
let component: NotifyPanelComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NotifyPanelComponent],
|
||||
providers: [
|
||||
MockNotifyApiService,
|
||||
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(NotifyPanelComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders channels from the mocked API', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
const items: NodeListOf<HTMLButtonElement> =
|
||||
fixture.nativeElement.querySelectorAll('[data-testid="channel-item"]');
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('persists a new rule via the mocked API', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
|
||||
component.createRuleDraft();
|
||||
component.ruleForm.patchValue({
|
||||
name: 'Notify preview rule',
|
||||
channel: component.channels()[0]?.channelId ?? '',
|
||||
eventKindsText: 'scanner.report.ready',
|
||||
labelsText: 'kev',
|
||||
});
|
||||
|
||||
await component.saveRule();
|
||||
fixture.detectChanges();
|
||||
|
||||
const ruleButtons: HTMLElement[] = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('[data-testid="rule-item"]')
|
||||
);
|
||||
expect(
|
||||
ruleButtons.some((el) => el.textContent?.includes('Notify preview rule'))
|
||||
).toBeTrue();
|
||||
});
|
||||
|
||||
it('shows a test preview after sending', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
|
||||
await component.sendTestPreview();
|
||||
fixture.detectChanges();
|
||||
|
||||
const preview = fixture.nativeElement.querySelector('[data-testid="test-preview"]');
|
||||
expect(preview).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,7 +109,7 @@ import { ShadowModeConfig } from '../../core/api/policy-simulation.models';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #a5b4fc;
|
||||
color: #FFCF70;
|
||||
}
|
||||
|
||||
.shadow-indicator--enabled .shadow-indicator__icon {
|
||||
|
||||
@@ -222,7 +222,7 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
|
||||
.approvals__eyebrow {
|
||||
margin: 0;
|
||||
color: #a5b4fc;
|
||||
color: #FFCF70;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -440,7 +440,7 @@ import type { PolicyParseResult, PolicyIntent } from '../../../core/api/advisory
|
||||
}
|
||||
|
||||
.intent-badge.type-ScopeRestriction {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
|
||||
.sim__eyebrow {
|
||||
margin: 0;
|
||||
color: #a5b4fc;
|
||||
color: #FFCF70;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -109,7 +109,7 @@ import { PolicyPackStore } from '../services/policy-pack.store';
|
||||
.workspace__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
|
||||
.pack-card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; box-shadow: 0 12px 30px rgba(0,0,0,0.28); display: grid; gap: 0.6rem; }
|
||||
.pack-card__head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
|
||||
.pack-card__eyebrow { margin: 0; color: #a5b4fc; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
|
||||
.pack-card__eyebrow { margin: 0; color: #FFCF70; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
|
||||
.pack-card__desc { margin: 0.2rem 0 0; color: #cbd5e1; }
|
||||
.pack-card__meta { display: grid; justify-items: end; gap: 0.2rem; color: #94a3b8; font-size: 0.9rem; }
|
||||
.pack-card__tags { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
|
||||
@@ -563,8 +563,8 @@ type SortOrder = 'asc' | 'desc';
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: #4f46e5;
|
||||
border-bottom-color: #4f46e5;
|
||||
color: #F5A623;
|
||||
border-bottom-color: #F5A623;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,7 +582,7 @@ type SortOrder = 'asc' | 'desc';
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-top-color: #4f46e5;
|
||||
border-top-color: #F5A623;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@@ -675,7 +675,7 @@ type SortOrder = 'asc' | 'desc';
|
||||
}
|
||||
|
||||
.policy-studio__link {
|
||||
color: #4f46e5;
|
||||
color: #F5A623;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -720,13 +720,13 @@ type SortOrder = 'asc' | 'desc';
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
background: #F5A623;
|
||||
border-color: #F5A623;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #4338ca;
|
||||
border-color: #4338ca;
|
||||
background: #E09115;
|
||||
border-color: #E09115;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,8 +762,8 @@ type SortOrder = 'asc' | 'desc';
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
border-color: #F5A623;
|
||||
box-shadow: 0 0 0 3px rgba(245, 166, 35, 0.1);
|
||||
}
|
||||
|
||||
&--sm {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Releases Feature Module
|
||||
* Sprint: SPRINT_20260118_004_FE_releases_feature
|
||||
*/
|
||||
|
||||
export * from './releases.routes';
|
||||
export { ReleaseFlowComponent } from './release-flow.component';
|
||||
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
||||
export { RemediationHintsComponent } from './remediation-hints.component';
|
||||
/**
|
||||
* Releases Feature Module
|
||||
* Sprint: SPRINT_20260118_004_FE_releases_feature
|
||||
*/
|
||||
|
||||
export * from './releases.routes';
|
||||
export { ReleaseFlowComponent } from './release-flow.component';
|
||||
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
||||
export { RemediationHintsComponent } from './remediation-hints.component';
|
||||
|
||||
@@ -1,328 +1,328 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PolicyGateResult,
|
||||
PolicyGateStatus,
|
||||
DeterminismFeatureFlags,
|
||||
} from '../../core/api/release.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-gate-indicator',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div
|
||||
class="gate-indicator"
|
||||
[class.gate-indicator--expanded]="expanded()"
|
||||
[class]="'gate-indicator--' + gate().status"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="gate-header"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
[attr.aria-controls]="'gate-details-' + gate().gateId"
|
||||
>
|
||||
<div class="gate-status">
|
||||
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
|
||||
@switch (gate().status) {
|
||||
@case ('passed') { <span>✓</span> }
|
||||
@case ('failed') { <span>✗</span> }
|
||||
@case ('warning') { <span>!</span> }
|
||||
@case ('pending') { <span>⌛</span> }
|
||||
@case ('skipped') { <span>-</span> }
|
||||
}
|
||||
</span>
|
||||
<span class="status-text">{{ getStatusLabel() }}</span>
|
||||
</div>
|
||||
<div class="gate-info">
|
||||
<span class="gate-name">{{ gate().name }}</span>
|
||||
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
|
||||
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
|
||||
}
|
||||
@if (gate().blockingPublish) {
|
||||
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
|
||||
}
|
||||
</div>
|
||||
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
|
||||
@if (expanded()) {
|
||||
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
|
||||
<p class="gate-message">{{ gate().message }}</p>
|
||||
<div class="gate-meta">
|
||||
<span class="meta-item">
|
||||
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
|
||||
</span>
|
||||
@if (gate().evidence?.url) {
|
||||
<a
|
||||
[href]="gate().evidence?.url"
|
||||
class="evidence-link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
View Evidence
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Determinism-specific info when feature flag shows it -->
|
||||
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
|
||||
<div class="feature-flag-info">
|
||||
@if (featureFlags()?.blockOnFailure) {
|
||||
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
|
||||
} @else if (featureFlags()?.warnOnly) {
|
||||
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.gate-indicator {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&--passed {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
border-left: 3px solid #f97316;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
border-left: 3px solid #eab308;
|
||||
}
|
||||
|
||||
&--skipped {
|
||||
border-left: 3px solid #64748b;
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
border-color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.gate-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.gate-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-icon {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.gate-indicator--failed .status-icon {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.gate-indicator--warning .status-icon {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.gate-indicator--pending .status-icon {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.gate-indicator--skipped .status-icon {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-text { color: #22c55e; }
|
||||
.gate-indicator--failed .status-text { color: #ef4444; }
|
||||
.gate-indicator--warning .status-text { color: #f97316; }
|
||||
.gate-indicator--pending .status-text { color: #eab308; }
|
||||
.gate-indicator--skipped .status-text { color: #64748b; }
|
||||
|
||||
.gate-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.gate-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gate-type-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--determinism {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
}
|
||||
|
||||
.blocking-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #64748b;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.gate-details {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-top: 1px solid #334155;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.gate-message {
|
||||
margin: 0.75rem 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gate-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
|
||||
strong {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-flag-info {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.flag-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--active {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PolicyGateIndicatorComponent {
|
||||
readonly gate = input.required<PolicyGateResult>();
|
||||
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
|
||||
|
||||
readonly expanded = signal(false);
|
||||
|
||||
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
|
||||
getStatusLabel(): string {
|
||||
const labels: Record<PolicyGateStatus, string> = {
|
||||
passed: 'Passed',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
warning: 'Warning',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return labels[this.gate().status] ?? 'Unknown';
|
||||
}
|
||||
|
||||
getStatusIconClass(): string {
|
||||
return `status-icon--${this.gate().status}`;
|
||||
}
|
||||
|
||||
formatDate(isoString: string): string {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PolicyGateResult,
|
||||
PolicyGateStatus,
|
||||
DeterminismFeatureFlags,
|
||||
} from '../../core/api/release.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-gate-indicator',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div
|
||||
class="gate-indicator"
|
||||
[class.gate-indicator--expanded]="expanded()"
|
||||
[class]="'gate-indicator--' + gate().status"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="gate-header"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
[attr.aria-controls]="'gate-details-' + gate().gateId"
|
||||
>
|
||||
<div class="gate-status">
|
||||
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
|
||||
@switch (gate().status) {
|
||||
@case ('passed') { <span>✓</span> }
|
||||
@case ('failed') { <span>✗</span> }
|
||||
@case ('warning') { <span>!</span> }
|
||||
@case ('pending') { <span>⌛</span> }
|
||||
@case ('skipped') { <span>-</span> }
|
||||
}
|
||||
</span>
|
||||
<span class="status-text">{{ getStatusLabel() }}</span>
|
||||
</div>
|
||||
<div class="gate-info">
|
||||
<span class="gate-name">{{ gate().name }}</span>
|
||||
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
|
||||
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
|
||||
}
|
||||
@if (gate().blockingPublish) {
|
||||
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
|
||||
}
|
||||
</div>
|
||||
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
|
||||
@if (expanded()) {
|
||||
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
|
||||
<p class="gate-message">{{ gate().message }}</p>
|
||||
<div class="gate-meta">
|
||||
<span class="meta-item">
|
||||
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
|
||||
</span>
|
||||
@if (gate().evidence?.url) {
|
||||
<a
|
||||
[href]="gate().evidence?.url"
|
||||
class="evidence-link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
View Evidence
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Determinism-specific info when feature flag shows it -->
|
||||
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
|
||||
<div class="feature-flag-info">
|
||||
@if (featureFlags()?.blockOnFailure) {
|
||||
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
|
||||
} @else if (featureFlags()?.warnOnly) {
|
||||
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.gate-indicator {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&--passed {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
border-left: 3px solid #f97316;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
border-left: 3px solid #eab308;
|
||||
}
|
||||
|
||||
&--skipped {
|
||||
border-left: 3px solid #64748b;
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
border-color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.gate-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.gate-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-icon {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.gate-indicator--failed .status-icon {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.gate-indicator--warning .status-icon {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.gate-indicator--pending .status-icon {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.gate-indicator--skipped .status-icon {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-text { color: #22c55e; }
|
||||
.gate-indicator--failed .status-text { color: #ef4444; }
|
||||
.gate-indicator--warning .status-text { color: #f97316; }
|
||||
.gate-indicator--pending .status-text { color: #eab308; }
|
||||
.gate-indicator--skipped .status-text { color: #64748b; }
|
||||
|
||||
.gate-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.gate-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gate-type-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--determinism {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
}
|
||||
|
||||
.blocking-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #64748b;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.gate-details {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-top: 1px solid #334155;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.gate-message {
|
||||
margin: 0.75rem 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gate-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
|
||||
strong {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-flag-info {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.flag-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--active {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PolicyGateIndicatorComponent {
|
||||
readonly gate = input.required<PolicyGateResult>();
|
||||
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
|
||||
|
||||
readonly expanded = signal(false);
|
||||
|
||||
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
|
||||
getStatusLabel(): string {
|
||||
const labels: Record<PolicyGateStatus, string> = {
|
||||
passed: 'Passed',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
warning: 'Warning',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return labels[this.gate().status] ?? 'Unknown';
|
||||
}
|
||||
|
||||
getStatusIconClass(): string {
|
||||
return `status-icon--${this.gate().status}`;
|
||||
}
|
||||
|
||||
formatDate(isoString: string): string {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,229 +1,229 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import {
|
||||
Release,
|
||||
ReleaseArtifact,
|
||||
PolicyGateResult,
|
||||
PolicyGateStatus,
|
||||
DeterminismFeatureFlags,
|
||||
} from '../../core/api/release.models';
|
||||
import { RELEASE_API, MockReleaseApi } from '../../core/api/release.client';
|
||||
import { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
||||
import { RemediationHintsComponent } from './remediation-hints.component';
|
||||
|
||||
type ViewMode = 'list' | 'detail';
|
||||
|
||||
@Component({
|
||||
selector: 'app-release-flow',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule, PolicyGateIndicatorComponent, RemediationHintsComponent],
|
||||
providers: [{ provide: RELEASE_API, useClass: MockReleaseApi }],
|
||||
templateUrl: './release-flow.component.html',
|
||||
styleUrls: ['./release-flow.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ReleaseFlowComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly releaseApi = inject(RELEASE_API);
|
||||
|
||||
// State
|
||||
readonly releases = signal<readonly Release[]>([]);
|
||||
readonly selectedRelease = signal<Release | null>(null);
|
||||
readonly selectedArtifact = signal<ReleaseArtifact | null>(null);
|
||||
readonly featureFlags = signal<DeterminismFeatureFlags | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly publishing = signal(false);
|
||||
readonly viewMode = signal<ViewMode>('list');
|
||||
readonly bypassReason = signal('');
|
||||
readonly showBypassModal = signal(false);
|
||||
|
||||
// Computed values
|
||||
readonly canPublishSelected = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return false;
|
||||
return release.artifacts.every((a) => a.policyEvaluation?.canPublish ?? false);
|
||||
});
|
||||
|
||||
readonly blockingGatesCount = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return 0;
|
||||
return release.artifacts.reduce((count, artifact) => {
|
||||
return count + (artifact.policyEvaluation?.blockingGates.length ?? 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly determinismBlockingCount = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return 0;
|
||||
return release.artifacts.reduce((count, artifact) => {
|
||||
const gates = artifact.policyEvaluation?.gates ?? [];
|
||||
const deterministicBlocking = gates.filter(
|
||||
(g) => g.gateType === 'determinism' && g.status === 'failed' && g.blockingPublish
|
||||
);
|
||||
return count + deterministicBlocking.length;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly isDeterminismEnabled = computed(() => {
|
||||
const flags = this.featureFlags();
|
||||
return flags?.enabled ?? false;
|
||||
});
|
||||
|
||||
readonly canBypass = computed(() => {
|
||||
const flags = this.featureFlags();
|
||||
return flags?.bypassRoles && flags.bypassRoles.length > 0;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private loadData(): void {
|
||||
this.loading.set(true);
|
||||
|
||||
// Load feature flags
|
||||
this.releaseApi.getFeatureFlags().subscribe({
|
||||
next: (flags) => this.featureFlags.set(flags),
|
||||
error: (err) => console.error('Failed to load feature flags:', err),
|
||||
});
|
||||
|
||||
// Load releases
|
||||
this.releaseApi.listReleases().subscribe({
|
||||
next: (releases) => {
|
||||
this.releases.set(releases);
|
||||
this.loading.set(false);
|
||||
|
||||
// Check if we should auto-select from route
|
||||
const releaseId = this.route.snapshot.paramMap.get('releaseId');
|
||||
if (releaseId) {
|
||||
const release = releases.find((r) => r.releaseId === releaseId);
|
||||
if (release) {
|
||||
this.selectRelease(release);
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load releases:', err);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
selectRelease(release: Release): void {
|
||||
this.selectedRelease.set(release);
|
||||
this.selectedArtifact.set(release.artifacts[0] ?? null);
|
||||
this.viewMode.set('detail');
|
||||
}
|
||||
|
||||
selectArtifact(artifact: ReleaseArtifact): void {
|
||||
this.selectedArtifact.set(artifact);
|
||||
}
|
||||
|
||||
backToList(): void {
|
||||
this.selectedRelease.set(null);
|
||||
this.selectedArtifact.set(null);
|
||||
this.viewMode.set('list');
|
||||
}
|
||||
|
||||
publishRelease(): void {
|
||||
const release = this.selectedRelease();
|
||||
if (!release || !this.canPublishSelected()) return;
|
||||
|
||||
this.publishing.set(true);
|
||||
this.releaseApi.publishRelease(release.releaseId).subscribe({
|
||||
next: (updated) => {
|
||||
// Update the release in the list
|
||||
this.releases.update((list) =>
|
||||
list.map((r) => (r.releaseId === updated.releaseId ? updated : r))
|
||||
);
|
||||
this.selectedRelease.set(updated);
|
||||
this.publishing.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Publish failed:', err);
|
||||
this.publishing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openBypassModal(): void {
|
||||
this.bypassReason.set('');
|
||||
this.showBypassModal.set(true);
|
||||
}
|
||||
|
||||
closeBypassModal(): void {
|
||||
this.showBypassModal.set(false);
|
||||
}
|
||||
|
||||
submitBypassRequest(): void {
|
||||
const release = this.selectedRelease();
|
||||
const reason = this.bypassReason();
|
||||
if (!release || !reason.trim()) return;
|
||||
|
||||
this.releaseApi.requestBypass(release.releaseId, reason).subscribe({
|
||||
next: (result) => {
|
||||
console.log('Bypass requested:', result.requestId);
|
||||
this.closeBypassModal();
|
||||
// In real implementation, would show notification and refresh
|
||||
},
|
||||
error: (err) => console.error('Bypass request failed:', err),
|
||||
});
|
||||
}
|
||||
|
||||
updateBypassReason(event: Event): void {
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
this.bypassReason.set(target.value);
|
||||
}
|
||||
|
||||
getStatusClass(status: PolicyGateStatus): string {
|
||||
const statusClasses: Record<PolicyGateStatus, string> = {
|
||||
passed: 'status--passed',
|
||||
failed: 'status--failed',
|
||||
pending: 'status--pending',
|
||||
warning: 'status--warning',
|
||||
skipped: 'status--skipped',
|
||||
};
|
||||
return statusClasses[status] ?? 'status--pending';
|
||||
}
|
||||
|
||||
getReleaseStatusClass(release: Release): string {
|
||||
const statusClasses: Record<string, string> = {
|
||||
draft: 'release-status--draft',
|
||||
pending_approval: 'release-status--pending',
|
||||
approved: 'release-status--approved',
|
||||
publishing: 'release-status--publishing',
|
||||
published: 'release-status--published',
|
||||
blocked: 'release-status--blocked',
|
||||
cancelled: 'release-status--cancelled',
|
||||
};
|
||||
return statusClasses[release.status] ?? 'release-status--draft';
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
trackByReleaseId(_index: number, release: Release): string {
|
||||
return release.releaseId;
|
||||
}
|
||||
|
||||
trackByArtifactId(_index: number, artifact: ReleaseArtifact): string {
|
||||
return artifact.artifactId;
|
||||
}
|
||||
|
||||
trackByGateId(_index: number, gate: PolicyGateResult): string {
|
||||
return gate.gateId;
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import {
|
||||
Release,
|
||||
ReleaseArtifact,
|
||||
PolicyGateResult,
|
||||
PolicyGateStatus,
|
||||
DeterminismFeatureFlags,
|
||||
} from '../../core/api/release.models';
|
||||
import { RELEASE_API, MockReleaseApi } from '../../core/api/release.client';
|
||||
import { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
||||
import { RemediationHintsComponent } from './remediation-hints.component';
|
||||
|
||||
type ViewMode = 'list' | 'detail';
|
||||
|
||||
@Component({
|
||||
selector: 'app-release-flow',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule, PolicyGateIndicatorComponent, RemediationHintsComponent],
|
||||
providers: [{ provide: RELEASE_API, useClass: MockReleaseApi }],
|
||||
templateUrl: './release-flow.component.html',
|
||||
styleUrls: ['./release-flow.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ReleaseFlowComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly releaseApi = inject(RELEASE_API);
|
||||
|
||||
// State
|
||||
readonly releases = signal<readonly Release[]>([]);
|
||||
readonly selectedRelease = signal<Release | null>(null);
|
||||
readonly selectedArtifact = signal<ReleaseArtifact | null>(null);
|
||||
readonly featureFlags = signal<DeterminismFeatureFlags | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly publishing = signal(false);
|
||||
readonly viewMode = signal<ViewMode>('list');
|
||||
readonly bypassReason = signal('');
|
||||
readonly showBypassModal = signal(false);
|
||||
|
||||
// Computed values
|
||||
readonly canPublishSelected = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return false;
|
||||
return release.artifacts.every((a) => a.policyEvaluation?.canPublish ?? false);
|
||||
});
|
||||
|
||||
readonly blockingGatesCount = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return 0;
|
||||
return release.artifacts.reduce((count, artifact) => {
|
||||
return count + (artifact.policyEvaluation?.blockingGates.length ?? 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly determinismBlockingCount = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return 0;
|
||||
return release.artifacts.reduce((count, artifact) => {
|
||||
const gates = artifact.policyEvaluation?.gates ?? [];
|
||||
const deterministicBlocking = gates.filter(
|
||||
(g) => g.gateType === 'determinism' && g.status === 'failed' && g.blockingPublish
|
||||
);
|
||||
return count + deterministicBlocking.length;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly isDeterminismEnabled = computed(() => {
|
||||
const flags = this.featureFlags();
|
||||
return flags?.enabled ?? false;
|
||||
});
|
||||
|
||||
readonly canBypass = computed(() => {
|
||||
const flags = this.featureFlags();
|
||||
return flags?.bypassRoles && flags.bypassRoles.length > 0;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private loadData(): void {
|
||||
this.loading.set(true);
|
||||
|
||||
// Load feature flags
|
||||
this.releaseApi.getFeatureFlags().subscribe({
|
||||
next: (flags) => this.featureFlags.set(flags),
|
||||
error: (err) => console.error('Failed to load feature flags:', err),
|
||||
});
|
||||
|
||||
// Load releases
|
||||
this.releaseApi.listReleases().subscribe({
|
||||
next: (releases) => {
|
||||
this.releases.set(releases);
|
||||
this.loading.set(false);
|
||||
|
||||
// Check if we should auto-select from route
|
||||
const releaseId = this.route.snapshot.paramMap.get('releaseId');
|
||||
if (releaseId) {
|
||||
const release = releases.find((r) => r.releaseId === releaseId);
|
||||
if (release) {
|
||||
this.selectRelease(release);
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load releases:', err);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
selectRelease(release: Release): void {
|
||||
this.selectedRelease.set(release);
|
||||
this.selectedArtifact.set(release.artifacts[0] ?? null);
|
||||
this.viewMode.set('detail');
|
||||
}
|
||||
|
||||
selectArtifact(artifact: ReleaseArtifact): void {
|
||||
this.selectedArtifact.set(artifact);
|
||||
}
|
||||
|
||||
backToList(): void {
|
||||
this.selectedRelease.set(null);
|
||||
this.selectedArtifact.set(null);
|
||||
this.viewMode.set('list');
|
||||
}
|
||||
|
||||
publishRelease(): void {
|
||||
const release = this.selectedRelease();
|
||||
if (!release || !this.canPublishSelected()) return;
|
||||
|
||||
this.publishing.set(true);
|
||||
this.releaseApi.publishRelease(release.releaseId).subscribe({
|
||||
next: (updated) => {
|
||||
// Update the release in the list
|
||||
this.releases.update((list) =>
|
||||
list.map((r) => (r.releaseId === updated.releaseId ? updated : r))
|
||||
);
|
||||
this.selectedRelease.set(updated);
|
||||
this.publishing.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Publish failed:', err);
|
||||
this.publishing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openBypassModal(): void {
|
||||
this.bypassReason.set('');
|
||||
this.showBypassModal.set(true);
|
||||
}
|
||||
|
||||
closeBypassModal(): void {
|
||||
this.showBypassModal.set(false);
|
||||
}
|
||||
|
||||
submitBypassRequest(): void {
|
||||
const release = this.selectedRelease();
|
||||
const reason = this.bypassReason();
|
||||
if (!release || !reason.trim()) return;
|
||||
|
||||
this.releaseApi.requestBypass(release.releaseId, reason).subscribe({
|
||||
next: (result) => {
|
||||
console.log('Bypass requested:', result.requestId);
|
||||
this.closeBypassModal();
|
||||
// In real implementation, would show notification and refresh
|
||||
},
|
||||
error: (err) => console.error('Bypass request failed:', err),
|
||||
});
|
||||
}
|
||||
|
||||
updateBypassReason(event: Event): void {
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
this.bypassReason.set(target.value);
|
||||
}
|
||||
|
||||
getStatusClass(status: PolicyGateStatus): string {
|
||||
const statusClasses: Record<PolicyGateStatus, string> = {
|
||||
passed: 'status--passed',
|
||||
failed: 'status--failed',
|
||||
pending: 'status--pending',
|
||||
warning: 'status--warning',
|
||||
skipped: 'status--skipped',
|
||||
};
|
||||
return statusClasses[status] ?? 'status--pending';
|
||||
}
|
||||
|
||||
getReleaseStatusClass(release: Release): string {
|
||||
const statusClasses: Record<string, string> = {
|
||||
draft: 'release-status--draft',
|
||||
pending_approval: 'release-status--pending',
|
||||
approved: 'release-status--approved',
|
||||
publishing: 'release-status--publishing',
|
||||
published: 'release-status--published',
|
||||
blocked: 'release-status--blocked',
|
||||
cancelled: 'release-status--cancelled',
|
||||
};
|
||||
return statusClasses[release.status] ?? 'release-status--draft';
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
trackByReleaseId(_index: number, release: Release): string {
|
||||
return release.releaseId;
|
||||
}
|
||||
|
||||
trackByArtifactId(_index: number, artifact: ReleaseArtifact): string {
|
||||
return artifact.artifactId;
|
||||
}
|
||||
|
||||
trackByGateId(_index: number, gate: PolicyGateResult): string {
|
||||
return gate.gateId;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,7 +104,7 @@ export interface EvidencePanelRequest {
|
||||
}
|
||||
|
||||
.evidence-btn.reachability:hover:not(:disabled) {
|
||||
border-color: var(--st-color-info, #6366f1);
|
||||
border-color: var(--st-color-info, #D4920A);
|
||||
background: var(--st-color-info-bg, #eef2ff);
|
||||
}
|
||||
|
||||
|
||||
@@ -372,7 +372,7 @@ import type { Exception, ExceptionLedgerEntry, ExceptionStatus } from '../../../
|
||||
.detail-value.severity.critical { color: var(--st-color-error, #ef4444); }
|
||||
.detail-value.severity.high { color: var(--st-color-warning, #f59e0b); }
|
||||
.detail-value.severity.medium { color: var(--st-color-warning-dark, #d97706); }
|
||||
.detail-value.severity.low { color: var(--st-color-info, #6366f1); }
|
||||
.detail-value.severity.low { color: var(--st-color-info, #D4920A); }
|
||||
|
||||
.justification {
|
||||
margin: 4px 0 0 0;
|
||||
@@ -408,7 +408,7 @@ import type { Exception, ExceptionLedgerEntry, ExceptionStatus } from '../../../
|
||||
background: var(--st-color-border, #d1d5db);
|
||||
}
|
||||
|
||||
.timeline-dot.created { background: var(--st-color-info, #6366f1); }
|
||||
.timeline-dot.created { background: var(--st-color-info, #D4920A); }
|
||||
.timeline-dot.approved { background: var(--st-color-success, #22c55e); }
|
||||
.timeline-dot.rejected { background: var(--st-color-error, #ef4444); }
|
||||
.timeline-dot.expired { background: var(--st-color-text-tertiary, #9ca3af); }
|
||||
|
||||
@@ -287,7 +287,7 @@ export interface ReachabilityPath {
|
||||
}
|
||||
|
||||
.node-row.entrypoint .connector-start {
|
||||
color: var(--st-color-info, #6366f1);
|
||||
color: var(--st-color-info, #D4920A);
|
||||
}
|
||||
|
||||
.show-all-btn {
|
||||
|
||||
@@ -266,7 +266,7 @@ export interface SbomDiffSummary {
|
||||
|
||||
.icon.added { background: var(--st-color-success, #22c55e); color: white; }
|
||||
.icon.removed { background: var(--st-color-error, #ef4444); color: white; }
|
||||
.icon.upgraded { background: var(--st-color-info, #6366f1); color: white; }
|
||||
.icon.upgraded { background: var(--st-color-info, #D4920A); color: white; }
|
||||
.icon.downgraded { background: var(--st-color-warning, #f59e0b); color: white; }
|
||||
|
||||
.package-info {
|
||||
|
||||
@@ -365,7 +365,7 @@ export interface RiskStateSnapshot {
|
||||
.metric-value.critical { color: var(--st-color-error, #ef4444); }
|
||||
.metric-value.high { color: var(--st-color-warning, #f59e0b); }
|
||||
.metric-value.medium { color: var(--st-color-warning-dark, #d97706); }
|
||||
.metric-value.low { color: var(--st-color-info, #6366f1); }
|
||||
.metric-value.low { color: var(--st-color-info, #D4920A); }
|
||||
|
||||
.metric-delta, .stat-delta {
|
||||
font-size: 11px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user