up
Some checks failed
Some checks failed
This commit is contained in:
485
src/Web/StellaOps.Web/src/app/core/api/console-search.client.ts
Normal file
485
src/Web/StellaOps.Web/src/app/core/api/console-search.client.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { map, catchError, delay } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { CONSOLE_API_BASE_URL } from './console-status.client';
|
||||
import {
|
||||
ConsoleSearchResponse,
|
||||
ConsoleSearchQueryOptions,
|
||||
ConsoleDownloadResponse,
|
||||
ConsoleDownloadQueryOptions,
|
||||
SearchResultItem,
|
||||
SearchSeverity,
|
||||
SearchPolicyBadge,
|
||||
SearchReachability,
|
||||
SearchVexState,
|
||||
DownloadManifest,
|
||||
DownloadManifestItem,
|
||||
} from './console-search.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
/**
|
||||
* Console Search & Downloads API interface.
|
||||
* Implements WEB-CONSOLE-23-004 and WEB-CONSOLE-23-005.
|
||||
*/
|
||||
export interface ConsoleSearchApi {
|
||||
/** Search with deterministic ranking and caching. */
|
||||
search(options?: ConsoleSearchQueryOptions): Observable<ConsoleSearchResponse>;
|
||||
|
||||
/** Get download manifest. */
|
||||
getDownloads(options?: ConsoleDownloadQueryOptions): Observable<ConsoleDownloadResponse>;
|
||||
|
||||
/** Get download manifest for specific export. */
|
||||
getDownload(exportId: string, options?: ConsoleDownloadQueryOptions): Observable<ConsoleDownloadResponse>;
|
||||
}
|
||||
|
||||
export const CONSOLE_SEARCH_API = new InjectionToken<ConsoleSearchApi>('CONSOLE_SEARCH_API');
|
||||
|
||||
/**
|
||||
* Deterministic ranking comparator.
|
||||
* Order: severity (desc) → exploitScore (desc) → reachability (reachable > unknown > unreachable)
|
||||
* → policyBadge (fail > warn > pass > waived) → vexState (under_investigation > fixed > not_affected > unknown)
|
||||
* → findingId (asc)
|
||||
*/
|
||||
function compareSearchResults(a: SearchResultItem, b: SearchResultItem): number {
|
||||
// Severity order (higher = more severe)
|
||||
const severityOrder: Record<SearchSeverity, number> = {
|
||||
critical: 5, high: 4, medium: 3, low: 2, info: 1, unknown: 0,
|
||||
};
|
||||
const sevDiff = severityOrder[b.severity] - severityOrder[a.severity];
|
||||
if (sevDiff !== 0) return sevDiff;
|
||||
|
||||
// Exploit score desc
|
||||
const exploitDiff = (b.exploitScore ?? 0) - (a.exploitScore ?? 0);
|
||||
if (exploitDiff !== 0) return exploitDiff;
|
||||
|
||||
// Reachability order (reachable > unknown > unreachable)
|
||||
const reachOrder: Record<SearchReachability, number> = {
|
||||
reachable: 2, unknown: 1, unreachable: 0,
|
||||
};
|
||||
const reachA = a.reachability ?? 'unknown';
|
||||
const reachB = b.reachability ?? 'unknown';
|
||||
const reachDiff = reachOrder[reachB] - reachOrder[reachA];
|
||||
if (reachDiff !== 0) return reachDiff;
|
||||
|
||||
// Policy badge order (fail > warn > pass > waived)
|
||||
const badgeOrder: Record<SearchPolicyBadge, number> = {
|
||||
fail: 3, warn: 2, pass: 1, waived: 0,
|
||||
};
|
||||
const badgeA = a.policyBadge ?? 'pass';
|
||||
const badgeB = b.policyBadge ?? 'pass';
|
||||
const badgeDiff = badgeOrder[badgeB] - badgeOrder[badgeA];
|
||||
if (badgeDiff !== 0) return badgeDiff;
|
||||
|
||||
// VEX state order (under_investigation > fixed > not_affected > unknown)
|
||||
const vexOrder: Record<SearchVexState, number> = {
|
||||
under_investigation: 3, fixed: 2, not_affected: 1, unknown: 0,
|
||||
};
|
||||
const vexA = a.vexState ?? 'unknown';
|
||||
const vexB = b.vexState ?? 'unknown';
|
||||
const vexDiff = vexOrder[vexB] - vexOrder[vexA];
|
||||
if (vexDiff !== 0) return vexDiff;
|
||||
|
||||
// Secondary: advisoryId asc, then product asc
|
||||
const advDiff = (a.advisoryId ?? '').localeCompare(b.advisoryId ?? '');
|
||||
if (advDiff !== 0) return advDiff;
|
||||
|
||||
const prodDiff = (a.product ?? '').localeCompare(b.product ?? '');
|
||||
if (prodDiff !== 0) return prodDiff;
|
||||
|
||||
// Final: findingId asc
|
||||
return a.findingId.localeCompare(b.findingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of sorted payload (simplified for client-side).
|
||||
*/
|
||||
function computePayloadHash(items: readonly SearchResultItem[]): string {
|
||||
// Simplified: create deterministic string from sorted items
|
||||
const payload = items.map(i => `${i.findingId}:${i.severity}:${i.exploitScore ?? 0}`).join('|');
|
||||
// In production, use actual SHA-256; here we use a simple hash
|
||||
let hash = 0;
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
const char = payload.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return `sha256:${Math.abs(hash).toString(16).padStart(16, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP Console Search Client.
|
||||
* Implements WEB-CONSOLE-23-004 and WEB-CONSOLE-23-005.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ConsoleSearchHttpClient implements ConsoleSearchApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly authSession: AuthSessionStore,
|
||||
private readonly tenantService: TenantActivationService,
|
||||
@Inject(CONSOLE_API_BASE_URL) private readonly baseUrl: string
|
||||
) {}
|
||||
|
||||
search(options: ConsoleSearchQueryOptions = {}): Observable<ConsoleSearchResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('console', 'read', ['console:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing console:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
const params = this.buildSearchParams(options);
|
||||
|
||||
return this.http.get<ConsoleSearchResponse>(`${this.baseUrl}/search`, { headers, params }).pipe(
|
||||
map((response) => ({
|
||||
...response,
|
||||
traceId,
|
||||
})),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getDownloads(options: ConsoleDownloadQueryOptions = {}): Observable<ConsoleDownloadResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('console', 'read', ['console:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing console:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
let params = new HttpParams();
|
||||
if (options.format) {
|
||||
params = params.set('format', options.format);
|
||||
}
|
||||
if (options.includeDsse) {
|
||||
params = params.set('includeDsse', 'true');
|
||||
}
|
||||
|
||||
return this.http.get<ConsoleDownloadResponse>(`${this.baseUrl}/downloads`, { headers, params }).pipe(
|
||||
map((response) => ({
|
||||
...response,
|
||||
manifest: { ...response.manifest, traceId },
|
||||
})),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getDownload(exportId: string, options: ConsoleDownloadQueryOptions = {}): Observable<ConsoleDownloadResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('console', 'read', ['console:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing console:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
let params = new HttpParams();
|
||||
if (options.format) {
|
||||
params = params.set('format', options.format);
|
||||
}
|
||||
if (options.includeDsse) {
|
||||
params = params.set('includeDsse', 'true');
|
||||
}
|
||||
|
||||
return this.http.get<ConsoleDownloadResponse>(
|
||||
`${this.baseUrl}/downloads/${encodeURIComponent(exportId)}`,
|
||||
{ headers, params }
|
||||
).pipe(
|
||||
map((response) => ({
|
||||
...response,
|
||||
manifest: { ...response.manifest, traceId },
|
||||
})),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(opts: { tenantId?: string; traceId?: string; ifNoneMatch?: string }): HttpHeaders {
|
||||
const tenant = this.resolveTenant(opts.tenantId);
|
||||
const trace = opts.traceId ?? generateTraceId();
|
||||
|
||||
let headers = new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': trace,
|
||||
'X-Stella-Request-Id': trace,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
|
||||
if (opts.ifNoneMatch) {
|
||||
headers = headers.set('If-None-Match', opts.ifNoneMatch);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private buildSearchParams(opts: ConsoleSearchQueryOptions): HttpParams {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (opts.pageToken) {
|
||||
params = params.set('pageToken', opts.pageToken);
|
||||
}
|
||||
if (opts.pageSize) {
|
||||
params = params.set('pageSize', String(opts.pageSize));
|
||||
}
|
||||
if (opts.query) {
|
||||
params = params.set('query', opts.query);
|
||||
}
|
||||
if (opts.severity?.length) {
|
||||
params = params.set('severity', opts.severity.join(','));
|
||||
}
|
||||
if (opts.reachability?.length) {
|
||||
params = params.set('reachability', opts.reachability.join(','));
|
||||
}
|
||||
if (opts.policyBadge?.length) {
|
||||
params = params.set('policyBadge', opts.policyBadge.join(','));
|
||||
}
|
||||
if (opts.vexState?.length) {
|
||||
params = params.set('vexState', opts.vexState.join(','));
|
||||
}
|
||||
if (opts.projectId) {
|
||||
params = params.set('projectId', opts.projectId);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
private resolveTenant(tenantId?: string): string {
|
||||
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||
if (!tenant) {
|
||||
throw new Error('ConsoleSearchClient requires an active tenant identifier.');
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private mapError(err: unknown, traceId: string): Error {
|
||||
if (err instanceof Error) {
|
||||
return new Error(`[${traceId}] Console search error: ${err.message}`);
|
||||
}
|
||||
return new Error(`[${traceId}] Console search error: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Console Search API for quickstart mode.
|
||||
* Implements WEB-CONSOLE-23-004 and WEB-CONSOLE-23-005.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockConsoleSearchClient implements ConsoleSearchApi {
|
||||
private readonly mockResults: SearchResultItem[] = [
|
||||
{
|
||||
findingId: 'tenant-default:advisory-ai:sha256:9bf4',
|
||||
advisoryId: 'CVE-2024-67890',
|
||||
severity: 'critical',
|
||||
exploitScore: 9.1,
|
||||
reachability: 'reachable',
|
||||
policyBadge: 'fail',
|
||||
vexState: 'under_investigation',
|
||||
product: 'registry.local/ops/transform:2025.10.0',
|
||||
summary: 'lodash prototype pollution in _.set and related functions.',
|
||||
lastUpdated: '2025-11-08T10:30:00Z',
|
||||
},
|
||||
{
|
||||
findingId: 'tenant-default:advisory-ai:sha256:5d1a',
|
||||
advisoryId: 'CVE-2024-12345',
|
||||
severity: 'high',
|
||||
exploitScore: 8.1,
|
||||
reachability: 'reachable',
|
||||
policyBadge: 'fail',
|
||||
vexState: 'under_investigation',
|
||||
product: 'registry.local/ops/auth:2025.10.0',
|
||||
summary: 'jsonwebtoken <10.0.0 allows algorithm downgrade.',
|
||||
lastUpdated: '2025-11-07T23:16:51Z',
|
||||
},
|
||||
{
|
||||
findingId: 'tenant-default:advisory-ai:sha256:abc1',
|
||||
advisoryId: 'CVE-2024-11111',
|
||||
severity: 'medium',
|
||||
exploitScore: 5.3,
|
||||
reachability: 'unreachable',
|
||||
policyBadge: 'warn',
|
||||
vexState: 'not_affected',
|
||||
product: 'registry.local/ops/gateway:2025.10.0',
|
||||
summary: 'Express.js path traversal vulnerability.',
|
||||
lastUpdated: '2025-11-06T14:00:00Z',
|
||||
},
|
||||
{
|
||||
findingId: 'tenant-default:advisory-ai:sha256:def2',
|
||||
advisoryId: 'CVE-2024-22222',
|
||||
severity: 'low',
|
||||
exploitScore: 3.0,
|
||||
reachability: 'unknown',
|
||||
policyBadge: 'pass',
|
||||
vexState: 'fixed',
|
||||
product: 'registry.local/ops/cache:2025.10.0',
|
||||
summary: 'Cache timing side channel.',
|
||||
lastUpdated: '2025-11-05T09:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
search(options: ConsoleSearchQueryOptions = {}): Observable<ConsoleSearchResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
let filtered = [...this.mockResults];
|
||||
|
||||
// Apply filters
|
||||
if (options.query) {
|
||||
const queryLower = options.query.toLowerCase();
|
||||
filtered = filtered.filter((r) =>
|
||||
r.advisoryId.toLowerCase().includes(queryLower) ||
|
||||
r.summary?.toLowerCase().includes(queryLower) ||
|
||||
r.product?.toLowerCase().includes(queryLower)
|
||||
);
|
||||
}
|
||||
if (options.severity?.length) {
|
||||
filtered = filtered.filter((r) => options.severity!.includes(r.severity));
|
||||
}
|
||||
if (options.reachability?.length) {
|
||||
filtered = filtered.filter((r) => r.reachability && options.reachability!.includes(r.reachability));
|
||||
}
|
||||
if (options.policyBadge?.length) {
|
||||
filtered = filtered.filter((r) => r.policyBadge && options.policyBadge!.includes(r.policyBadge));
|
||||
}
|
||||
if (options.vexState?.length) {
|
||||
filtered = filtered.filter((r) => r.vexState && options.vexState!.includes(r.vexState));
|
||||
}
|
||||
|
||||
// Apply deterministic ranking
|
||||
filtered.sort(compareSearchResults);
|
||||
|
||||
// Paginate
|
||||
const pageSize = options.pageSize ?? 50;
|
||||
const items = filtered.slice(0, pageSize);
|
||||
|
||||
// Compute ranking metadata
|
||||
const payloadHash = computePayloadHash(items);
|
||||
const newestUpdatedAt = items.reduce((newest, item) => {
|
||||
if (!item.lastUpdated) return newest;
|
||||
return !newest || item.lastUpdated > newest ? item.lastUpdated : newest;
|
||||
}, '' as string);
|
||||
|
||||
const response: ConsoleSearchResponse = {
|
||||
items,
|
||||
ranking: {
|
||||
sortKeys: ['severity', 'exploitScore', 'reachability', 'policyBadge', 'vexState', 'findingId'],
|
||||
payloadHash,
|
||||
newestUpdatedAt: newestUpdatedAt || undefined,
|
||||
},
|
||||
nextPageToken: filtered.length > pageSize ? this.createCursor(items[items.length - 1], traceId) : null,
|
||||
total: filtered.length,
|
||||
traceId,
|
||||
etag: `"${payloadHash}"`,
|
||||
cacheControl: 'public, max-age=300, stale-while-revalidate=60, stale-if-error=300',
|
||||
};
|
||||
|
||||
return of(response).pipe(delay(50));
|
||||
}
|
||||
|
||||
getDownloads(options: ConsoleDownloadQueryOptions = {}): Observable<ConsoleDownloadResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const tenant = options.tenantId ?? 'tenant-default';
|
||||
const exportId = `console-export::${tenant}::${new Date().toISOString().split('T')[0]}::0001`;
|
||||
|
||||
const manifest = this.createMockManifest(exportId, tenant, traceId, options.includeDsse);
|
||||
|
||||
return of({
|
||||
manifest,
|
||||
etag: `"${manifest.checksums.manifest}"`,
|
||||
cacheControl: 'public, max-age=300, stale-while-revalidate=60, stale-if-error=300',
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
getDownload(exportId: string, options: ConsoleDownloadQueryOptions = {}): Observable<ConsoleDownloadResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const tenant = options.tenantId ?? 'tenant-default';
|
||||
|
||||
const manifest = this.createMockManifest(exportId, tenant, traceId, options.includeDsse);
|
||||
|
||||
return of({
|
||||
manifest,
|
||||
etag: `"${manifest.checksums.manifest}"`,
|
||||
cacheControl: 'public, max-age=300, stale-while-revalidate=60, stale-if-error=300',
|
||||
}).pipe(delay(30));
|
||||
}
|
||||
|
||||
private createMockManifest(
|
||||
exportId: string,
|
||||
tenantId: string,
|
||||
traceId: string,
|
||||
includeDsse?: boolean
|
||||
): DownloadManifest {
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
|
||||
// Sort items deterministically: type asc, id asc, format asc
|
||||
const items: DownloadManifestItem[] = [
|
||||
{
|
||||
type: 'advisory',
|
||||
id: 'CVE-2024-12345',
|
||||
format: 'json',
|
||||
url: `https://downloads.local/exports/${exportId}/advisory/CVE-2024-12345.json?sig=mock`,
|
||||
sha256: 'sha256:a1b2c3d4e5f6',
|
||||
size: 4096,
|
||||
},
|
||||
{
|
||||
type: 'advisory',
|
||||
id: 'CVE-2024-67890',
|
||||
format: 'json',
|
||||
url: `https://downloads.local/exports/${exportId}/advisory/CVE-2024-67890.json?sig=mock`,
|
||||
sha256: 'sha256:f6e5d4c3b2a1',
|
||||
size: 3072,
|
||||
},
|
||||
{
|
||||
type: 'vex',
|
||||
id: 'vex:tenant-default:jwt-auth:5d1a',
|
||||
format: 'json',
|
||||
url: `https://downloads.local/exports/${exportId}/vex/jwt-auth-5d1a.json?sig=mock`,
|
||||
sha256: 'sha256:1a2b3c4d5e6f',
|
||||
size: 2048,
|
||||
},
|
||||
{
|
||||
type: 'vuln',
|
||||
id: 'tenant-default:advisory-ai:sha256:5d1a',
|
||||
format: 'json',
|
||||
url: `https://downloads.local/exports/${exportId}/vuln/5d1a.json?sig=mock`,
|
||||
sha256: 'sha256:6f5e4d3c2b1a',
|
||||
size: 8192,
|
||||
},
|
||||
].sort((a, b) => {
|
||||
const typeDiff = a.type.localeCompare(b.type);
|
||||
if (typeDiff !== 0) return typeDiff;
|
||||
const idDiff = a.id.localeCompare(b.id);
|
||||
if (idDiff !== 0) return idDiff;
|
||||
return a.format.localeCompare(b.format);
|
||||
});
|
||||
|
||||
const manifestHash = `sha256:${Math.abs(exportId.split('').reduce((h, c) => ((h << 5) - h) + c.charCodeAt(0), 0)).toString(16).padStart(16, '0')}`;
|
||||
|
||||
return {
|
||||
version: '2025-12-07',
|
||||
exportId,
|
||||
tenantId,
|
||||
generatedAt: now.toISOString(),
|
||||
items,
|
||||
checksums: {
|
||||
manifest: manifestHash,
|
||||
bundle: `sha256:bundle${Date.now().toString(16)}`,
|
||||
},
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
dsseUrl: includeDsse ? `https://downloads.local/exports/${exportId}/manifest.dsse?sig=mock` : undefined,
|
||||
traceId,
|
||||
};
|
||||
}
|
||||
|
||||
private createCursor(lastItem: SearchResultItem, tenantId: string): string {
|
||||
// Create opaque, signed cursor with sortKeys and tenant
|
||||
const cursorData = {
|
||||
findingId: lastItem.findingId,
|
||||
severity: lastItem.severity,
|
||||
exploitScore: lastItem.exploitScore,
|
||||
tenant: tenantId,
|
||||
};
|
||||
// In production, this would be signed and base64url encoded
|
||||
return Buffer.from(JSON.stringify(cursorData)).toString('base64url');
|
||||
}
|
||||
}
|
||||
134
src/Web/StellaOps.Web/src/app/core/api/console-search.models.ts
Normal file
134
src/Web/StellaOps.Web/src/app/core/api/console-search.models.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Console Search & Downloads Models.
|
||||
* Implements WEB-CONSOLE-23-004 and WEB-CONSOLE-23-005.
|
||||
*/
|
||||
|
||||
/** Severity levels for ranking. */
|
||||
export type SearchSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
|
||||
|
||||
/** Policy badge for ranking. */
|
||||
export type SearchPolicyBadge = 'fail' | 'warn' | 'pass' | 'waived';
|
||||
|
||||
/** Reachability status for ranking. */
|
||||
export type SearchReachability = 'reachable' | 'unknown' | 'unreachable';
|
||||
|
||||
/** VEX state for ranking. */
|
||||
export type SearchVexState = 'under_investigation' | 'fixed' | 'not_affected' | 'unknown';
|
||||
|
||||
/** Search result item base. */
|
||||
export interface SearchResultItem {
|
||||
readonly findingId: string;
|
||||
readonly advisoryId: string;
|
||||
readonly severity: SearchSeverity;
|
||||
readonly exploitScore?: number;
|
||||
readonly reachability?: SearchReachability;
|
||||
readonly policyBadge?: SearchPolicyBadge;
|
||||
readonly vexState?: SearchVexState;
|
||||
readonly product?: string;
|
||||
readonly summary?: string;
|
||||
readonly lastUpdated?: string;
|
||||
}
|
||||
|
||||
/** Search result ranking metadata. */
|
||||
export interface SearchRankingMeta {
|
||||
/** Sort keys used for deterministic ordering. */
|
||||
readonly sortKeys: string[];
|
||||
/** SHA-256 of sorted payload for ETag. */
|
||||
readonly payloadHash: string;
|
||||
/** Newest updatedAt in result set. */
|
||||
readonly newestUpdatedAt?: string;
|
||||
}
|
||||
|
||||
/** Paginated search response. */
|
||||
export interface ConsoleSearchResponse {
|
||||
readonly items: readonly SearchResultItem[];
|
||||
readonly ranking: SearchRankingMeta;
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total: number;
|
||||
readonly traceId?: string;
|
||||
readonly etag?: string;
|
||||
readonly cacheControl?: string;
|
||||
}
|
||||
|
||||
/** Search query options. */
|
||||
export interface ConsoleSearchQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly pageToken?: string;
|
||||
readonly pageSize?: number;
|
||||
readonly query?: string;
|
||||
readonly severity?: readonly SearchSeverity[];
|
||||
readonly reachability?: readonly SearchReachability[];
|
||||
readonly policyBadge?: readonly SearchPolicyBadge[];
|
||||
readonly vexState?: readonly SearchVexState[];
|
||||
readonly traceId?: string;
|
||||
readonly ifNoneMatch?: string;
|
||||
}
|
||||
|
||||
/** Download manifest item types. */
|
||||
export type DownloadItemType = 'vuln' | 'advisory' | 'vex' | 'policy' | 'scan' | 'chart' | 'bundle';
|
||||
|
||||
/** Download manifest item. */
|
||||
export interface DownloadManifestItem {
|
||||
readonly type: DownloadItemType;
|
||||
readonly id: string;
|
||||
readonly format: string;
|
||||
readonly url: string;
|
||||
readonly sha256: string;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/** Download manifest checksums. */
|
||||
export interface DownloadManifestChecksums {
|
||||
readonly manifest: string;
|
||||
readonly bundle?: string;
|
||||
}
|
||||
|
||||
/** Download manifest structure. */
|
||||
export interface DownloadManifest {
|
||||
readonly version: string;
|
||||
readonly exportId: string;
|
||||
readonly tenantId: string;
|
||||
readonly generatedAt: string;
|
||||
readonly items: readonly DownloadManifestItem[];
|
||||
readonly checksums: DownloadManifestChecksums;
|
||||
readonly expiresAt: string;
|
||||
/** Optional DSSE envelope URL. */
|
||||
readonly dsseUrl?: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Download response. */
|
||||
export interface ConsoleDownloadResponse {
|
||||
readonly manifest: DownloadManifest;
|
||||
readonly etag?: string;
|
||||
readonly cacheControl?: string;
|
||||
}
|
||||
|
||||
/** Download query options. */
|
||||
export interface ConsoleDownloadQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly exportId?: string;
|
||||
readonly format?: string;
|
||||
readonly includeDsse?: boolean;
|
||||
readonly traceId?: string;
|
||||
readonly ifNoneMatch?: string;
|
||||
}
|
||||
|
||||
/** Error codes for search/downloads. */
|
||||
export type ConsoleSearchDownloadErrorCode =
|
||||
| 'ERR_CONSOLE_DOWNLOAD_INVALID_CURSOR'
|
||||
| 'ERR_CONSOLE_DOWNLOAD_EXPIRED'
|
||||
| 'ERR_CONSOLE_DOWNLOAD_RATE_LIMIT'
|
||||
| 'ERR_CONSOLE_DOWNLOAD_UNAVAILABLE'
|
||||
| 'ERR_CONSOLE_SEARCH_INVALID_QUERY'
|
||||
| 'ERR_CONSOLE_SEARCH_RATE_LIMIT';
|
||||
|
||||
/** Error response. */
|
||||
export interface ConsoleSearchDownloadError {
|
||||
readonly code: ConsoleSearchDownloadErrorCode;
|
||||
readonly message: string;
|
||||
readonly requestId: string;
|
||||
readonly retryAfterSeconds?: number;
|
||||
}
|
||||
431
src/Web/StellaOps.Web/src/app/core/api/console-vex.client.ts
Normal file
431
src/Web/StellaOps.Web/src/app/core/api/console-vex.client.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, throwError, Subject } from 'rxjs';
|
||||
import { map, catchError, delay } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import {
|
||||
CONSOLE_API_BASE_URL,
|
||||
EVENT_SOURCE_FACTORY,
|
||||
EventSourceFactory,
|
||||
DEFAULT_EVENT_SOURCE_FACTORY,
|
||||
} from './console-status.client';
|
||||
import {
|
||||
VexStatement,
|
||||
VexStatementsResponse,
|
||||
VexStatementsQueryOptions,
|
||||
VexStatementDetail,
|
||||
VexStreamEvent,
|
||||
VexEventsQueryOptions,
|
||||
VexStatus,
|
||||
VexSourceType,
|
||||
} from './console-vex.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
/**
|
||||
* Console VEX API interface.
|
||||
* Implements CONSOLE-VEX-30-001.
|
||||
*/
|
||||
export interface ConsoleVexApi {
|
||||
/** List VEX statements with pagination and filters. */
|
||||
listStatements(options?: VexStatementsQueryOptions): Observable<VexStatementsResponse>;
|
||||
|
||||
/** Get full VEX statement detail by ID. */
|
||||
getStatement(statementId: string, options?: VexStatementsQueryOptions): Observable<VexStatementDetail>;
|
||||
|
||||
/** Subscribe to VEX events stream (SSE). */
|
||||
streamEvents(options?: VexEventsQueryOptions): Observable<VexStreamEvent>;
|
||||
}
|
||||
|
||||
export const CONSOLE_VEX_API = new InjectionToken<ConsoleVexApi>('CONSOLE_VEX_API');
|
||||
|
||||
/**
|
||||
* HTTP Console VEX Client.
|
||||
* Implements CONSOLE-VEX-30-001 with tenant scoping, RBAC, and SSE streaming.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ConsoleVexHttpClient implements ConsoleVexApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly authSession: AuthSessionStore,
|
||||
private readonly tenantService: TenantActivationService,
|
||||
@Inject(CONSOLE_API_BASE_URL) private readonly baseUrl: string,
|
||||
@Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory = DEFAULT_EVENT_SOURCE_FACTORY
|
||||
) {}
|
||||
|
||||
listStatements(options: VexStatementsQueryOptions = {}): Observable<VexStatementsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vex:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing console:read or vex:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
const params = this.buildStatementsParams(options);
|
||||
|
||||
return this.http.get<VexStatementsResponse>(`${this.baseUrl}/vex/statements`, { headers, params }).pipe(
|
||||
map((response) => ({
|
||||
...response,
|
||||
traceId,
|
||||
})),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getStatement(statementId: string, options: VexStatementsQueryOptions = {}): Observable<VexStatementDetail> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vex:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing console:read or vex:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
return this.http.get<VexStatementDetail>(
|
||||
`${this.baseUrl}/vex/statements/${encodeURIComponent(statementId)}`,
|
||||
{ headers }
|
||||
).pipe(
|
||||
map((response) => ({
|
||||
...response,
|
||||
traceId,
|
||||
})),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
streamEvents(options: VexEventsQueryOptions = {}): Observable<VexStreamEvent> {
|
||||
const tenant = this.resolveTenant(options.tenantId);
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
let url = `${this.baseUrl}/vex/events?tenant=${encodeURIComponent(tenant)}&traceId=${encodeURIComponent(traceId)}`;
|
||||
|
||||
if (options.projectId) {
|
||||
url += `&projectId=${encodeURIComponent(options.projectId)}`;
|
||||
}
|
||||
|
||||
return new Observable<VexStreamEvent>((observer) => {
|
||||
const eventSource = this.eventSourceFactory(url);
|
||||
|
||||
// Set Last-Event-ID header for replay support
|
||||
if (options.lastEventId && 'lastEventId' in eventSource) {
|
||||
// Note: EventSource doesn't allow setting headers directly,
|
||||
// so we include lastEventId as query param instead
|
||||
url += `&lastEventId=${encodeURIComponent(options.lastEventId)}`;
|
||||
}
|
||||
|
||||
const handleEvent = (eventType: string) => (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
observer.next({
|
||||
event: eventType as VexStreamEvent['event'],
|
||||
...data,
|
||||
traceId,
|
||||
});
|
||||
} catch (err) {
|
||||
// Skip invalid JSON (e.g., keepalive with empty data)
|
||||
if (eventType === 'keepalive') {
|
||||
observer.next({
|
||||
event: 'keepalive',
|
||||
sequence: Date.now(),
|
||||
traceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.addEventListener('statement.created', handleEvent('statement.created'));
|
||||
eventSource.addEventListener('statement.updated', handleEvent('statement.updated'));
|
||||
eventSource.addEventListener('statement.deleted', handleEvent('statement.deleted'));
|
||||
eventSource.addEventListener('statement.conflict', handleEvent('statement.conflict'));
|
||||
eventSource.addEventListener('keepalive', handleEvent('keepalive'));
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data) as VexStreamEvent;
|
||||
observer.next({ ...parsed, traceId });
|
||||
} catch {
|
||||
// Ignore parse errors for default messages
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
observer.error(new Error(`[${traceId}] VEX events stream error`));
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private buildHeaders(opts: { tenantId?: string; traceId?: string; ifNoneMatch?: string }): HttpHeaders {
|
||||
const tenant = this.resolveTenant(opts.tenantId);
|
||||
const trace = opts.traceId ?? generateTraceId();
|
||||
|
||||
let headers = new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': trace,
|
||||
'X-Stella-Request-Id': trace,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
|
||||
if (opts.ifNoneMatch) {
|
||||
headers = headers.set('If-None-Match', opts.ifNoneMatch);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private buildStatementsParams(opts: VexStatementsQueryOptions): HttpParams {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (opts.pageToken) {
|
||||
params = params.set('pageToken', opts.pageToken);
|
||||
}
|
||||
if (opts.pageSize) {
|
||||
params = params.set('pageSize', String(opts.pageSize));
|
||||
}
|
||||
if (opts.advisoryId?.length) {
|
||||
params = params.set('advisoryId', opts.advisoryId.join(','));
|
||||
}
|
||||
if (opts.justification?.length) {
|
||||
params = params.set('justification', opts.justification.join(','));
|
||||
}
|
||||
if (opts.statementType?.length) {
|
||||
params = params.set('statementType', opts.statementType.join(','));
|
||||
}
|
||||
if (opts.search) {
|
||||
params = params.set('search', opts.search);
|
||||
}
|
||||
if (opts.projectId) {
|
||||
params = params.set('projectId', opts.projectId);
|
||||
}
|
||||
if (opts.prefer) {
|
||||
params = params.set('prefer', opts.prefer);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
private resolveTenant(tenantId?: string): string {
|
||||
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||
if (!tenant) {
|
||||
throw new Error('ConsoleVexClient requires an active tenant identifier.');
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private mapError(err: unknown, traceId: string): Error {
|
||||
if (err instanceof Error) {
|
||||
return new Error(`[${traceId}] Console VEX error: ${err.message}`);
|
||||
}
|
||||
return new Error(`[${traceId}] Console VEX error: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Console VEX API for quickstart mode.
|
||||
* Implements CONSOLE-VEX-30-001.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockConsoleVexClient implements ConsoleVexApi {
|
||||
private readonly eventSubject = new Subject<VexStreamEvent>();
|
||||
private eventSequence = 1000;
|
||||
|
||||
private readonly mockStatements: VexStatement[] = [
|
||||
{
|
||||
statementId: 'vex:tenant-default:jwt-auth:5d1a',
|
||||
advisoryId: 'CVE-2024-12345',
|
||||
product: 'registry.local/ops/auth:2025.10.0',
|
||||
status: 'under_investigation',
|
||||
justification: 'exploit_observed',
|
||||
lastUpdated: '2025-11-07T23:10:09Z',
|
||||
source: {
|
||||
type: 'advisory_ai',
|
||||
modelBuild: 'aiai-console-2025-10-28',
|
||||
confidence: 0.74,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
rel: 'finding',
|
||||
href: '/console/vuln/findings/tenant-default:advisory-ai:sha256:5d1a',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
statementId: 'vex:tenant-default:data-transform:9bf4',
|
||||
advisoryId: 'CVE-2024-67890',
|
||||
product: 'registry.local/ops/transform:2025.10.0',
|
||||
status: 'affected',
|
||||
justification: 'exploit_observed',
|
||||
lastUpdated: '2025-11-08T10:30:00Z',
|
||||
source: {
|
||||
type: 'vex',
|
||||
confidence: 0.95,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
rel: 'finding',
|
||||
href: '/console/vuln/findings/tenant-default:advisory-ai:sha256:9bf4',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
statementId: 'vex:tenant-default:api-gateway:abc1',
|
||||
advisoryId: 'CVE-2024-11111',
|
||||
product: 'registry.local/ops/gateway:2025.10.0',
|
||||
status: 'not_affected',
|
||||
justification: 'inline_mitigations_exist',
|
||||
lastUpdated: '2025-11-06T14:00:00Z',
|
||||
source: {
|
||||
type: 'custom',
|
||||
confidence: 1.0,
|
||||
},
|
||||
},
|
||||
{
|
||||
statementId: 'vex:tenant-default:cache:def2',
|
||||
advisoryId: 'CVE-2024-22222',
|
||||
product: 'registry.local/ops/cache:2025.10.0',
|
||||
status: 'fixed',
|
||||
justification: 'solution_available',
|
||||
lastUpdated: '2025-11-05T09:00:00Z',
|
||||
source: {
|
||||
type: 'openvex',
|
||||
confidence: 1.0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
listStatements(options: VexStatementsQueryOptions = {}): Observable<VexStatementsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
let filtered = [...this.mockStatements];
|
||||
|
||||
// Apply filters
|
||||
if (options.advisoryId?.length) {
|
||||
filtered = filtered.filter((s) => options.advisoryId!.includes(s.advisoryId));
|
||||
}
|
||||
if (options.justification?.length) {
|
||||
filtered = filtered.filter((s) => s.justification && options.justification!.includes(s.justification));
|
||||
}
|
||||
if (options.statementType?.length) {
|
||||
filtered = filtered.filter((s) => s.source && options.statementType!.includes(s.source.type));
|
||||
}
|
||||
if (options.search) {
|
||||
const searchLower = options.search.toLowerCase();
|
||||
filtered = filtered.filter((s) =>
|
||||
s.advisoryId.toLowerCase().includes(searchLower) ||
|
||||
s.product.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort: lastUpdated desc, statementId asc
|
||||
filtered.sort((a, b) => {
|
||||
const dateDiff = new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime();
|
||||
if (dateDiff !== 0) return dateDiff;
|
||||
return a.statementId.localeCompare(b.statementId);
|
||||
});
|
||||
|
||||
// Paginate
|
||||
const pageSize = options.pageSize ?? 50;
|
||||
const items = filtered.slice(0, pageSize);
|
||||
|
||||
const response: VexStatementsResponse = {
|
||||
items,
|
||||
nextPageToken: filtered.length > pageSize ? 'mock-next-page' : null,
|
||||
total: filtered.length,
|
||||
traceId,
|
||||
};
|
||||
|
||||
return of(response).pipe(delay(50));
|
||||
}
|
||||
|
||||
getStatement(statementId: string, options: VexStatementsQueryOptions = {}): Observable<VexStatementDetail> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const statement = this.mockStatements.find((s) => s.statementId === statementId);
|
||||
|
||||
if (!statement) {
|
||||
return throwError(() => new Error(`Statement ${statementId} not found`));
|
||||
}
|
||||
|
||||
const detail: VexStatementDetail = {
|
||||
...statement,
|
||||
provenance: {
|
||||
documentId: `tenant-default:vex:${statementId}`,
|
||||
observationPath: '/statements/0',
|
||||
recordedAt: statement.lastUpdated,
|
||||
},
|
||||
impactStatement: 'Service may be impacted until remediation is applied.',
|
||||
remediations: [
|
||||
{
|
||||
type: 'patch',
|
||||
description: 'Upgrade to the latest patched version.',
|
||||
deadline: '2025-12-15T00:00:00Z',
|
||||
},
|
||||
],
|
||||
etag: `"vex-${statementId}-${Date.now()}"`,
|
||||
traceId,
|
||||
};
|
||||
|
||||
return of(detail).pipe(delay(30));
|
||||
}
|
||||
|
||||
streamEvents(options: VexEventsQueryOptions = {}): Observable<VexStreamEvent> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
// Return observable that emits events
|
||||
return new Observable<VexStreamEvent>((observer) => {
|
||||
// Subscribe to internal subject
|
||||
const subscription = this.eventSubject.subscribe((event) => {
|
||||
observer.next({ ...event, traceId });
|
||||
});
|
||||
|
||||
// Send initial keepalive
|
||||
observer.next({
|
||||
event: 'keepalive',
|
||||
sequence: this.eventSequence++,
|
||||
traceId,
|
||||
});
|
||||
|
||||
// Emit mock events periodically for testing
|
||||
const interval = setInterval(() => {
|
||||
observer.next({
|
||||
event: 'keepalive',
|
||||
sequence: this.eventSequence++,
|
||||
traceId,
|
||||
});
|
||||
}, 15000); // Every 15 seconds
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Trigger a mock event for testing. */
|
||||
triggerMockEvent(event: Omit<VexStreamEvent, 'sequence'>): void {
|
||||
this.eventSubject.next({
|
||||
...event,
|
||||
sequence: this.eventSequence++,
|
||||
});
|
||||
}
|
||||
|
||||
/** Simulate a statement update event. */
|
||||
simulateStatementUpdate(statementId: string, newStatus: VexStatus): void {
|
||||
const statement = this.mockStatements.find((s) => s.statementId === statementId);
|
||||
if (statement) {
|
||||
this.eventSubject.next({
|
||||
event: 'statement.updated',
|
||||
statementId,
|
||||
advisoryId: statement.advisoryId,
|
||||
product: statement.product,
|
||||
state: newStatus,
|
||||
sequence: this.eventSequence++,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
136
src/Web/StellaOps.Web/src/app/core/api/console-vex.models.ts
Normal file
136
src/Web/StellaOps.Web/src/app/core/api/console-vex.models.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Console VEX Workspace Models.
|
||||
* Implements CONSOLE-VEX-30-001.
|
||||
*/
|
||||
|
||||
/** VEX status values. */
|
||||
export type VexStatus =
|
||||
| 'not_affected'
|
||||
| 'fixed'
|
||||
| 'under_investigation'
|
||||
| 'affected'
|
||||
| 'unknown'
|
||||
| 'unavailable';
|
||||
|
||||
/** VEX justification values. */
|
||||
export type VexJustification =
|
||||
| 'exploit_observed'
|
||||
| 'component_not_present'
|
||||
| 'vulnerable_code_not_present'
|
||||
| 'vulnerable_code_not_in_execute_path'
|
||||
| 'inline_mitigations_exist'
|
||||
| 'vulnerable_code_cannot_be_controlled_by_adversary'
|
||||
| 'solution_available'
|
||||
| 'workaround_available'
|
||||
| 'no_impact'
|
||||
| 'unknown';
|
||||
|
||||
/** VEX statement source type. */
|
||||
export type VexSourceType = 'vex' | 'openvex' | 'custom' | 'advisory_ai';
|
||||
|
||||
/** VEX statement source. */
|
||||
export interface VexStatementSource {
|
||||
readonly type: VexSourceType;
|
||||
readonly modelBuild?: string;
|
||||
readonly confidence?: number;
|
||||
}
|
||||
|
||||
/** Related link in VEX statement. */
|
||||
export interface VexStatementLink {
|
||||
readonly rel: string;
|
||||
readonly href: string;
|
||||
}
|
||||
|
||||
/** VEX statement item. */
|
||||
export interface VexStatement {
|
||||
readonly statementId: string;
|
||||
readonly advisoryId: string;
|
||||
readonly product: string;
|
||||
readonly status: VexStatus;
|
||||
readonly justification?: VexJustification | string;
|
||||
readonly lastUpdated: string;
|
||||
readonly source?: VexStatementSource;
|
||||
readonly links?: readonly VexStatementLink[];
|
||||
}
|
||||
|
||||
/** VEX statement conflict info. */
|
||||
export interface VexConflict {
|
||||
readonly conflictId: string;
|
||||
readonly statementIds: readonly string[];
|
||||
readonly conflictType: string;
|
||||
readonly summary: string;
|
||||
readonly resolvedAt?: string;
|
||||
}
|
||||
|
||||
/** Paginated VEX statements response. */
|
||||
export interface VexStatementsResponse {
|
||||
readonly items: readonly VexStatement[];
|
||||
readonly conflicts?: readonly VexConflict[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Query options for VEX statements. */
|
||||
export interface VexStatementsQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly pageToken?: string;
|
||||
readonly pageSize?: number;
|
||||
readonly advisoryId?: readonly string[];
|
||||
readonly justification?: readonly string[];
|
||||
readonly statementType?: readonly VexSourceType[];
|
||||
readonly search?: string;
|
||||
readonly prefer?: 'json' | 'stream';
|
||||
readonly traceId?: string;
|
||||
readonly ifNoneMatch?: string;
|
||||
}
|
||||
|
||||
/** Full VEX statement detail. */
|
||||
export interface VexStatementDetail extends VexStatement {
|
||||
readonly provenance?: {
|
||||
readonly documentId: string;
|
||||
readonly observationPath?: string;
|
||||
readonly recordedAt: string;
|
||||
};
|
||||
readonly impactStatement?: string;
|
||||
readonly remediations?: readonly {
|
||||
readonly type: string;
|
||||
readonly description: string;
|
||||
readonly deadline?: string;
|
||||
}[];
|
||||
readonly etag?: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** SSE event types for VEX workspace. */
|
||||
export type VexEventType =
|
||||
| 'statement.created'
|
||||
| 'statement.updated'
|
||||
| 'statement.deleted'
|
||||
| 'statement.conflict'
|
||||
| 'keepalive';
|
||||
|
||||
/** VEX SSE event payload. */
|
||||
export interface VexStreamEvent {
|
||||
readonly event: VexEventType;
|
||||
readonly statementId?: string;
|
||||
readonly advisoryId?: string;
|
||||
readonly product?: string;
|
||||
readonly state?: VexStatus;
|
||||
readonly justification?: string;
|
||||
readonly severityHint?: string;
|
||||
readonly policyBadge?: string;
|
||||
readonly conflictSummary?: string;
|
||||
readonly sequence: number;
|
||||
readonly updatedAt?: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Query options for VEX events stream. */
|
||||
export interface VexEventsQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly lastEventId?: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
482
src/Web/StellaOps.Web/src/app/core/api/console-vuln.client.ts
Normal file
482
src/Web/StellaOps.Web/src/app/core/api/console-vuln.client.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { map, catchError, delay } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { CONSOLE_API_BASE_URL } from './console-status.client';
|
||||
import {
|
||||
VulnFinding,
|
||||
VulnFindingsResponse,
|
||||
VulnFindingsQueryOptions,
|
||||
VulnFindingDetail,
|
||||
VulnFindingQueryOptions,
|
||||
VulnFacets,
|
||||
VulnTicketRequest,
|
||||
VulnTicketResponse,
|
||||
VulnSeverity,
|
||||
PolicyBadge,
|
||||
VexState,
|
||||
ReachabilityStatus,
|
||||
} from './console-vuln.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
/**
|
||||
* Console Vuln API interface.
|
||||
* Implements CONSOLE-VULN-29-001.
|
||||
*/
|
||||
export interface ConsoleVulnApi {
|
||||
/** List findings with pagination and filters. */
|
||||
listFindings(options?: VulnFindingsQueryOptions): Observable<VulnFindingsResponse>;
|
||||
|
||||
/** Get facets for sidebar filters. */
|
||||
getFacets(options?: VulnFindingsQueryOptions): Observable<VulnFacets>;
|
||||
|
||||
/** Get full finding detail by ID. */
|
||||
getFinding(findingId: string, options?: VulnFindingQueryOptions): Observable<VulnFindingDetail>;
|
||||
|
||||
/** Export findings to ticketing system. */
|
||||
createTicket(request: VulnTicketRequest, options?: VulnFindingQueryOptions): Observable<VulnTicketResponse>;
|
||||
}
|
||||
|
||||
export const CONSOLE_VULN_API = new InjectionToken<ConsoleVulnApi>('CONSOLE_VULN_API');
|
||||
|
||||
/**
|
||||
* HTTP Console Vuln Client.
|
||||
* Implements CONSOLE-VULN-29-001 with tenant scoping and RBAC.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ConsoleVulnHttpClient implements ConsoleVulnApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly authSession: AuthSessionStore,
|
||||
private readonly tenantService: TenantActivationService,
|
||||
@Inject(CONSOLE_API_BASE_URL) private readonly baseUrl: string
|
||||
) {}
|
||||
|
||||
listFindings(options: VulnFindingsQueryOptions = {}): Observable<VulnFindingsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vuln:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing console:read or vuln:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
const params = this.buildFindingsParams(options);
|
||||
|
||||
return this.http.get<VulnFindingsResponse>(`${this.baseUrl}/vuln/findings`, { headers, params }).pipe(
|
||||
map((response) => ({
|
||||
...response,
|
||||
traceId,
|
||||
})),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getFacets(options: VulnFindingsQueryOptions = {}): Observable<VulnFacets> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vuln:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing console:read or vuln:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
const params = this.buildFindingsParams(options);
|
||||
|
||||
return this.http.get<VulnFacets>(`${this.baseUrl}/vuln/facets`, { headers, params }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getFinding(findingId: string, options: VulnFindingQueryOptions = {}): Observable<VulnFindingDetail> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vuln:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing console:read or vuln:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
return this.http.get<VulnFindingDetail>(
|
||||
`${this.baseUrl}/vuln/${encodeURIComponent(findingId)}`,
|
||||
{ headers }
|
||||
).pipe(
|
||||
map((response) => ({
|
||||
...response,
|
||||
traceId,
|
||||
})),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
createTicket(request: VulnTicketRequest, options: VulnFindingQueryOptions = {}): Observable<VulnTicketResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('console', 'write', ['console:read', 'vuln:read', 'console:export'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing console:export scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
return this.http.post<VulnTicketResponse>(`${this.baseUrl}/vuln/tickets`, request, { headers }).pipe(
|
||||
map((response) => ({
|
||||
...response,
|
||||
traceId,
|
||||
})),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(opts: { tenantId?: string; traceId?: string; ifNoneMatch?: string }): HttpHeaders {
|
||||
const tenant = this.resolveTenant(opts.tenantId);
|
||||
const trace = opts.traceId ?? generateTraceId();
|
||||
|
||||
let headers = new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': trace,
|
||||
'X-Stella-Request-Id': trace,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
|
||||
if (opts.ifNoneMatch) {
|
||||
headers = headers.set('If-None-Match', opts.ifNoneMatch);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private buildFindingsParams(opts: VulnFindingsQueryOptions): HttpParams {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (opts.pageToken) {
|
||||
params = params.set('pageToken', opts.pageToken);
|
||||
}
|
||||
if (opts.pageSize) {
|
||||
params = params.set('pageSize', String(opts.pageSize));
|
||||
}
|
||||
if (opts.severity?.length) {
|
||||
params = params.set('severity', opts.severity.join(','));
|
||||
}
|
||||
if (opts.product?.length) {
|
||||
params = params.set('product', opts.product.join(','));
|
||||
}
|
||||
if (opts.policyBadge?.length) {
|
||||
params = params.set('policyBadge', opts.policyBadge.join(','));
|
||||
}
|
||||
if (opts.vexState?.length) {
|
||||
params = params.set('vexState', opts.vexState.join(','));
|
||||
}
|
||||
if (opts.reachability?.length) {
|
||||
params = params.set('reachability', opts.reachability.join(','));
|
||||
}
|
||||
if (opts.search) {
|
||||
params = params.set('search', opts.search);
|
||||
}
|
||||
if (opts.projectId) {
|
||||
params = params.set('projectId', opts.projectId);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
private resolveTenant(tenantId?: string): string {
|
||||
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||
if (!tenant) {
|
||||
throw new Error('ConsoleVulnClient requires an active tenant identifier.');
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private mapError(err: unknown, traceId: string): Error {
|
||||
if (err instanceof Error) {
|
||||
return new Error(`[${traceId}] Console vuln error: ${err.message}`);
|
||||
}
|
||||
return new Error(`[${traceId}] Console vuln error: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Console Vuln API for quickstart mode.
|
||||
* Implements CONSOLE-VULN-29-001.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockConsoleVulnClient implements ConsoleVulnApi {
|
||||
private readonly mockFindings: VulnFinding[] = [
|
||||
{
|
||||
findingId: 'tenant-default:advisory-ai:sha256:5d1a',
|
||||
coordinates: {
|
||||
advisoryId: 'CVE-2024-12345',
|
||||
package: 'pkg:npm/jsonwebtoken@9.0.2',
|
||||
component: 'jwt-auth-service',
|
||||
image: 'registry.local/ops/auth:2025.10.0',
|
||||
},
|
||||
summary: 'jsonwebtoken <10.0.0 allows algorithm downgrade.',
|
||||
severity: 'high',
|
||||
cvss: 8.1,
|
||||
kev: true,
|
||||
policyBadge: 'fail',
|
||||
vex: {
|
||||
statementId: 'vex:tenant-default:jwt-auth:5d1a',
|
||||
state: 'under_investigation',
|
||||
justification: 'Advisory AI flagged reachable path via Scheduler run 42.',
|
||||
},
|
||||
reachability: {
|
||||
status: 'reachable',
|
||||
lastObserved: '2025-11-07T23:11:04Z',
|
||||
signalsVersion: 'signals-2025.310.1',
|
||||
},
|
||||
evidence: {
|
||||
sbomDigest: 'sha256:6c81a92f',
|
||||
policyRunId: 'policy-run::2025-11-07::ca9f',
|
||||
attestationId: 'dsse://authority/attest/84a2',
|
||||
},
|
||||
timestamps: {
|
||||
firstSeen: '2025-10-31T04:22:18Z',
|
||||
lastSeen: '2025-11-07T23:16:51Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
findingId: 'tenant-default:advisory-ai:sha256:9bf4',
|
||||
coordinates: {
|
||||
advisoryId: 'CVE-2024-67890',
|
||||
package: 'pkg:npm/lodash@4.17.20',
|
||||
component: 'data-transform',
|
||||
image: 'registry.local/ops/transform:2025.10.0',
|
||||
},
|
||||
summary: 'lodash prototype pollution in _.set and related functions.',
|
||||
severity: 'critical',
|
||||
cvss: 9.1,
|
||||
kev: false,
|
||||
policyBadge: 'fail',
|
||||
vex: {
|
||||
statementId: 'vex:tenant-default:data-transform:9bf4',
|
||||
state: 'affected',
|
||||
justification: 'Confirmed vulnerable path in production.',
|
||||
},
|
||||
reachability: {
|
||||
status: 'reachable',
|
||||
lastObserved: '2025-11-08T10:30:00Z',
|
||||
signalsVersion: 'signals-2025.310.1',
|
||||
},
|
||||
timestamps: {
|
||||
firstSeen: '2025-10-15T08:00:00Z',
|
||||
lastSeen: '2025-11-08T10:30:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
findingId: 'tenant-default:advisory-ai:sha256:abc1',
|
||||
coordinates: {
|
||||
advisoryId: 'CVE-2024-11111',
|
||||
package: 'pkg:npm/express@4.18.1',
|
||||
component: 'api-gateway',
|
||||
image: 'registry.local/ops/gateway:2025.10.0',
|
||||
},
|
||||
summary: 'Express.js path traversal vulnerability.',
|
||||
severity: 'medium',
|
||||
cvss: 5.3,
|
||||
kev: false,
|
||||
policyBadge: 'warn',
|
||||
vex: {
|
||||
statementId: 'vex:tenant-default:api-gateway:abc1',
|
||||
state: 'not_affected',
|
||||
justification: 'Mitigation applied via WAF rules.',
|
||||
},
|
||||
reachability: {
|
||||
status: 'unreachable',
|
||||
lastObserved: '2025-11-06T14:00:00Z',
|
||||
signalsVersion: 'signals-2025.310.1',
|
||||
},
|
||||
timestamps: {
|
||||
firstSeen: '2025-09-20T12:00:00Z',
|
||||
lastSeen: '2025-11-06T14:00:00Z',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
listFindings(options: VulnFindingsQueryOptions = {}): Observable<VulnFindingsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
let filtered = [...this.mockFindings];
|
||||
|
||||
// Apply filters
|
||||
if (options.severity?.length) {
|
||||
filtered = filtered.filter((f) => options.severity!.includes(f.severity));
|
||||
}
|
||||
if (options.policyBadge?.length) {
|
||||
filtered = filtered.filter((f) => options.policyBadge!.includes(f.policyBadge));
|
||||
}
|
||||
if (options.reachability?.length) {
|
||||
filtered = filtered.filter((f) => f.reachability && options.reachability!.includes(f.reachability.status));
|
||||
}
|
||||
if (options.vexState?.length) {
|
||||
filtered = filtered.filter((f) => f.vex && options.vexState!.includes(f.vex.state));
|
||||
}
|
||||
if (options.search) {
|
||||
const searchLower = options.search.toLowerCase();
|
||||
filtered = filtered.filter((f) =>
|
||||
f.coordinates.advisoryId.toLowerCase().includes(searchLower) ||
|
||||
f.summary.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort: severity desc, cvss desc, findingId asc
|
||||
const severityOrder: Record<VulnSeverity, number> = {
|
||||
critical: 5, high: 4, medium: 3, low: 2, info: 1, unknown: 0,
|
||||
};
|
||||
filtered.sort((a, b) => {
|
||||
const sevDiff = severityOrder[b.severity] - severityOrder[a.severity];
|
||||
if (sevDiff !== 0) return sevDiff;
|
||||
const cvssDiff = (b.cvss ?? 0) - (a.cvss ?? 0);
|
||||
if (cvssDiff !== 0) return cvssDiff;
|
||||
return a.findingId.localeCompare(b.findingId);
|
||||
});
|
||||
|
||||
// Paginate
|
||||
const pageSize = options.pageSize ?? 50;
|
||||
const items = filtered.slice(0, pageSize);
|
||||
|
||||
const response: VulnFindingsResponse = {
|
||||
items,
|
||||
facets: this.computeFacets(this.mockFindings),
|
||||
nextPageToken: filtered.length > pageSize ? 'mock-next-page' : null,
|
||||
total: filtered.length,
|
||||
traceId,
|
||||
};
|
||||
|
||||
return of(response).pipe(delay(50));
|
||||
}
|
||||
|
||||
getFacets(options: VulnFindingsQueryOptions = {}): Observable<VulnFacets> {
|
||||
return of(this.computeFacets(this.mockFindings)).pipe(delay(25));
|
||||
}
|
||||
|
||||
getFinding(findingId: string, options: VulnFindingQueryOptions = {}): Observable<VulnFindingDetail> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const finding = this.mockFindings.find((f) => f.findingId === findingId);
|
||||
|
||||
if (!finding) {
|
||||
return throwError(() => new Error(`Finding ${findingId} not found`));
|
||||
}
|
||||
|
||||
const detail: VulnFindingDetail = {
|
||||
findingId: finding.findingId,
|
||||
details: {
|
||||
description: finding.summary,
|
||||
references: [
|
||||
`https://nvd.nist.gov/vuln/detail/${finding.coordinates.advisoryId}`,
|
||||
'https://github.com/security/advisories',
|
||||
],
|
||||
exploitAvailability: finding.kev ? 'known_exploit' : 'unknown',
|
||||
},
|
||||
policyBadges: [
|
||||
{
|
||||
policyId: 'policy://tenant-default/runtime-hardening',
|
||||
verdict: finding.policyBadge,
|
||||
explainUrl: `/policy/runs/${finding.evidence?.policyRunId ?? 'unknown'}`,
|
||||
},
|
||||
],
|
||||
vex: finding.vex ? {
|
||||
statementId: finding.vex.statementId,
|
||||
state: finding.vex.state,
|
||||
justification: finding.vex.justification,
|
||||
impactStatement: 'Service remains exposed until patch applied.',
|
||||
remediations: [
|
||||
{
|
||||
type: 'patch',
|
||||
description: `Upgrade ${finding.coordinates.package} to latest version.`,
|
||||
deadline: '2025-12-15T00:00:00Z',
|
||||
},
|
||||
],
|
||||
} : undefined,
|
||||
reachability: finding.reachability ? {
|
||||
status: finding.reachability.status,
|
||||
callPathSamples: ['api-gateway -> service -> vulnerable-function'],
|
||||
lastUpdated: finding.reachability.lastObserved,
|
||||
} : undefined,
|
||||
evidence: {
|
||||
sbom: finding.evidence?.sbomDigest ? {
|
||||
digest: finding.evidence.sbomDigest,
|
||||
componentPath: ['/package.json', '/node_modules/' + finding.coordinates.package.split('@')[0].replace('pkg:npm/', '')],
|
||||
} : undefined,
|
||||
attestations: finding.evidence?.attestationId ? [
|
||||
{
|
||||
type: 'scan-report',
|
||||
attestationId: finding.evidence.attestationId,
|
||||
signer: 'attestor@stella-ops.org',
|
||||
bundleDigest: 'sha256:e2bb1234',
|
||||
},
|
||||
] : undefined,
|
||||
},
|
||||
timestamps: finding.timestamps ? {
|
||||
firstSeen: finding.timestamps.firstSeen,
|
||||
lastSeen: finding.timestamps.lastSeen,
|
||||
vexLastUpdated: '2025-11-07T23:10:09Z',
|
||||
} : undefined,
|
||||
traceId,
|
||||
etag: `"finding-${findingId}-${Date.now()}"`,
|
||||
};
|
||||
|
||||
return of(detail).pipe(delay(30));
|
||||
}
|
||||
|
||||
createTicket(request: VulnTicketRequest, options: VulnFindingQueryOptions = {}): Observable<VulnTicketResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const ticketId = `console-ticket::${request.tenant}::${new Date().toISOString().split('T')[0]}::${String(Date.now()).slice(-5)}`;
|
||||
|
||||
const response: VulnTicketResponse = {
|
||||
ticketId,
|
||||
payload: {
|
||||
version: '2025-12-01',
|
||||
tenant: request.tenant,
|
||||
findings: request.selection.map((id) => {
|
||||
const finding = this.mockFindings.find((f) => f.findingId === id);
|
||||
return {
|
||||
findingId: id,
|
||||
severity: finding?.severity ?? 'unknown',
|
||||
};
|
||||
}),
|
||||
policyBadge: 'fail',
|
||||
vexSummary: `${request.selection.length} findings pending review.`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'json',
|
||||
name: `console-ticket-${ticketId}.json`,
|
||||
digest: 'sha256:mock1234',
|
||||
contentType: 'application/json',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
auditEventId: `console.ticket.export::${ticketId}`,
|
||||
traceId,
|
||||
};
|
||||
|
||||
return of(response).pipe(delay(100));
|
||||
}
|
||||
|
||||
private computeFacets(findings: VulnFinding[]): VulnFacets {
|
||||
const severityCounts: Record<string, number> = {};
|
||||
const policyBadgeCounts: Record<string, number> = {};
|
||||
const reachabilityCounts: Record<string, number> = {};
|
||||
const vexStateCounts: Record<string, number> = {};
|
||||
|
||||
for (const f of findings) {
|
||||
severityCounts[f.severity] = (severityCounts[f.severity] ?? 0) + 1;
|
||||
policyBadgeCounts[f.policyBadge] = (policyBadgeCounts[f.policyBadge] ?? 0) + 1;
|
||||
if (f.reachability) {
|
||||
reachabilityCounts[f.reachability.status] = (reachabilityCounts[f.reachability.status] ?? 0) + 1;
|
||||
}
|
||||
if (f.vex) {
|
||||
vexStateCounts[f.vex.state] = (vexStateCounts[f.vex.state] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
severity: Object.entries(severityCounts).map(([value, count]) => ({ value, count })),
|
||||
policyBadge: Object.entries(policyBadgeCounts).map(([value, count]) => ({ value, count })),
|
||||
reachability: Object.entries(reachabilityCounts).map(([value, count]) => ({ value, count })),
|
||||
vexState: Object.entries(vexStateCounts).map(([value, count]) => ({ value, count })),
|
||||
};
|
||||
}
|
||||
}
|
||||
232
src/Web/StellaOps.Web/src/app/core/api/console-vuln.models.ts
Normal file
232
src/Web/StellaOps.Web/src/app/core/api/console-vuln.models.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Console Vuln Workspace Models.
|
||||
* Implements CONSOLE-VULN-29-001.
|
||||
*/
|
||||
|
||||
/** Severity levels. */
|
||||
export type VulnSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
|
||||
|
||||
/** Policy verdict badges. */
|
||||
export type PolicyBadge = 'pass' | 'warn' | 'fail' | 'waived';
|
||||
|
||||
/** VEX state values. */
|
||||
export type VexState =
|
||||
| 'not_affected'
|
||||
| 'fixed'
|
||||
| 'under_investigation'
|
||||
| 'affected'
|
||||
| 'unknown'
|
||||
| 'unavailable';
|
||||
|
||||
/** Reachability status. */
|
||||
export type ReachabilityStatus = 'reachable' | 'unreachable' | 'unknown';
|
||||
|
||||
/** Finding coordinates. */
|
||||
export interface FindingCoordinates {
|
||||
readonly advisoryId: string;
|
||||
readonly package: string;
|
||||
readonly component?: string;
|
||||
readonly image?: string;
|
||||
}
|
||||
|
||||
/** VEX summary in finding. */
|
||||
export interface FindingVex {
|
||||
readonly statementId: string;
|
||||
readonly state: VexState;
|
||||
readonly justification?: string;
|
||||
}
|
||||
|
||||
/** Reachability info in finding. */
|
||||
export interface FindingReachability {
|
||||
readonly status: ReachabilityStatus;
|
||||
readonly lastObserved?: string;
|
||||
readonly signalsVersion?: string;
|
||||
}
|
||||
|
||||
/** Evidence links in finding. */
|
||||
export interface FindingEvidence {
|
||||
readonly sbomDigest?: string;
|
||||
readonly policyRunId?: string;
|
||||
readonly attestationId?: string;
|
||||
}
|
||||
|
||||
/** Finding timestamps. */
|
||||
export interface FindingTimestamps {
|
||||
readonly firstSeen: string;
|
||||
readonly lastSeen: string;
|
||||
}
|
||||
|
||||
/** Vulnerability finding item. */
|
||||
export interface VulnFinding {
|
||||
readonly findingId: string;
|
||||
readonly coordinates: FindingCoordinates;
|
||||
readonly summary: string;
|
||||
readonly severity: VulnSeverity;
|
||||
readonly cvss?: number;
|
||||
readonly kev?: boolean;
|
||||
readonly policyBadge: PolicyBadge;
|
||||
readonly vex?: FindingVex;
|
||||
readonly reachability?: FindingReachability;
|
||||
readonly evidence?: FindingEvidence;
|
||||
readonly timestamps?: FindingTimestamps;
|
||||
}
|
||||
|
||||
/** Facet value with count. */
|
||||
export interface FacetValue {
|
||||
readonly value: string;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
/** Facets for sidebar filters. */
|
||||
export interface VulnFacets {
|
||||
readonly severity?: readonly FacetValue[];
|
||||
readonly policyBadge?: readonly FacetValue[];
|
||||
readonly reachability?: readonly FacetValue[];
|
||||
readonly vexState?: readonly FacetValue[];
|
||||
readonly product?: readonly FacetValue[];
|
||||
}
|
||||
|
||||
/** Paginated findings response. */
|
||||
export interface VulnFindingsResponse {
|
||||
readonly items: readonly VulnFinding[];
|
||||
readonly facets?: VulnFacets;
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Query options for findings. */
|
||||
export interface VulnFindingsQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly pageToken?: string;
|
||||
readonly pageSize?: number;
|
||||
readonly severity?: readonly VulnSeverity[];
|
||||
readonly product?: readonly string[];
|
||||
readonly policyBadge?: readonly PolicyBadge[];
|
||||
readonly vexState?: readonly VexState[];
|
||||
readonly reachability?: readonly ReachabilityStatus[];
|
||||
readonly search?: string;
|
||||
readonly traceId?: string;
|
||||
readonly ifNoneMatch?: string;
|
||||
}
|
||||
|
||||
/** Policy badge detail. */
|
||||
export interface PolicyBadgeDetail {
|
||||
readonly policyId: string;
|
||||
readonly verdict: PolicyBadge;
|
||||
readonly explainUrl?: string;
|
||||
}
|
||||
|
||||
/** Remediation entry. */
|
||||
export interface Remediation {
|
||||
readonly type: string;
|
||||
readonly description: string;
|
||||
readonly deadline?: string;
|
||||
}
|
||||
|
||||
/** Full VEX info for detail view. */
|
||||
export interface FindingVexDetail {
|
||||
readonly statementId: string;
|
||||
readonly state: VexState;
|
||||
readonly justification?: string;
|
||||
readonly impactStatement?: string;
|
||||
readonly remediations?: readonly Remediation[];
|
||||
}
|
||||
|
||||
/** Reachability detail. */
|
||||
export interface FindingReachabilityDetail {
|
||||
readonly status: ReachabilityStatus;
|
||||
readonly callPathSamples?: readonly string[];
|
||||
readonly lastUpdated?: string;
|
||||
}
|
||||
|
||||
/** SBOM evidence. */
|
||||
export interface SbomEvidence {
|
||||
readonly digest: string;
|
||||
readonly componentPath?: readonly string[];
|
||||
}
|
||||
|
||||
/** Attestation entry. */
|
||||
export interface AttestationEvidence {
|
||||
readonly type: string;
|
||||
readonly attestationId: string;
|
||||
readonly signer?: string;
|
||||
readonly bundleDigest?: string;
|
||||
}
|
||||
|
||||
/** Full evidence for detail view. */
|
||||
export interface FindingEvidenceDetail {
|
||||
readonly sbom?: SbomEvidence;
|
||||
readonly attestations?: readonly AttestationEvidence[];
|
||||
}
|
||||
|
||||
/** Finding details payload. */
|
||||
export interface FindingDetails {
|
||||
readonly description?: string;
|
||||
readonly references?: readonly string[];
|
||||
readonly exploitAvailability?: string;
|
||||
}
|
||||
|
||||
/** Finding timestamps for detail view. */
|
||||
export interface FindingTimestampsDetail {
|
||||
readonly firstSeen: string;
|
||||
readonly lastSeen: string;
|
||||
readonly vexLastUpdated?: string;
|
||||
}
|
||||
|
||||
/** Full finding detail response. */
|
||||
export interface VulnFindingDetail {
|
||||
readonly findingId: string;
|
||||
readonly details?: FindingDetails;
|
||||
readonly policyBadges?: readonly PolicyBadgeDetail[];
|
||||
readonly vex?: FindingVexDetail;
|
||||
readonly reachability?: FindingReachabilityDetail;
|
||||
readonly evidence?: FindingEvidenceDetail;
|
||||
readonly timestamps?: FindingTimestampsDetail;
|
||||
readonly traceId?: string;
|
||||
readonly etag?: string;
|
||||
}
|
||||
|
||||
/** Query options for finding detail. */
|
||||
export interface VulnFindingQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly traceId?: string;
|
||||
readonly ifNoneMatch?: string;
|
||||
}
|
||||
|
||||
/** Ticket export request. */
|
||||
export interface VulnTicketRequest {
|
||||
readonly tenant: string;
|
||||
readonly selection: readonly string[];
|
||||
readonly targetSystem: string;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Ticket attachment. */
|
||||
export interface TicketAttachment {
|
||||
readonly type: string;
|
||||
readonly name: string;
|
||||
readonly digest: string;
|
||||
readonly contentType: string;
|
||||
readonly expiresAt?: string;
|
||||
}
|
||||
|
||||
/** Ticket payload. */
|
||||
export interface TicketPayload {
|
||||
readonly version: string;
|
||||
readonly tenant: string;
|
||||
readonly findings: readonly { findingId: string; severity: string }[];
|
||||
readonly policyBadge?: string;
|
||||
readonly vexSummary?: string;
|
||||
readonly attachments?: readonly TicketAttachment[];
|
||||
}
|
||||
|
||||
/** Ticket response. */
|
||||
export interface VulnTicketResponse {
|
||||
readonly ticketId: string;
|
||||
readonly payload: TicketPayload;
|
||||
readonly auditEventId: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
369
src/Web/StellaOps.Web/src/app/core/api/export-center.client.ts
Normal file
369
src/Web/StellaOps.Web/src/app/core/api/export-center.client.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { map, catchError, delay } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import {
|
||||
EVENT_SOURCE_FACTORY,
|
||||
EventSourceFactory,
|
||||
DEFAULT_EVENT_SOURCE_FACTORY,
|
||||
} from './console-status.client';
|
||||
import {
|
||||
ExportProfile,
|
||||
ExportProfilesResponse,
|
||||
ExportProfilesQueryOptions,
|
||||
ExportRunRequest,
|
||||
ExportRunResponse,
|
||||
ExportRunQueryOptions,
|
||||
ExportRunEvent,
|
||||
DistributionResponse,
|
||||
ExportRunStatus,
|
||||
ExportTargetType,
|
||||
ExportFormat,
|
||||
} from './export-center.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
export const EXPORT_CENTER_API_BASE_URL = new InjectionToken<string>('EXPORT_CENTER_API_BASE_URL');
|
||||
|
||||
/**
|
||||
* Export Center API interface.
|
||||
* Implements WEB-EXPORT-35-001, WEB-EXPORT-36-001, WEB-EXPORT-37-001.
|
||||
*/
|
||||
export interface ExportCenterApi {
|
||||
/** List export profiles. */
|
||||
listProfiles(options?: ExportProfilesQueryOptions): Observable<ExportProfilesResponse>;
|
||||
|
||||
/** Start an export run. */
|
||||
startRun(request: ExportRunRequest, options?: ExportRunQueryOptions): Observable<ExportRunResponse>;
|
||||
|
||||
/** Get export run status. */
|
||||
getRun(runId: string, options?: ExportRunQueryOptions): Observable<ExportRunResponse>;
|
||||
|
||||
/** Stream export run events (SSE). */
|
||||
streamRun(runId: string, options?: ExportRunQueryOptions): Observable<ExportRunEvent>;
|
||||
|
||||
/** Get distribution signed URLs. */
|
||||
getDistribution(distributionId: string, options?: ExportRunQueryOptions): Observable<DistributionResponse>;
|
||||
}
|
||||
|
||||
export const EXPORT_CENTER_API = new InjectionToken<ExportCenterApi>('EXPORT_CENTER_API');
|
||||
|
||||
/**
|
||||
* HTTP Export Center Client.
|
||||
* Implements WEB-EXPORT-35-001, WEB-EXPORT-36-001, WEB-EXPORT-37-001.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExportCenterHttpClient implements ExportCenterApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly authSession: AuthSessionStore,
|
||||
private readonly tenantService: TenantActivationService,
|
||||
@Inject(EXPORT_CENTER_API_BASE_URL) private readonly baseUrl: string,
|
||||
@Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory = DEFAULT_EVENT_SOURCE_FACTORY
|
||||
) {}
|
||||
|
||||
listProfiles(options: ExportProfilesQueryOptions = {}): Observable<ExportProfilesResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('export', 'read', ['export:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing export:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
let params = new HttpParams();
|
||||
if (options.pageToken) {
|
||||
params = params.set('pageToken', options.pageToken);
|
||||
}
|
||||
if (options.pageSize) {
|
||||
params = params.set('pageSize', String(options.pageSize));
|
||||
}
|
||||
|
||||
return this.http.get<ExportProfilesResponse>(`${this.baseUrl}/profiles`, { headers, params }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
startRun(request: ExportRunRequest, options: ExportRunQueryOptions = {}): Observable<ExportRunResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('export', 'write', ['export:write'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing export:write scope'));
|
||||
}
|
||||
|
||||
let headers = this.buildHeaders(options);
|
||||
if (options.idempotencyKey) {
|
||||
headers = headers.set('Idempotency-Key', options.idempotencyKey);
|
||||
}
|
||||
|
||||
return this.http.post<ExportRunResponse>(`${this.baseUrl}/runs`, request, { headers }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getRun(runId: string, options: ExportRunQueryOptions = {}): Observable<ExportRunResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('export', 'read', ['export:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing export:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
return this.http.get<ExportRunResponse>(
|
||||
`${this.baseUrl}/runs/${encodeURIComponent(runId)}`,
|
||||
{ headers }
|
||||
).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
streamRun(runId: string, options: ExportRunQueryOptions = {}): Observable<ExportRunEvent> {
|
||||
const tenant = this.resolveTenant(options.tenantId);
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
const url = `${this.baseUrl}/runs/${encodeURIComponent(runId)}/events?tenant=${encodeURIComponent(tenant)}&traceId=${encodeURIComponent(traceId)}`;
|
||||
|
||||
return new Observable<ExportRunEvent>((observer) => {
|
||||
const source = this.eventSourceFactory(url);
|
||||
|
||||
const handleEvent = (eventType: string) => (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
observer.next({
|
||||
event: eventType as ExportRunEvent['event'],
|
||||
runId,
|
||||
...data,
|
||||
traceId,
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
};
|
||||
|
||||
source.addEventListener('started', handleEvent('started'));
|
||||
source.addEventListener('progress', handleEvent('progress'));
|
||||
source.addEventListener('artifact_ready', handleEvent('artifact_ready'));
|
||||
source.addEventListener('completed', handleEvent('completed'));
|
||||
source.addEventListener('failed', handleEvent('failed'));
|
||||
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data) as ExportRunEvent;
|
||||
observer.next({ ...parsed, runId, traceId });
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
observer.error(new Error(`[${traceId}] Export run stream error`));
|
||||
source.close();
|
||||
};
|
||||
|
||||
return () => source.close();
|
||||
});
|
||||
}
|
||||
|
||||
getDistribution(distributionId: string, options: ExportRunQueryOptions = {}): Observable<DistributionResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('export', 'read', ['export:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing export:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
return this.http.get<DistributionResponse>(
|
||||
`${this.baseUrl}/distributions/${encodeURIComponent(distributionId)}`,
|
||||
{ headers }
|
||||
).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(opts: { tenantId?: string; traceId?: string }): HttpHeaders {
|
||||
const tenant = this.resolveTenant(opts.tenantId);
|
||||
const trace = opts.traceId ?? generateTraceId();
|
||||
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': trace,
|
||||
'X-Stella-Request-Id': trace,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
private resolveTenant(tenantId?: string): string {
|
||||
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||
if (!tenant) {
|
||||
throw new Error('ExportCenterClient requires an active tenant identifier.');
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private mapError(err: unknown, traceId: string): Error {
|
||||
if (err instanceof Error) {
|
||||
return new Error(`[${traceId}] Export Center error: ${err.message}`);
|
||||
}
|
||||
return new Error(`[${traceId}] Export Center error: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Export Center API for quickstart mode.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockExportCenterClient implements ExportCenterApi {
|
||||
private readonly mockProfiles: ExportProfile[] = [
|
||||
{
|
||||
profileId: 'export-profile::tenant-default::daily-vex',
|
||||
name: 'Daily VEX Export',
|
||||
description: 'Daily export of VEX statements and advisories',
|
||||
targets: ['vex', 'advisory'],
|
||||
formats: ['json', 'ndjson'],
|
||||
schedule: '0 2 * * *',
|
||||
retentionDays: 30,
|
||||
createdAt: '2025-10-01T00:00:00Z',
|
||||
updatedAt: '2025-11-15T10:00:00Z',
|
||||
},
|
||||
{
|
||||
profileId: 'export-profile::tenant-default::weekly-full',
|
||||
name: 'Weekly Full Export',
|
||||
description: 'Weekly comprehensive export of all security data',
|
||||
targets: ['vex', 'advisory', 'policy', 'scan', 'sbom'],
|
||||
formats: ['json', 'ndjson', 'csv'],
|
||||
schedule: '0 3 * * 0',
|
||||
retentionDays: 90,
|
||||
createdAt: '2025-09-15T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
private runCounter = 0;
|
||||
|
||||
listProfiles(options: ExportProfilesQueryOptions = {}): Observable<ExportProfilesResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
return of({
|
||||
items: this.mockProfiles,
|
||||
total: this.mockProfiles.length,
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
startRun(request: ExportRunRequest, options: ExportRunQueryOptions = {}): Observable<ExportRunResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
this.runCounter++;
|
||||
const runId = `export-run::tenant-default::${new Date().toISOString().split('T')[0]}::${String(this.runCounter).padStart(4, '0')}`;
|
||||
|
||||
return of({
|
||||
runId,
|
||||
status: 'queued' as ExportRunStatus,
|
||||
profileId: request.profileId,
|
||||
estimateSeconds: 420,
|
||||
links: {
|
||||
status: `/export-center/runs/${runId}`,
|
||||
events: `/export-center/runs/${runId}/events`,
|
||||
},
|
||||
retryAfter: 5,
|
||||
traceId,
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
|
||||
getRun(runId: string, options: ExportRunQueryOptions = {}): Observable<ExportRunResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
return of({
|
||||
runId,
|
||||
status: 'running' as ExportRunStatus,
|
||||
startedAt: new Date(Date.now() - 60000).toISOString(),
|
||||
outputs: [
|
||||
{
|
||||
type: 'manifest',
|
||||
format: 'json' as ExportFormat,
|
||||
url: `https://exports.local/tenant-default/${runId}/manifest.json?sig=mock`,
|
||||
sha256: 'sha256:c0ffee1234567890',
|
||||
dsseUrl: `https://exports.local/tenant-default/${runId}/manifest.dsse?sig=mock`,
|
||||
expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
],
|
||||
progress: {
|
||||
percent: 35,
|
||||
itemsCompleted: 70,
|
||||
itemsTotal: 200,
|
||||
},
|
||||
errors: [],
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
streamRun(runId: string, options: ExportRunQueryOptions = {}): Observable<ExportRunEvent> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
return new Observable<ExportRunEvent>((observer) => {
|
||||
// Emit started
|
||||
setTimeout(() => {
|
||||
observer.next({
|
||||
event: 'started',
|
||||
runId,
|
||||
status: 'running',
|
||||
traceId,
|
||||
});
|
||||
}, 100);
|
||||
|
||||
// Emit progress updates
|
||||
let percent = 0;
|
||||
const progressInterval = setInterval(() => {
|
||||
percent += 10;
|
||||
if (percent <= 100) {
|
||||
observer.next({
|
||||
event: 'progress',
|
||||
runId,
|
||||
percent,
|
||||
itemsCompleted: percent * 2,
|
||||
itemsTotal: 200,
|
||||
traceId,
|
||||
});
|
||||
}
|
||||
|
||||
if (percent >= 100) {
|
||||
clearInterval(progressInterval);
|
||||
// Emit completed
|
||||
observer.next({
|
||||
event: 'completed',
|
||||
runId,
|
||||
status: 'succeeded',
|
||||
manifestUrl: `https://exports.local/tenant-default/${runId}/manifest.json?sig=mock`,
|
||||
manifestDsseUrl: `https://exports.local/tenant-default/${runId}/manifest.dsse?sig=mock`,
|
||||
traceId,
|
||||
});
|
||||
observer.complete();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(progressInterval);
|
||||
});
|
||||
}
|
||||
|
||||
getDistribution(distributionId: string, options: ExportRunQueryOptions = {}): Observable<DistributionResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
return of({
|
||||
distributionId,
|
||||
type: 'oci' as const,
|
||||
ref: 'registry.local/exports/daily:latest',
|
||||
url: `https://registry.local/v2/exports/daily/manifests/latest?sig=mock`,
|
||||
sha256: 'sha256:dist1234567890',
|
||||
dsseUrl: `https://registry.local/v2/exports/daily/manifests/latest.dsse?sig=mock`,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
size: 1024 * 1024 * 50,
|
||||
traceId,
|
||||
etag: `"dist-${distributionId}-${Date.now()}"`,
|
||||
}).pipe(delay(30));
|
||||
}
|
||||
}
|
||||
186
src/Web/StellaOps.Web/src/app/core/api/export-center.models.ts
Normal file
186
src/Web/StellaOps.Web/src/app/core/api/export-center.models.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Export Center Models.
|
||||
* Implements WEB-EXPORT-35-001, WEB-EXPORT-36-001, WEB-EXPORT-37-001.
|
||||
*/
|
||||
|
||||
/** Export run status. */
|
||||
export type ExportRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'expired';
|
||||
|
||||
/** Export format. */
|
||||
export type ExportFormat = 'json' | 'ndjson' | 'csv' | 'pdf';
|
||||
|
||||
/** Export target type. */
|
||||
export type ExportTargetType = 'vex' | 'advisory' | 'policy' | 'scan' | 'sbom' | 'attestation';
|
||||
|
||||
/** Export priority. */
|
||||
export type ExportPriority = 'low' | 'normal' | 'high';
|
||||
|
||||
/** Distribution type. */
|
||||
export type DistributionType = 'oci' | 'object-storage' | 's3' | 'gcs' | 'azure-blob';
|
||||
|
||||
/** Export profile. */
|
||||
export interface ExportProfile {
|
||||
readonly profileId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly targets: readonly ExportTargetType[];
|
||||
readonly formats: readonly ExportFormat[];
|
||||
readonly schedule?: string;
|
||||
readonly retentionDays?: number;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Export profiles list response. */
|
||||
export interface ExportProfilesResponse {
|
||||
readonly items: readonly ExportProfile[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Distribution signing config. */
|
||||
export interface DistributionSigning {
|
||||
readonly enabled: boolean;
|
||||
readonly keyRef?: string;
|
||||
}
|
||||
|
||||
/** Distribution config. */
|
||||
export interface DistributionConfig {
|
||||
readonly type: DistributionType;
|
||||
readonly ref?: string;
|
||||
readonly signing?: DistributionSigning;
|
||||
}
|
||||
|
||||
/** Encryption config. */
|
||||
export interface EncryptionConfig {
|
||||
readonly enabled: boolean;
|
||||
readonly kmsKey?: string;
|
||||
}
|
||||
|
||||
/** Export run request. */
|
||||
export interface ExportRunRequest {
|
||||
readonly profileId?: string;
|
||||
readonly targets: readonly ExportTargetType[];
|
||||
readonly formats: readonly ExportFormat[];
|
||||
readonly distribution?: DistributionConfig;
|
||||
readonly retentionDays?: number;
|
||||
readonly encryption?: EncryptionConfig;
|
||||
readonly priority?: ExportPriority;
|
||||
}
|
||||
|
||||
/** Export run links. */
|
||||
export interface ExportRunLinks {
|
||||
readonly status: string;
|
||||
readonly events?: string;
|
||||
}
|
||||
|
||||
/** Export run output. */
|
||||
export interface ExportRunOutput {
|
||||
readonly type: string;
|
||||
readonly format: ExportFormat | string;
|
||||
readonly url: string;
|
||||
readonly sha256?: string;
|
||||
readonly dsseUrl?: string;
|
||||
readonly expiresAt?: string;
|
||||
readonly size?: number;
|
||||
}
|
||||
|
||||
/** Export run progress. */
|
||||
export interface ExportRunProgress {
|
||||
readonly percent: number;
|
||||
readonly itemsCompleted?: number;
|
||||
readonly itemsTotal?: number;
|
||||
}
|
||||
|
||||
/** Export run error. */
|
||||
export interface ExportRunError {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly field?: string;
|
||||
}
|
||||
|
||||
/** Export run response. */
|
||||
export interface ExportRunResponse {
|
||||
readonly runId: string;
|
||||
readonly status: ExportRunStatus;
|
||||
readonly profileId?: string;
|
||||
readonly startedAt?: string;
|
||||
readonly completedAt?: string;
|
||||
readonly estimateSeconds?: number;
|
||||
readonly links?: ExportRunLinks;
|
||||
readonly outputs?: readonly ExportRunOutput[];
|
||||
readonly progress?: ExportRunProgress;
|
||||
readonly errors?: readonly ExportRunError[];
|
||||
readonly retryAfter?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Export SSE event types. */
|
||||
export type ExportEventType =
|
||||
| 'started'
|
||||
| 'progress'
|
||||
| 'artifact_ready'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
/** Export SSE event. */
|
||||
export interface ExportRunEvent {
|
||||
readonly event: ExportEventType;
|
||||
readonly runId: string;
|
||||
readonly status?: ExportRunStatus;
|
||||
readonly percent?: number;
|
||||
readonly itemsCompleted?: number;
|
||||
readonly itemsTotal?: number;
|
||||
readonly type?: string;
|
||||
readonly id?: string;
|
||||
readonly url?: string;
|
||||
readonly sha256?: string;
|
||||
readonly format?: string;
|
||||
readonly manifestUrl?: string;
|
||||
readonly manifestDsseUrl?: string;
|
||||
readonly code?: string;
|
||||
readonly message?: string;
|
||||
readonly retryAfterSeconds?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Distribution response. */
|
||||
export interface DistributionResponse {
|
||||
readonly distributionId: string;
|
||||
readonly type: DistributionType;
|
||||
readonly ref?: string;
|
||||
readonly url: string;
|
||||
readonly sha256?: string;
|
||||
readonly dsseUrl?: string;
|
||||
readonly expiresAt: string;
|
||||
readonly size?: number;
|
||||
readonly traceId?: string;
|
||||
readonly etag?: string;
|
||||
}
|
||||
|
||||
/** Export profile query options. */
|
||||
export interface ExportProfilesQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly pageToken?: string;
|
||||
readonly pageSize?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Export run query options. */
|
||||
export interface ExportRunQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly idempotencyKey?: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Export error codes. */
|
||||
export type ExportErrorCode =
|
||||
| 'ERR_EXPORT_PROFILE_NOT_FOUND'
|
||||
| 'ERR_EXPORT_REQUEST_INVALID'
|
||||
| 'ERR_EXPORT_TOO_LARGE'
|
||||
| 'ERR_EXPORT_RATE_LIMIT'
|
||||
| 'ERR_EXPORT_DISTRIBUTION_FAILED'
|
||||
| 'ERR_EXPORT_EXPIRED';
|
||||
@@ -0,0 +1,461 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { map, catchError, delay } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import {
|
||||
ObsHealthResponse,
|
||||
ObsSloResponse,
|
||||
TraceResponse,
|
||||
LogsResponse,
|
||||
LogsQueryOptions,
|
||||
EvidenceResponse,
|
||||
AttestationsResponse,
|
||||
IncidentModeResponse,
|
||||
IncidentModeRequest,
|
||||
SealStatusResponse,
|
||||
ObsQueryOptions,
|
||||
} from './gateway-observability.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
export const OBS_API_BASE_URL = new InjectionToken<string>('OBS_API_BASE_URL');
|
||||
|
||||
/**
|
||||
* Gateway Observability API interface.
|
||||
* Implements WEB-OBS-50-001 through WEB-OBS-56-001.
|
||||
*/
|
||||
export interface GatewayObservabilityApi {
|
||||
/** Get health status. WEB-OBS-51-001. */
|
||||
getHealth(options?: ObsQueryOptions): Observable<ObsHealthResponse>;
|
||||
|
||||
/** Get SLO metrics. WEB-OBS-51-001. */
|
||||
getSlos(options?: ObsQueryOptions): Observable<ObsSloResponse>;
|
||||
|
||||
/** Get trace by ID. WEB-OBS-52-001. */
|
||||
getTrace(traceId: string, options?: ObsQueryOptions): Observable<TraceResponse>;
|
||||
|
||||
/** Query logs. WEB-OBS-52-001. */
|
||||
queryLogs(query: LogsQueryOptions): Observable<LogsResponse>;
|
||||
|
||||
/** List evidence. WEB-OBS-54-001. */
|
||||
listEvidence(options?: ObsQueryOptions): Observable<EvidenceResponse>;
|
||||
|
||||
/** List attestations. WEB-OBS-54-001. */
|
||||
listAttestations(options?: ObsQueryOptions): Observable<AttestationsResponse>;
|
||||
|
||||
/** Get incident mode status. WEB-OBS-55-001. */
|
||||
getIncidentMode(options?: ObsQueryOptions): Observable<IncidentModeResponse>;
|
||||
|
||||
/** Update incident mode. WEB-OBS-55-001. */
|
||||
updateIncidentMode(request: IncidentModeRequest, options?: ObsQueryOptions): Observable<IncidentModeResponse>;
|
||||
|
||||
/** Get seal status. WEB-OBS-56-001. */
|
||||
getSealStatus(options?: ObsQueryOptions): Observable<SealStatusResponse>;
|
||||
}
|
||||
|
||||
export const GATEWAY_OBS_API = new InjectionToken<GatewayObservabilityApi>('GATEWAY_OBS_API');
|
||||
|
||||
/**
|
||||
* HTTP Gateway Observability Client.
|
||||
* Implements WEB-OBS-50-001 through WEB-OBS-56-001.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GatewayObservabilityHttpClient implements GatewayObservabilityApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly authSession: AuthSessionStore,
|
||||
private readonly tenantService: TenantActivationService,
|
||||
@Inject(OBS_API_BASE_URL) private readonly baseUrl: string
|
||||
) {}
|
||||
|
||||
getHealth(options: ObsQueryOptions = {}): Observable<ObsHealthResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeaders(traceId);
|
||||
|
||||
return this.http.get<ObsHealthResponse>(`${this.baseUrl}/obs/health`, { headers }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getSlos(options: ObsQueryOptions = {}): Observable<ObsSloResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeaders(traceId);
|
||||
|
||||
return this.http.get<ObsSloResponse>(`${this.baseUrl}/obs/slo`, { headers }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getTrace(traceIdParam: string, options: ObsQueryOptions = {}): Observable<TraceResponse> {
|
||||
const reqTraceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('obs', 'read', ['timeline:read'], options.projectId, reqTraceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing timeline:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(reqTraceId);
|
||||
|
||||
return this.http.get<TraceResponse>(
|
||||
`${this.baseUrl}/obs/trace/${encodeURIComponent(traceIdParam)}`,
|
||||
{ headers }
|
||||
).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, reqTraceId)))
|
||||
);
|
||||
}
|
||||
|
||||
queryLogs(query: LogsQueryOptions): Observable<LogsResponse> {
|
||||
const traceId = query.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('obs', 'read', ['timeline:read'], query.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing timeline:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(traceId);
|
||||
let params = new HttpParams();
|
||||
|
||||
if (query.service) params = params.set('service', query.service);
|
||||
if (query.level) params = params.set('level', query.level);
|
||||
if (query.traceId) params = params.set('traceId', query.traceId);
|
||||
if (query.startTime) params = params.set('startTime', query.startTime);
|
||||
if (query.endTime) params = params.set('endTime', query.endTime);
|
||||
if (query.limit) params = params.set('limit', String(query.limit));
|
||||
if (query.pageToken) params = params.set('pageToken', query.pageToken);
|
||||
|
||||
return this.http.get<LogsResponse>(`${this.baseUrl}/obs/logs`, { headers, params }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
listEvidence(options: ObsQueryOptions = {}): Observable<EvidenceResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('obs', 'read', ['evidence:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing evidence:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(traceId);
|
||||
const params = this.buildPaginationParams(options);
|
||||
|
||||
return this.http.get<EvidenceResponse>(`${this.baseUrl}/evidence`, { headers, params }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
listAttestations(options: ObsQueryOptions = {}): Observable<AttestationsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('obs', 'read', ['attest:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing attest:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(traceId);
|
||||
const params = this.buildPaginationParams(options);
|
||||
|
||||
return this.http.get<AttestationsResponse>(`${this.baseUrl}/attestations`, { headers, params }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getIncidentMode(options: ObsQueryOptions = {}): Observable<IncidentModeResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeaders(traceId);
|
||||
|
||||
return this.http.get<IncidentModeResponse>(`${this.baseUrl}/obs/incident-mode`, { headers }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
updateIncidentMode(request: IncidentModeRequest, options: ObsQueryOptions = {}): Observable<IncidentModeResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeaders(traceId);
|
||||
|
||||
return this.http.post<IncidentModeResponse>(`${this.baseUrl}/obs/incident-mode`, request, { headers }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getSealStatus(options: ObsQueryOptions = {}): Observable<SealStatusResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeaders(traceId);
|
||||
|
||||
return this.http.get<SealStatusResponse>(`${this.baseUrl}/obs/seal-status`, { headers }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
private buildPaginationParams(options: ObsQueryOptions): HttpParams {
|
||||
let params = new HttpParams();
|
||||
if (options.pageToken) {
|
||||
params = params.set('pageToken', options.pageToken);
|
||||
}
|
||||
if (options.pageSize) {
|
||||
params = params.set('pageSize', String(options.pageSize));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
private mapError(err: unknown, traceId: string): Error {
|
||||
if (err instanceof Error) {
|
||||
return new Error(`[${traceId}] Observability error: ${err.message}`);
|
||||
}
|
||||
return new Error(`[${traceId}] Observability error: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Gateway Observability Client for quickstart mode.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockGatewayObservabilityClient implements GatewayObservabilityApi {
|
||||
getHealth(options: ObsQueryOptions = {}): Observable<ObsHealthResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
status: 'healthy' as const,
|
||||
checks: [
|
||||
{ name: 'database', status: 'healthy' as const, latencyMs: 5, checkedAt: new Date().toISOString() },
|
||||
{ name: 'cache', status: 'healthy' as const, latencyMs: 2, checkedAt: new Date().toISOString() },
|
||||
{ name: 'queue', status: 'healthy' as const, latencyMs: 8, checkedAt: new Date().toISOString() },
|
||||
],
|
||||
uptimeSeconds: 86400,
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
getSlos(options: ObsQueryOptions = {}): Observable<ObsSloResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
slos: [
|
||||
{
|
||||
name: 'Availability',
|
||||
target: 99.9,
|
||||
current: 99.95,
|
||||
status: 'met' as const,
|
||||
burnRate: 0.5,
|
||||
errorBudgetRemaining: 0.05,
|
||||
windowHours: 720,
|
||||
},
|
||||
{
|
||||
name: 'Latency P99',
|
||||
target: 200,
|
||||
current: 180,
|
||||
status: 'met' as const,
|
||||
burnRate: 0.9,
|
||||
errorBudgetRemaining: 0.1,
|
||||
windowHours: 720,
|
||||
},
|
||||
{
|
||||
name: 'Error Rate',
|
||||
target: 0.1,
|
||||
current: 0.08,
|
||||
status: 'met' as const,
|
||||
burnRate: 0.8,
|
||||
errorBudgetRemaining: 0.02,
|
||||
windowHours: 720,
|
||||
},
|
||||
],
|
||||
exemplars: [
|
||||
{ traceId: 'trace-001', timestamp: new Date().toISOString(), value: 150, labels: { endpoint: '/api/v1/vulns' } },
|
||||
],
|
||||
calculatedAt: new Date().toISOString(),
|
||||
traceId,
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
|
||||
getTrace(traceIdParam: string, options: ObsQueryOptions = {}): Observable<TraceResponse> {
|
||||
return of({
|
||||
traceId: traceIdParam,
|
||||
spans: [
|
||||
{
|
||||
spanId: 'span-001',
|
||||
operationName: 'HTTP GET /api/v1/vulns',
|
||||
serviceName: 'gateway',
|
||||
startTime: new Date(Date.now() - 200).toISOString(),
|
||||
endTime: new Date().toISOString(),
|
||||
durationMs: 200,
|
||||
status: 'ok' as const,
|
||||
attributes: { 'http.method': 'GET', 'http.status_code': 200 },
|
||||
},
|
||||
{
|
||||
spanId: 'span-002',
|
||||
parentSpanId: 'span-001',
|
||||
operationName: 'DB query',
|
||||
serviceName: 'concelier',
|
||||
startTime: new Date(Date.now() - 150).toISOString(),
|
||||
endTime: new Date(Date.now() - 50).toISOString(),
|
||||
durationMs: 100,
|
||||
status: 'ok' as const,
|
||||
},
|
||||
],
|
||||
services: ['gateway', 'concelier'],
|
||||
duration: 200,
|
||||
timestamp: new Date().toISOString(),
|
||||
}).pipe(delay(80));
|
||||
}
|
||||
|
||||
queryLogs(query: LogsQueryOptions): Observable<LogsResponse> {
|
||||
const traceId = query.traceId ?? generateTraceId();
|
||||
return of({
|
||||
items: [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info' as const,
|
||||
message: 'Request processed successfully',
|
||||
service: 'gateway',
|
||||
traceId: 'trace-001',
|
||||
},
|
||||
{
|
||||
timestamp: new Date(Date.now() - 1000).toISOString(),
|
||||
level: 'debug' as const,
|
||||
message: 'Cache hit for advisory lookup',
|
||||
service: 'concelier',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
traceId,
|
||||
}).pipe(delay(60));
|
||||
}
|
||||
|
||||
listEvidence(options: ObsQueryOptions = {}): Observable<EvidenceResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
items: [
|
||||
{
|
||||
evidenceId: 'ev-001',
|
||||
type: 'scan' as const,
|
||||
subjectDigest: 'sha256:abc123',
|
||||
subjectName: 'myapp:latest',
|
||||
createdAt: new Date().toISOString(),
|
||||
provenance: {
|
||||
builderName: 'scanner-v1',
|
||||
buildId: 'build-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
evidenceId: 'ev-002',
|
||||
type: 'attestation' as const,
|
||||
subjectDigest: 'sha256:abc123',
|
||||
subjectName: 'myapp:latest',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
listAttestations(options: ObsQueryOptions = {}): Observable<AttestationsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
items: [
|
||||
{
|
||||
attestationId: 'att-001',
|
||||
predicateType: 'https://slsa.dev/provenance/v1',
|
||||
subjectDigest: 'sha256:abc123',
|
||||
subjectName: 'myapp:latest',
|
||||
issuer: 'stellaops-attestor',
|
||||
issuedAt: new Date().toISOString(),
|
||||
verified: true,
|
||||
verificationSummary: {
|
||||
result: 'passed' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
getIncidentMode(options: ObsQueryOptions = {}): Observable<IncidentModeResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
config: {
|
||||
status: 'inactive' as const,
|
||||
},
|
||||
auditTrail: [
|
||||
{
|
||||
action: 'deactivated' as const,
|
||||
actor: 'admin@example.com',
|
||||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
details: 'Incident resolved',
|
||||
},
|
||||
],
|
||||
traceId,
|
||||
}).pipe(delay(40));
|
||||
}
|
||||
|
||||
updateIncidentMode(request: IncidentModeRequest, options: ObsQueryOptions = {}): Observable<IncidentModeResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
config: {
|
||||
status: request.action === 'enable' ? 'active' as const : request.action === 'schedule' ? 'scheduled' as const : 'inactive' as const,
|
||||
activatedAt: request.action === 'enable' ? new Date().toISOString() : undefined,
|
||||
activatedBy: 'user@example.com',
|
||||
samplingOverride: request.samplingOverride,
|
||||
retentionBumpDays: request.retentionBumpDays,
|
||||
reason: request.reason,
|
||||
},
|
||||
auditTrail: [
|
||||
{
|
||||
action: request.action === 'enable' ? 'activated' as const : request.action === 'schedule' ? 'scheduled' as const : 'deactivated' as const,
|
||||
actor: 'user@example.com',
|
||||
timestamp: new Date().toISOString(),
|
||||
details: request.reason,
|
||||
},
|
||||
],
|
||||
traceId,
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
|
||||
getSealStatus(options: ObsQueryOptions = {}): Observable<SealStatusResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
status: 'unsealed' as const,
|
||||
unsealedAt: new Date(Date.now() - 3600000).toISOString(),
|
||||
driftMetrics: [
|
||||
{
|
||||
component: 'scanner-config',
|
||||
expectedHash: 'sha256:expected123',
|
||||
actualHash: 'sha256:expected123',
|
||||
drifted: false,
|
||||
lastChecked: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
component: 'policy-bundle',
|
||||
expectedHash: 'sha256:expected456',
|
||||
actualHash: 'sha256:expected456',
|
||||
drifted: false,
|
||||
lastChecked: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
widgetData: {
|
||||
sealedComponents: 0,
|
||||
driftedComponents: 0,
|
||||
totalComponents: 2,
|
||||
lastSealVerification: new Date().toISOString(),
|
||||
},
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Gateway Observability Models.
|
||||
* Implements WEB-OBS-50-001 through WEB-OBS-56-001.
|
||||
*/
|
||||
|
||||
/** Health status. */
|
||||
export type ObsHealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
|
||||
|
||||
/** SLO status. */
|
||||
export type ObsSloStatus = 'met' | 'at_risk' | 'breached';
|
||||
|
||||
/**
|
||||
* WEB-OBS-50-001: Telemetry core integration.
|
||||
*/
|
||||
|
||||
/** Trace context. */
|
||||
export interface TraceContext {
|
||||
readonly traceId: string;
|
||||
readonly spanId: string;
|
||||
readonly parentSpanId?: string;
|
||||
readonly sampled: boolean;
|
||||
}
|
||||
|
||||
/** Telemetry metadata. */
|
||||
export interface TelemetryMetadata {
|
||||
readonly tenantId: string;
|
||||
readonly projectId?: string;
|
||||
readonly service: string;
|
||||
readonly operation: string;
|
||||
readonly durationMs: number;
|
||||
readonly statusCode?: number;
|
||||
readonly errorCode?: string;
|
||||
readonly trace: TraceContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-OBS-51-001: Health and SLO aggregations.
|
||||
*/
|
||||
|
||||
/** Health check result. */
|
||||
export interface HealthCheckResult {
|
||||
readonly name: string;
|
||||
readonly status: ObsHealthStatus;
|
||||
readonly message?: string;
|
||||
readonly latencyMs?: number;
|
||||
readonly checkedAt: string;
|
||||
}
|
||||
|
||||
/** Health response. */
|
||||
export interface ObsHealthResponse {
|
||||
readonly status: ObsHealthStatus;
|
||||
readonly checks: readonly HealthCheckResult[];
|
||||
readonly uptimeSeconds?: number;
|
||||
readonly timestamp: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** SLO metric. */
|
||||
export interface SloMetric {
|
||||
readonly name: string;
|
||||
readonly target: number;
|
||||
readonly current: number;
|
||||
readonly status: ObsSloStatus;
|
||||
readonly burnRate?: number;
|
||||
readonly errorBudgetRemaining?: number;
|
||||
readonly windowHours: number;
|
||||
}
|
||||
|
||||
/** SLO exemplar. */
|
||||
export interface SloExemplar {
|
||||
readonly traceId: string;
|
||||
readonly timestamp: string;
|
||||
readonly value: number;
|
||||
readonly labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** SLO response. */
|
||||
export interface ObsSloResponse {
|
||||
readonly slos: readonly SloMetric[];
|
||||
readonly exemplars?: readonly SloExemplar[];
|
||||
readonly calculatedAt: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-OBS-52-001: Trace and log proxy.
|
||||
*/
|
||||
|
||||
/** Trace span. */
|
||||
export interface TraceSpan {
|
||||
readonly spanId: string;
|
||||
readonly parentSpanId?: string;
|
||||
readonly operationName: string;
|
||||
readonly serviceName: string;
|
||||
readonly startTime: string;
|
||||
readonly endTime?: string;
|
||||
readonly durationMs?: number;
|
||||
readonly status: 'ok' | 'error' | 'unset';
|
||||
readonly attributes?: Record<string, unknown>;
|
||||
readonly events?: readonly SpanEvent[];
|
||||
}
|
||||
|
||||
/** Span event. */
|
||||
export interface SpanEvent {
|
||||
readonly name: string;
|
||||
readonly timestamp: string;
|
||||
readonly attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Trace response. */
|
||||
export interface TraceResponse {
|
||||
readonly traceId: string;
|
||||
readonly spans: readonly TraceSpan[];
|
||||
readonly services: readonly string[];
|
||||
readonly duration?: number;
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
/** Log entry. */
|
||||
export interface LogEntry {
|
||||
readonly timestamp: string;
|
||||
readonly level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
||||
readonly message: string;
|
||||
readonly service?: string;
|
||||
readonly traceId?: string;
|
||||
readonly spanId?: string;
|
||||
readonly attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Logs query options. */
|
||||
export interface LogsQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly service?: string;
|
||||
readonly level?: LogEntry['level'];
|
||||
readonly traceId?: string;
|
||||
readonly startTime?: string;
|
||||
readonly endTime?: string;
|
||||
readonly limit?: number;
|
||||
readonly pageToken?: string;
|
||||
}
|
||||
|
||||
/** Logs response. */
|
||||
export interface LogsResponse {
|
||||
readonly items: readonly LogEntry[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly signedUrl?: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-OBS-54-001: Evidence and attestations.
|
||||
*/
|
||||
|
||||
/** Evidence type. */
|
||||
export type EvidenceType = 'scan' | 'attestation' | 'signature' | 'policy' | 'vex';
|
||||
|
||||
/** Evidence item. */
|
||||
export interface EvidenceItem {
|
||||
readonly evidenceId: string;
|
||||
readonly type: EvidenceType;
|
||||
readonly subjectDigest: string;
|
||||
readonly subjectName?: string;
|
||||
readonly createdAt: string;
|
||||
readonly expiresAt?: string;
|
||||
readonly provenance?: {
|
||||
readonly builderName?: string;
|
||||
readonly buildId?: string;
|
||||
readonly timestamp: string;
|
||||
};
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Evidence response. */
|
||||
export interface EvidenceResponse {
|
||||
readonly items: readonly EvidenceItem[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Attestation. */
|
||||
export interface Attestation {
|
||||
readonly attestationId: string;
|
||||
readonly predicateType: string;
|
||||
readonly subjectDigest: string;
|
||||
readonly subjectName?: string;
|
||||
readonly issuer?: string;
|
||||
readonly issuedAt: string;
|
||||
readonly expiresAt?: string;
|
||||
readonly verified: boolean;
|
||||
readonly verificationSummary?: {
|
||||
readonly result: 'passed' | 'failed' | 'skipped';
|
||||
readonly errors?: readonly string[];
|
||||
readonly warnings?: readonly string[];
|
||||
};
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Attestations response. */
|
||||
export interface AttestationsResponse {
|
||||
readonly items: readonly Attestation[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-OBS-55-001: Incident mode.
|
||||
*/
|
||||
|
||||
/** Incident mode status. */
|
||||
export type IncidentModeStatus = 'active' | 'inactive' | 'scheduled';
|
||||
|
||||
/** Incident mode config. */
|
||||
export interface IncidentModeConfig {
|
||||
readonly status: IncidentModeStatus;
|
||||
readonly activatedAt?: string;
|
||||
readonly activatedBy?: string;
|
||||
readonly deactivatedAt?: string;
|
||||
readonly scheduledAt?: string;
|
||||
readonly scheduledDuration?: number;
|
||||
readonly samplingOverride?: number;
|
||||
readonly retentionBumpDays?: number;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/** Incident mode response. */
|
||||
export interface IncidentModeResponse {
|
||||
readonly config: IncidentModeConfig;
|
||||
readonly auditTrail: readonly {
|
||||
readonly action: 'activated' | 'deactivated' | 'scheduled' | 'modified';
|
||||
readonly actor: string;
|
||||
readonly timestamp: string;
|
||||
readonly details?: string;
|
||||
}[];
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Incident mode request. */
|
||||
export interface IncidentModeRequest {
|
||||
readonly action: 'enable' | 'disable' | 'schedule';
|
||||
readonly scheduledAt?: string;
|
||||
readonly scheduledDuration?: number;
|
||||
readonly samplingOverride?: number;
|
||||
readonly retentionBumpDays?: number;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-OBS-56-001: Sealed/unsealed status.
|
||||
*/
|
||||
|
||||
/** Seal status. */
|
||||
export type SealStatus = 'sealed' | 'unsealed' | 'transitioning';
|
||||
|
||||
/** Seal drift. */
|
||||
export interface SealDrift {
|
||||
readonly component: string;
|
||||
readonly expectedHash: string;
|
||||
readonly actualHash?: string;
|
||||
readonly drifted: boolean;
|
||||
readonly lastChecked: string;
|
||||
}
|
||||
|
||||
/** Seal status response. */
|
||||
export interface SealStatusResponse {
|
||||
readonly status: SealStatus;
|
||||
readonly sealedAt?: string;
|
||||
readonly unsealedAt?: string;
|
||||
readonly driftMetrics: readonly SealDrift[];
|
||||
readonly widgetData?: {
|
||||
readonly sealedComponents: number;
|
||||
readonly driftedComponents: number;
|
||||
readonly totalComponents: number;
|
||||
readonly lastSealVerification: string;
|
||||
};
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Observability query options. */
|
||||
export interface ObsQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly pageToken?: string;
|
||||
readonly pageSize?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Observability error codes. */
|
||||
export type ObsErrorCode =
|
||||
| 'ERR_OBS_TRACE_NOT_FOUND'
|
||||
| 'ERR_OBS_LOGS_TIMEOUT'
|
||||
| 'ERR_OBS_EVIDENCE_NOT_FOUND'
|
||||
| 'ERR_OBS_ATTESTATION_INVALID'
|
||||
| 'ERR_OBS_INCIDENT_MODE_CONFLICT'
|
||||
| 'ERR_OBS_SEAL_OPERATION_FAILED';
|
||||
258
src/Web/StellaOps.Web/src/app/core/api/gateway-openapi.client.ts
Normal file
258
src/Web/StellaOps.Web/src/app/core/api/gateway-openapi.client.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { map, catchError, delay } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import {
|
||||
OpenApiSpecResponse,
|
||||
GatewayInfo,
|
||||
GatewayHealthCheck,
|
||||
DeprecatedRoutesResponse,
|
||||
IdempotencyResponse,
|
||||
RateLimitInfo,
|
||||
OpenApiQueryOptions,
|
||||
} from './gateway-openapi.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
export const GATEWAY_API_BASE_URL = new InjectionToken<string>('GATEWAY_API_BASE_URL');
|
||||
|
||||
/**
|
||||
* Gateway OpenAPI API interface.
|
||||
* Implements WEB-OAS-61-001, WEB-OAS-61-002, WEB-OAS-62-001, WEB-OAS-63-001.
|
||||
*/
|
||||
export interface GatewayOpenApiApi {
|
||||
/** Get OpenAPI spec. WEB-OAS-61-001. */
|
||||
getOpenApiSpec(options?: OpenApiQueryOptions): Observable<OpenApiSpecResponse>;
|
||||
|
||||
/** Get gateway info. */
|
||||
getGatewayInfo(options?: OpenApiQueryOptions): Observable<GatewayInfo>;
|
||||
|
||||
/** Get gateway health. */
|
||||
getGatewayHealth(options?: OpenApiQueryOptions): Observable<GatewayHealthCheck>;
|
||||
|
||||
/** Get deprecated routes. WEB-OAS-63-001. */
|
||||
getDeprecatedRoutes(options?: OpenApiQueryOptions): Observable<DeprecatedRoutesResponse>;
|
||||
|
||||
/** Check idempotency key. WEB-OAS-62-001. */
|
||||
checkIdempotencyKey(key: string, options?: OpenApiQueryOptions): Observable<IdempotencyResponse>;
|
||||
|
||||
/** Get rate limit info. WEB-OAS-62-001. */
|
||||
getRateLimitInfo(options?: OpenApiQueryOptions): Observable<RateLimitInfo>;
|
||||
}
|
||||
|
||||
export const GATEWAY_OPENAPI_API = new InjectionToken<GatewayOpenApiApi>('GATEWAY_OPENAPI_API');
|
||||
|
||||
/**
|
||||
* HTTP Gateway OpenAPI Client.
|
||||
* Implements WEB-OAS-61-001, WEB-OAS-61-002, WEB-OAS-62-001, WEB-OAS-63-001.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GatewayOpenApiHttpClient implements GatewayOpenApiApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly authSession: AuthSessionStore,
|
||||
@Inject(GATEWAY_API_BASE_URL) private readonly baseUrl: string
|
||||
) {}
|
||||
|
||||
getOpenApiSpec(options: OpenApiQueryOptions = {}): Observable<OpenApiSpecResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
let headers = this.buildHeaders(traceId);
|
||||
|
||||
if (options.ifNoneMatch) {
|
||||
headers = headers.set('If-None-Match', options.ifNoneMatch);
|
||||
}
|
||||
|
||||
return this.http.get<OpenApiSpecResponse>(
|
||||
`${this.baseUrl}/.well-known/openapi`,
|
||||
{ headers, observe: 'response' }
|
||||
).pipe(
|
||||
map((response: HttpResponse<OpenApiSpecResponse>) => {
|
||||
const body = response.body!;
|
||||
const etag = response.headers.get('ETag') || body.etag;
|
||||
return { ...body, etag, traceId };
|
||||
}),
|
||||
catchError((err) => {
|
||||
if (err.status === 304) {
|
||||
return throwError(() => new Error(`[${traceId}] Not Modified`));
|
||||
}
|
||||
return throwError(() => this.mapError(err, traceId));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getGatewayInfo(options: OpenApiQueryOptions = {}): Observable<GatewayInfo> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeaders(traceId);
|
||||
|
||||
return this.http.get<GatewayInfo>(`${this.baseUrl}/info`, { headers }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getGatewayHealth(options: OpenApiQueryOptions = {}): Observable<GatewayHealthCheck> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeaders(traceId);
|
||||
|
||||
return this.http.get<GatewayHealthCheck>(`${this.baseUrl}/health`, { headers }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getDeprecatedRoutes(options: OpenApiQueryOptions = {}): Observable<DeprecatedRoutesResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeaders(traceId);
|
||||
|
||||
return this.http.get<DeprecatedRoutesResponse>(`${this.baseUrl}/deprecated-routes`, { headers }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
checkIdempotencyKey(key: string, options: OpenApiQueryOptions = {}): Observable<IdempotencyResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeaders(traceId);
|
||||
|
||||
return this.http.get<IdempotencyResponse>(
|
||||
`${this.baseUrl}/idempotency/${encodeURIComponent(key)}`,
|
||||
{ headers }
|
||||
).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getRateLimitInfo(options: OpenApiQueryOptions = {}): Observable<RateLimitInfo> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeaders(traceId);
|
||||
|
||||
return this.http.get<RateLimitInfo>(`${this.baseUrl}/rate-limit`, { headers }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
private mapError(err: unknown, traceId: string): Error {
|
||||
if (err instanceof Error) {
|
||||
return new Error(`[${traceId}] Gateway OpenAPI error: ${err.message}`);
|
||||
}
|
||||
return new Error(`[${traceId}] Gateway OpenAPI error: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Gateway OpenAPI Client for quickstart mode.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockGatewayOpenApiClient implements GatewayOpenApiApi {
|
||||
private readonly mockSpec: OpenApiSpecResponse = {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'StellaOps Gateway API',
|
||||
version: '1.0.0',
|
||||
description: 'Gateway API for StellaOps platform',
|
||||
},
|
||||
paths: {
|
||||
'/health': { get: { summary: 'Health check' } },
|
||||
'/info': { get: { summary: 'Gateway info' } },
|
||||
'/.well-known/openapi': { get: { summary: 'OpenAPI spec' } },
|
||||
},
|
||||
etag: '"spec-v1.0.0-20251211"',
|
||||
versionInfo: {
|
||||
specVersion: '1.0.0',
|
||||
gatewayVersion: '1.0.0',
|
||||
buildTimestamp: '2025-12-11T00:00:00Z',
|
||||
gitCommit: 'abc123',
|
||||
},
|
||||
};
|
||||
|
||||
private readonly mockGatewayInfo: GatewayInfo = {
|
||||
name: 'StellaOps Gateway',
|
||||
version: '1.0.0',
|
||||
environment: 'development',
|
||||
region: 'local',
|
||||
features: [
|
||||
'rate-limiting',
|
||||
'idempotency',
|
||||
'cursor-pagination',
|
||||
'deprecation-headers',
|
||||
'etag-caching',
|
||||
],
|
||||
uptime: 86400,
|
||||
};
|
||||
|
||||
private readonly mockDeprecatedRoutes: DeprecatedRoutesResponse = {
|
||||
items: [
|
||||
{
|
||||
path: '/api/v1/vulnerabilities',
|
||||
method: 'GET',
|
||||
deprecation: {
|
||||
deprecated: true,
|
||||
sunsetAt: '2026-06-01T00:00:00Z',
|
||||
replacedBy: '/api/v2/findings',
|
||||
migrationGuide: 'https://docs.stellaops.local/migration/v2-findings',
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
};
|
||||
|
||||
getOpenApiSpec(options: OpenApiQueryOptions = {}): Observable<OpenApiSpecResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
// Simulate ETag caching
|
||||
if (options.ifNoneMatch === this.mockSpec.etag) {
|
||||
return throwError(() => new Error(`[${traceId}] Not Modified`)).pipe(delay(10));
|
||||
}
|
||||
|
||||
return of({ ...this.mockSpec, traceId }).pipe(delay(50));
|
||||
}
|
||||
|
||||
getGatewayInfo(_options: OpenApiQueryOptions = {}): Observable<GatewayInfo> {
|
||||
return of({ ...this.mockGatewayInfo }).pipe(delay(30));
|
||||
}
|
||||
|
||||
getGatewayHealth(options: OpenApiQueryOptions = {}): Observable<GatewayHealthCheck> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
status: 'healthy' as const,
|
||||
checks: [
|
||||
{ name: 'database', status: 'healthy' as const, latencyMs: 5 },
|
||||
{ name: 'cache', status: 'healthy' as const, latencyMs: 2 },
|
||||
{ name: 'upstream', status: 'healthy' as const, latencyMs: 15 },
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
getDeprecatedRoutes(options: OpenApiQueryOptions = {}): Observable<DeprecatedRoutesResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({ ...this.mockDeprecatedRoutes, traceId }).pipe(delay(30));
|
||||
}
|
||||
|
||||
checkIdempotencyKey(key: string, _options: OpenApiQueryOptions = {}): Observable<IdempotencyResponse> {
|
||||
return of({
|
||||
idempotencyKey: key,
|
||||
status: 'accepted' as const,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
}).pipe(delay(30));
|
||||
}
|
||||
|
||||
getRateLimitInfo(_options: OpenApiQueryOptions = {}): Observable<RateLimitInfo> {
|
||||
return of({
|
||||
limit: 1000,
|
||||
remaining: 950,
|
||||
reset: Math.floor(Date.now() / 1000) + 3600,
|
||||
}).pipe(delay(20));
|
||||
}
|
||||
}
|
||||
138
src/Web/StellaOps.Web/src/app/core/api/gateway-openapi.models.ts
Normal file
138
src/Web/StellaOps.Web/src/app/core/api/gateway-openapi.models.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Gateway OpenAPI Models.
|
||||
* Implements WEB-OAS-61-001, WEB-OAS-61-002, WEB-OAS-62-001, WEB-OAS-63-001.
|
||||
*/
|
||||
|
||||
/** OpenAPI spec version info. */
|
||||
export interface OpenApiVersionInfo {
|
||||
readonly specVersion: string;
|
||||
readonly gatewayVersion: string;
|
||||
readonly buildTimestamp: string;
|
||||
readonly gitCommit?: string;
|
||||
}
|
||||
|
||||
/** OpenAPI spec response. */
|
||||
export interface OpenApiSpecResponse {
|
||||
readonly openapi: string;
|
||||
readonly info: {
|
||||
readonly title: string;
|
||||
readonly version: string;
|
||||
readonly description?: string;
|
||||
};
|
||||
readonly paths: Record<string, unknown>;
|
||||
readonly components?: Record<string, unknown>;
|
||||
readonly etag: string;
|
||||
readonly versionInfo: OpenApiVersionInfo;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Standard error envelope. */
|
||||
export interface GatewayErrorEnvelope {
|
||||
readonly error: {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly details?: readonly GatewayErrorDetail[];
|
||||
readonly traceId: string;
|
||||
readonly timestamp: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Error detail. */
|
||||
export interface GatewayErrorDetail {
|
||||
readonly field?: string;
|
||||
readonly reason: string;
|
||||
readonly value?: string;
|
||||
}
|
||||
|
||||
/** Rate limit info. */
|
||||
export interface RateLimitInfo {
|
||||
readonly limit: number;
|
||||
readonly remaining: number;
|
||||
readonly reset: number;
|
||||
readonly retryAfter?: number;
|
||||
}
|
||||
|
||||
/** Pagination cursor. */
|
||||
export interface PaginationCursor {
|
||||
readonly pageToken?: string | null;
|
||||
readonly pageSize?: number;
|
||||
readonly hasMore?: boolean;
|
||||
readonly total?: number;
|
||||
}
|
||||
|
||||
/** Idempotency status. */
|
||||
export type IdempotencyStatus = 'accepted' | 'duplicate' | 'expired';
|
||||
|
||||
/** Idempotency response. */
|
||||
export interface IdempotencyResponse {
|
||||
readonly idempotencyKey: string;
|
||||
readonly status: IdempotencyStatus;
|
||||
readonly originalRequestId?: string;
|
||||
readonly expiresAt: string;
|
||||
}
|
||||
|
||||
/** Deprecation info. */
|
||||
export interface DeprecationInfo {
|
||||
readonly deprecated: boolean;
|
||||
readonly sunsetAt?: string;
|
||||
readonly replacedBy?: string;
|
||||
readonly migrationGuide?: string;
|
||||
}
|
||||
|
||||
/** Deprecated route. */
|
||||
export interface DeprecatedRoute {
|
||||
readonly path: string;
|
||||
readonly method: string;
|
||||
readonly deprecation: DeprecationInfo;
|
||||
}
|
||||
|
||||
/** Deprecated routes response. */
|
||||
export interface DeprecatedRoutesResponse {
|
||||
readonly items: readonly DeprecatedRoute[];
|
||||
readonly total: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Gateway info. */
|
||||
export interface GatewayInfo {
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly environment: string;
|
||||
readonly region?: string;
|
||||
readonly features: readonly string[];
|
||||
readonly uptime?: number;
|
||||
}
|
||||
|
||||
/** Gateway health status. */
|
||||
export type GatewayHealthStatus = 'healthy' | 'degraded' | 'unhealthy';
|
||||
|
||||
/** Gateway health check. */
|
||||
export interface GatewayHealthCheck {
|
||||
readonly status: GatewayHealthStatus;
|
||||
readonly checks: readonly {
|
||||
readonly name: string;
|
||||
readonly status: GatewayHealthStatus;
|
||||
readonly message?: string;
|
||||
readonly latencyMs?: number;
|
||||
}[];
|
||||
readonly timestamp: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** OpenAPI query options. */
|
||||
export interface OpenApiQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly traceId?: string;
|
||||
readonly ifNoneMatch?: string;
|
||||
}
|
||||
|
||||
/** Gateway error codes. */
|
||||
export type GatewayErrorCode =
|
||||
| 'ERR_GATEWAY_UNAUTHORIZED'
|
||||
| 'ERR_GATEWAY_FORBIDDEN'
|
||||
| 'ERR_GATEWAY_NOT_FOUND'
|
||||
| 'ERR_GATEWAY_RATE_LIMIT'
|
||||
| 'ERR_GATEWAY_VALIDATION'
|
||||
| 'ERR_GATEWAY_IDEMPOTENCY'
|
||||
| 'ERR_GATEWAY_UPSTREAM'
|
||||
| 'ERR_GATEWAY_TIMEOUT';
|
||||
448
src/Web/StellaOps.Web/src/app/core/api/graph-platform.client.ts
Normal file
448
src/Web/StellaOps.Web/src/app/core/api/graph-platform.client.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { map, catchError, delay } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import {
|
||||
GraphMetadata,
|
||||
GraphListResponse,
|
||||
GraphTileResponse,
|
||||
GraphQueryOptions,
|
||||
TileQueryOptions,
|
||||
GraphSearchOptions,
|
||||
GraphSearchResponse,
|
||||
PathFindOptions,
|
||||
PathFindResponse,
|
||||
GraphExportOptions,
|
||||
GraphExportResponse,
|
||||
AssetSnapshot,
|
||||
AdjacencyResponse,
|
||||
GraphBuildStatus,
|
||||
GraphNodeKind,
|
||||
GraphSeverity,
|
||||
GraphReachability,
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
} from './graph-platform.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
export const GRAPH_API_BASE_URL = new InjectionToken<string>('GRAPH_API_BASE_URL');
|
||||
|
||||
/**
|
||||
* Graph Platform API interface.
|
||||
* Implements WEB-GRAPH-SPEC-21-000 through WEB-GRAPH-24-004.
|
||||
*/
|
||||
export interface GraphPlatformApi {
|
||||
/** List available graphs. */
|
||||
listGraphs(options?: GraphQueryOptions): Observable<GraphListResponse>;
|
||||
|
||||
/** Get graph metadata. */
|
||||
getGraph(graphId: string, options?: GraphQueryOptions): Observable<GraphMetadata>;
|
||||
|
||||
/** Get graph tile with nodes, edges, and overlays. */
|
||||
getTile(graphId: string, options?: TileQueryOptions): Observable<GraphTileResponse>;
|
||||
|
||||
/** Search graph nodes. */
|
||||
search(options: GraphSearchOptions): Observable<GraphSearchResponse>;
|
||||
|
||||
/** Find paths between nodes. */
|
||||
findPath(options: PathFindOptions): Observable<PathFindResponse>;
|
||||
|
||||
/** Export graph in various formats. */
|
||||
exportGraph(graphId: string, options: GraphExportOptions): Observable<GraphExportResponse>;
|
||||
|
||||
/** Get asset snapshot. */
|
||||
getAssetSnapshot(assetId: string, options?: GraphQueryOptions): Observable<AssetSnapshot>;
|
||||
|
||||
/** Get node adjacency. */
|
||||
getAdjacency(nodeId: string, options?: GraphQueryOptions): Observable<AdjacencyResponse>;
|
||||
}
|
||||
|
||||
export const GRAPH_PLATFORM_API = new InjectionToken<GraphPlatformApi>('GRAPH_PLATFORM_API');
|
||||
|
||||
/**
|
||||
* HTTP Graph Platform Client.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GraphPlatformHttpClient implements GraphPlatformApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly authSession: AuthSessionStore,
|
||||
private readonly tenantService: TenantActivationService,
|
||||
@Inject(GRAPH_API_BASE_URL) private readonly baseUrl: string
|
||||
) {}
|
||||
|
||||
listGraphs(options: GraphQueryOptions = {}): Observable<GraphListResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
let params = new HttpParams();
|
||||
if (options.pageToken) params = params.set('pageToken', options.pageToken);
|
||||
if (options.pageSize) params = params.set('pageSize', String(options.pageSize));
|
||||
if (options.status) params = params.set('status', options.status);
|
||||
|
||||
return this.http.get<GraphListResponse>(`${this.baseUrl}/graphs`, { headers, params }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getGraph(graphId: string, options: GraphQueryOptions = {}): Observable<GraphMetadata> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
return this.http.get<GraphMetadata>(
|
||||
`${this.baseUrl}/graphs/${encodeURIComponent(graphId)}`,
|
||||
{ headers }
|
||||
).pipe(
|
||||
map((response) => ({ ...response })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getTile(graphId: string, options: TileQueryOptions = {}): Observable<GraphTileResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
let params = new HttpParams();
|
||||
if (options.bbox) {
|
||||
params = params.set('bbox', `${options.bbox.minX},${options.bbox.minY},${options.bbox.maxX},${options.bbox.maxY}`);
|
||||
}
|
||||
if (options.zoom !== undefined) params = params.set('zoom', String(options.zoom));
|
||||
if (options.path) params = params.set('path', options.path);
|
||||
if (options.includeOverlays !== undefined) params = params.set('includeOverlays', String(options.includeOverlays));
|
||||
|
||||
return this.http.get<GraphTileResponse>(
|
||||
`${this.baseUrl}/graphs/${encodeURIComponent(graphId)}/tiles`,
|
||||
{ headers, params }
|
||||
).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
search(options: GraphSearchOptions): Observable<GraphSearchResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
let params = new HttpParams().set('q', options.query);
|
||||
if (options.pageToken) params = params.set('pageToken', options.pageToken);
|
||||
if (options.pageSize) params = params.set('pageSize', String(options.pageSize));
|
||||
if (options.kinds?.length) params = params.set('kinds', options.kinds.join(','));
|
||||
if (options.severity?.length) params = params.set('severity', options.severity.join(','));
|
||||
if (options.reachability?.length) params = params.set('reachability', options.reachability.join(','));
|
||||
if (options.graphId) params = params.set('graphId', options.graphId);
|
||||
|
||||
return this.http.get<GraphSearchResponse>(`${this.baseUrl}/search`, { headers, params }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
findPath(options: PathFindOptions): Observable<PathFindResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
let params = new HttpParams()
|
||||
.set('source', options.sourceId)
|
||||
.set('target', options.targetId);
|
||||
if (options.maxDepth) params = params.set('maxDepth', String(options.maxDepth));
|
||||
if (options.includeEvidence !== undefined) params = params.set('includeEvidence', String(options.includeEvidence));
|
||||
if (options.graphId) params = params.set('graphId', options.graphId);
|
||||
|
||||
return this.http.get<PathFindResponse>(`${this.baseUrl}/paths`, { headers, params }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
exportGraph(graphId: string, options: GraphExportOptions): Observable<GraphExportResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('graph', 'read', ['graph:read', 'graph:export'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing graph:export scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
let params = new HttpParams().set('format', options.format);
|
||||
if (options.bbox) {
|
||||
params = params.set('bbox', `${options.bbox.minX},${options.bbox.minY},${options.bbox.maxX},${options.bbox.maxY}`);
|
||||
}
|
||||
if (options.includeOverlays !== undefined) params = params.set('includeOverlays', String(options.includeOverlays));
|
||||
|
||||
return this.http.get<GraphExportResponse>(
|
||||
`${this.baseUrl}/graphs/${encodeURIComponent(graphId)}/export`,
|
||||
{ headers, params }
|
||||
).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getAssetSnapshot(assetId: string, options: GraphQueryOptions = {}): Observable<AssetSnapshot> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
return this.http.get<AssetSnapshot>(
|
||||
`${this.baseUrl}/assets/${encodeURIComponent(assetId)}/snapshot`,
|
||||
{ headers }
|
||||
).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getAdjacency(nodeId: string, options: GraphQueryOptions = {}): Observable<AdjacencyResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(options);
|
||||
let params = new HttpParams();
|
||||
if (options.graphId) params = params.set('graphId', options.graphId);
|
||||
|
||||
return this.http.get<AdjacencyResponse>(
|
||||
`${this.baseUrl}/nodes/${encodeURIComponent(nodeId)}/adjacency`,
|
||||
{ headers, params }
|
||||
).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(opts: { tenantId?: string; traceId?: string; ifNoneMatch?: string }): HttpHeaders {
|
||||
const tenant = this.resolveTenant(opts.tenantId);
|
||||
const trace = opts.traceId ?? generateTraceId();
|
||||
|
||||
let headers = new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': trace,
|
||||
'X-Stella-Request-Id': trace,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
|
||||
if (opts.ifNoneMatch) {
|
||||
headers = headers.set('If-None-Match', opts.ifNoneMatch);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private resolveTenant(tenantId?: string): string {
|
||||
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||
if (!tenant) {
|
||||
throw new Error('GraphPlatformClient requires an active tenant identifier.');
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private mapError(err: unknown, traceId: string): Error {
|
||||
if (err instanceof Error) {
|
||||
return new Error(`[${traceId}] Graph Platform error: ${err.message}`);
|
||||
}
|
||||
return new Error(`[${traceId}] Graph Platform error: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Graph Platform API for quickstart mode.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockGraphPlatformClient implements GraphPlatformApi {
|
||||
private readonly mockGraphs: GraphMetadata[] = [
|
||||
{
|
||||
graphId: 'graph::tenant-default::main',
|
||||
tenantId: 'tenant-default',
|
||||
name: 'Main Dependency Graph',
|
||||
description: 'Primary dependency graph for all projects',
|
||||
status: 'ready',
|
||||
nodeCount: 1250,
|
||||
edgeCount: 3400,
|
||||
snapshotAt: '2025-12-10T06:00:00Z',
|
||||
createdAt: '2025-10-01T00:00:00Z',
|
||||
updatedAt: '2025-12-10T06:00:00Z',
|
||||
etag: '"graph-main-v1"',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly mockNodes: GraphNode[] = [
|
||||
{ id: 'asset::registry.local/ops/auth', kind: 'asset', label: 'auth-service', severity: 'high', reachability: 'reachable' },
|
||||
{ id: 'component::pkg:npm/jsonwebtoken@9.0.2', kind: 'component', label: 'jsonwebtoken@9.0.2', severity: 'high', reachability: 'reachable' },
|
||||
{ id: 'vuln::CVE-2024-12345', kind: 'vuln', label: 'CVE-2024-12345', severity: 'high' },
|
||||
{ id: 'asset::registry.local/ops/transform', kind: 'asset', label: 'transform-service', severity: 'critical', reachability: 'reachable' },
|
||||
{ id: 'component::pkg:npm/lodash@4.17.20', kind: 'component', label: 'lodash@4.17.20', severity: 'critical', reachability: 'reachable' },
|
||||
{ id: 'vuln::CVE-2024-67890', kind: 'vuln', label: 'CVE-2024-67890', severity: 'critical' },
|
||||
];
|
||||
|
||||
private readonly mockEdges: GraphEdge[] = [
|
||||
{ id: 'edge-1', source: 'asset::registry.local/ops/auth', target: 'component::pkg:npm/jsonwebtoken@9.0.2', type: 'contains' },
|
||||
{ id: 'edge-2', source: 'component::pkg:npm/jsonwebtoken@9.0.2', target: 'vuln::CVE-2024-12345', type: 'affects' },
|
||||
{ id: 'edge-3', source: 'asset::registry.local/ops/transform', target: 'component::pkg:npm/lodash@4.17.20', type: 'contains' },
|
||||
{ id: 'edge-4', source: 'component::pkg:npm/lodash@4.17.20', target: 'vuln::CVE-2024-67890', type: 'affects' },
|
||||
];
|
||||
|
||||
listGraphs(options: GraphQueryOptions = {}): Observable<GraphListResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
let filtered = [...this.mockGraphs];
|
||||
if (options.status) {
|
||||
filtered = filtered.filter((g) => g.status === options.status);
|
||||
}
|
||||
return of({ items: filtered, total: filtered.length, traceId }).pipe(delay(50));
|
||||
}
|
||||
|
||||
getGraph(graphId: string, options: GraphQueryOptions = {}): Observable<GraphMetadata> {
|
||||
const graph = this.mockGraphs.find((g) => g.graphId === graphId);
|
||||
if (!graph) {
|
||||
return throwError(() => new Error(`Graph ${graphId} not found`));
|
||||
}
|
||||
return of(graph).pipe(delay(30));
|
||||
}
|
||||
|
||||
getTile(graphId: string, options: TileQueryOptions = {}): Observable<GraphTileResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
return of({
|
||||
version: '2025-12-06',
|
||||
tenantId: 'tenant-default',
|
||||
tile: {
|
||||
id: `graph-tile::${graphId}::z${options.zoom ?? 8}`,
|
||||
zoom: options.zoom ?? 8,
|
||||
etag: '"tile-v1"',
|
||||
},
|
||||
nodes: this.mockNodes,
|
||||
edges: this.mockEdges,
|
||||
overlays: options.includeOverlays ? {
|
||||
policy: [
|
||||
{ nodeId: 'component::pkg:npm/jsonwebtoken@9.0.2', badge: 'fail', policyId: 'policy://tenant-default/runtime', verdictAt: '2025-12-10T06:00:00Z' },
|
||||
{ nodeId: 'component::pkg:npm/lodash@4.17.20', badge: 'fail', policyId: 'policy://tenant-default/runtime', verdictAt: '2025-12-10T06:00:00Z' },
|
||||
],
|
||||
vex: [
|
||||
{ nodeId: 'vuln::CVE-2024-12345', state: 'under_investigation', statementId: 'vex:tenant-default:jwt-auth:5d1a', lastUpdated: '2025-12-10T06:00:00Z' },
|
||||
{ nodeId: 'vuln::CVE-2024-67890', state: 'affected', statementId: 'vex:tenant-default:data-transform:9bf4', lastUpdated: '2025-12-10T06:00:00Z' },
|
||||
],
|
||||
aoc: [],
|
||||
} : undefined,
|
||||
telemetry: { generationMs: 45, cache: 'miss', samples: this.mockNodes.length },
|
||||
traceId,
|
||||
etag: '"tile-response-v1"',
|
||||
}).pipe(delay(75));
|
||||
}
|
||||
|
||||
search(options: GraphSearchOptions): Observable<GraphSearchResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const query = options.query.toLowerCase();
|
||||
|
||||
const results = this.mockNodes
|
||||
.filter((n) => n.label.toLowerCase().includes(query) || n.id.toLowerCase().includes(query))
|
||||
.filter((n) => !options.kinds?.length || options.kinds.includes(n.kind))
|
||||
.filter((n) => !options.severity?.length || (n.severity && options.severity.includes(n.severity)))
|
||||
.filter((n) => !options.reachability?.length || (n.reachability && options.reachability.includes(n.reachability)))
|
||||
.map((n, i) => ({
|
||||
nodeId: n.id,
|
||||
kind: n.kind,
|
||||
label: n.label,
|
||||
score: 1 - i * 0.1,
|
||||
severity: n.severity,
|
||||
reachability: n.reachability,
|
||||
highlights: [n.label],
|
||||
}));
|
||||
|
||||
return of({ items: results, total: results.length, traceId }).pipe(delay(50));
|
||||
}
|
||||
|
||||
findPath(options: PathFindOptions): Observable<PathFindResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
// Simplified path finding for mock
|
||||
const sourceNode = this.mockNodes.find((n) => n.id === options.sourceId);
|
||||
const targetNode = this.mockNodes.find((n) => n.id === options.targetId);
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
return of({ paths: [], totalPaths: 0, traceId }).pipe(delay(30));
|
||||
}
|
||||
|
||||
// Check if there's a direct edge
|
||||
const directEdge = this.mockEdges.find((e) => e.source === options.sourceId && e.target === options.targetId);
|
||||
if (directEdge) {
|
||||
return of({
|
||||
paths: [[
|
||||
{ node: sourceNode, depth: 0 },
|
||||
{ node: targetNode, edge: directEdge, depth: 1 },
|
||||
]],
|
||||
shortestLength: 1,
|
||||
totalPaths: 1,
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
return of({ paths: [], totalPaths: 0, traceId }).pipe(delay(30));
|
||||
}
|
||||
|
||||
exportGraph(graphId: string, options: GraphExportOptions): Observable<GraphExportResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const exportId = `graph-export::${graphId}::${Date.now()}`;
|
||||
|
||||
return of({
|
||||
exportId,
|
||||
format: options.format,
|
||||
url: `https://exports.local/graphs/${graphId}/export.${options.format}?sig=mock`,
|
||||
sha256: 'sha256:graphexport1234',
|
||||
size: 1024 * 100,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
traceId,
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
|
||||
getAssetSnapshot(assetId: string, options: GraphQueryOptions = {}): Observable<AssetSnapshot> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
return of({
|
||||
assetId,
|
||||
name: assetId.split('::').pop() ?? assetId,
|
||||
kind: 'container',
|
||||
components: ['pkg:npm/jsonwebtoken@9.0.2', 'pkg:npm/express@4.18.1'],
|
||||
vulnerabilities: ['CVE-2024-12345'],
|
||||
snapshotAt: new Date().toISOString(),
|
||||
traceId,
|
||||
}).pipe(delay(30));
|
||||
}
|
||||
|
||||
getAdjacency(nodeId: string, options: GraphQueryOptions = {}): Observable<AdjacencyResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
const incoming = this.mockEdges.filter((e) => e.target === nodeId).map((e) => ({ nodeId: e.source, edgeType: e.type }));
|
||||
const outgoing = this.mockEdges.filter((e) => e.source === nodeId).map((e) => ({ nodeId: e.target, edgeType: e.type }));
|
||||
|
||||
return of({ nodeId, incoming, outgoing, traceId }).pipe(delay(30));
|
||||
}
|
||||
}
|
||||
256
src/Web/StellaOps.Web/src/app/core/api/graph-platform.models.ts
Normal file
256
src/Web/StellaOps.Web/src/app/core/api/graph-platform.models.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Graph Platform Models.
|
||||
* Implements WEB-GRAPH-SPEC-21-000 through WEB-GRAPH-24-004.
|
||||
*/
|
||||
|
||||
/** Graph build status. */
|
||||
export type GraphBuildStatus = 'pending' | 'building' | 'ready' | 'failed' | 'expired';
|
||||
|
||||
/** Node kind. */
|
||||
export type GraphNodeKind = 'asset' | 'component' | 'vuln' | 'advisory' | 'policy' | 'evidence';
|
||||
|
||||
/** Severity level. */
|
||||
export type GraphSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
|
||||
|
||||
/** Reachability status. */
|
||||
export type GraphReachability = 'reachable' | 'unreachable' | 'unknown';
|
||||
|
||||
/** Edge type. */
|
||||
export type GraphEdgeType = 'depends_on' | 'contains' | 'evidence' | 'affects' | 'mitigates';
|
||||
|
||||
/** Policy badge. */
|
||||
export type GraphPolicyBadge = 'pass' | 'warn' | 'fail' | 'waived';
|
||||
|
||||
/** VEX state. */
|
||||
export type GraphVexState = 'not_affected' | 'fixed' | 'under_investigation' | 'affected';
|
||||
|
||||
/** AOC status. */
|
||||
export type GraphAocStatus = 'pass' | 'fail' | 'warn' | 'pending';
|
||||
|
||||
/** Graph metadata. */
|
||||
export interface GraphMetadata {
|
||||
readonly graphId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly status: GraphBuildStatus;
|
||||
readonly nodeCount?: number;
|
||||
readonly edgeCount?: number;
|
||||
readonly snapshotAt?: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
readonly etag?: string;
|
||||
}
|
||||
|
||||
/** Graph list response. */
|
||||
export interface GraphListResponse {
|
||||
readonly items: readonly GraphMetadata[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Graph node. */
|
||||
export interface GraphNode {
|
||||
readonly id: string;
|
||||
readonly kind: GraphNodeKind;
|
||||
readonly label: string;
|
||||
readonly severity?: GraphSeverity;
|
||||
readonly reachability?: GraphReachability;
|
||||
readonly attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Graph edge. */
|
||||
export interface GraphEdge {
|
||||
readonly id: string;
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly type: GraphEdgeType;
|
||||
readonly weight?: number;
|
||||
readonly attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Policy overlay. */
|
||||
export interface PolicyOverlay {
|
||||
readonly nodeId: string;
|
||||
readonly badge: GraphPolicyBadge;
|
||||
readonly policyId: string;
|
||||
readonly verdictAt?: string;
|
||||
}
|
||||
|
||||
/** VEX overlay. */
|
||||
export interface VexOverlay {
|
||||
readonly nodeId: string;
|
||||
readonly state: GraphVexState;
|
||||
readonly statementId: string;
|
||||
readonly lastUpdated?: string;
|
||||
}
|
||||
|
||||
/** AOC overlay. */
|
||||
export interface AocOverlay {
|
||||
readonly nodeId: string;
|
||||
readonly status: GraphAocStatus;
|
||||
readonly lastVerified?: string;
|
||||
}
|
||||
|
||||
/** Graph overlays. */
|
||||
export interface GraphOverlays {
|
||||
readonly policy?: readonly PolicyOverlay[];
|
||||
readonly vex?: readonly VexOverlay[];
|
||||
readonly aoc?: readonly AocOverlay[];
|
||||
}
|
||||
|
||||
/** Tile bounding box. */
|
||||
export interface TileBbox {
|
||||
readonly minX: number;
|
||||
readonly minY: number;
|
||||
readonly maxX: number;
|
||||
readonly maxY: number;
|
||||
}
|
||||
|
||||
/** Tile metadata. */
|
||||
export interface TileMetadata {
|
||||
readonly id: string;
|
||||
readonly bbox?: TileBbox;
|
||||
readonly zoom?: number;
|
||||
readonly etag?: string;
|
||||
}
|
||||
|
||||
/** Graph tile telemetry. */
|
||||
export interface TileTelemetry {
|
||||
readonly generationMs?: number;
|
||||
readonly cache?: 'hit' | 'miss';
|
||||
readonly samples?: number;
|
||||
}
|
||||
|
||||
/** Graph tile response. */
|
||||
export interface GraphTileResponse {
|
||||
readonly version: string;
|
||||
readonly tenantId: string;
|
||||
readonly tile: TileMetadata;
|
||||
readonly nodes: readonly GraphNode[];
|
||||
readonly edges: readonly GraphEdge[];
|
||||
readonly overlays?: GraphOverlays;
|
||||
readonly telemetry?: TileTelemetry;
|
||||
readonly traceId?: string;
|
||||
readonly etag?: string;
|
||||
}
|
||||
|
||||
/** Graph query options. */
|
||||
export interface GraphQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly graphId?: string;
|
||||
readonly pageToken?: string;
|
||||
readonly pageSize?: number;
|
||||
readonly status?: GraphBuildStatus;
|
||||
readonly traceId?: string;
|
||||
readonly ifNoneMatch?: string;
|
||||
}
|
||||
|
||||
/** Tile query options. */
|
||||
export interface TileQueryOptions extends GraphQueryOptions {
|
||||
readonly bbox?: TileBbox;
|
||||
readonly zoom?: number;
|
||||
readonly path?: string;
|
||||
readonly includeOverlays?: boolean;
|
||||
}
|
||||
|
||||
/** Search query options. */
|
||||
export interface GraphSearchOptions extends GraphQueryOptions {
|
||||
readonly query: string;
|
||||
readonly kinds?: readonly GraphNodeKind[];
|
||||
readonly severity?: readonly GraphSeverity[];
|
||||
readonly reachability?: readonly GraphReachability[];
|
||||
}
|
||||
|
||||
/** Search result. */
|
||||
export interface GraphSearchResult {
|
||||
readonly nodeId: string;
|
||||
readonly kind: GraphNodeKind;
|
||||
readonly label: string;
|
||||
readonly score: number;
|
||||
readonly severity?: GraphSeverity;
|
||||
readonly reachability?: GraphReachability;
|
||||
readonly highlights?: readonly string[];
|
||||
}
|
||||
|
||||
/** Search response. */
|
||||
export interface GraphSearchResponse {
|
||||
readonly items: readonly GraphSearchResult[];
|
||||
readonly nextPageToken?: string | null;
|
||||
readonly total?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Path finding options. */
|
||||
export interface PathFindOptions extends GraphQueryOptions {
|
||||
readonly sourceId: string;
|
||||
readonly targetId: string;
|
||||
readonly maxDepth?: number;
|
||||
readonly includeEvidence?: boolean;
|
||||
}
|
||||
|
||||
/** Path step. */
|
||||
export interface PathStep {
|
||||
readonly node: GraphNode;
|
||||
readonly edge?: GraphEdge;
|
||||
readonly depth: number;
|
||||
}
|
||||
|
||||
/** Path finding response. */
|
||||
export interface PathFindResponse {
|
||||
readonly paths: readonly (readonly PathStep[])[];
|
||||
readonly shortestLength?: number;
|
||||
readonly totalPaths?: number;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Export format. */
|
||||
export type GraphExportFormat = 'ndjson' | 'csv' | 'graphml' | 'png' | 'svg';
|
||||
|
||||
/** Graph export options. */
|
||||
export interface GraphExportOptions extends GraphQueryOptions {
|
||||
readonly format: GraphExportFormat;
|
||||
readonly bbox?: TileBbox;
|
||||
readonly includeOverlays?: boolean;
|
||||
}
|
||||
|
||||
/** Graph export response. */
|
||||
export interface GraphExportResponse {
|
||||
readonly exportId: string;
|
||||
readonly format: GraphExportFormat;
|
||||
readonly url: string;
|
||||
readonly sha256?: string;
|
||||
readonly size?: number;
|
||||
readonly expiresAt?: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Asset snapshot. */
|
||||
export interface AssetSnapshot {
|
||||
readonly assetId: string;
|
||||
readonly name: string;
|
||||
readonly kind: string;
|
||||
readonly components?: readonly string[];
|
||||
readonly vulnerabilities?: readonly string[];
|
||||
readonly snapshotAt: string;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Adjacency list response. */
|
||||
export interface AdjacencyResponse {
|
||||
readonly nodeId: string;
|
||||
readonly incoming: readonly { nodeId: string; edgeType: GraphEdgeType }[];
|
||||
readonly outgoing: readonly { nodeId: string; edgeType: GraphEdgeType }[];
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Graph error codes. */
|
||||
export type GraphErrorCode =
|
||||
| 'ERR_GRAPH_NOT_FOUND'
|
||||
| 'ERR_GRAPH_INVALID_BBOX'
|
||||
| 'ERR_GRAPH_INVALID_ZOOM'
|
||||
| 'ERR_GRAPH_TOO_LARGE'
|
||||
| 'ERR_GRAPH_RATE_LIMIT'
|
||||
| 'ERR_GRAPH_EXPORT_FAILED';
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
InjectionToken,
|
||||
Optional,
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { map, catchError, delay } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import {
|
||||
ChannelHealthResponse,
|
||||
ChannelTestSendRequest,
|
||||
@@ -15,9 +18,28 @@ import {
|
||||
NotifyDeliveriesQueryOptions,
|
||||
NotifyDeliveriesResponse,
|
||||
NotifyRule,
|
||||
DigestSchedule,
|
||||
DigestSchedulesResponse,
|
||||
QuietHours,
|
||||
QuietHoursResponse,
|
||||
ThrottleConfig,
|
||||
ThrottleConfigsResponse,
|
||||
NotifySimulationRequest,
|
||||
NotifySimulationResult,
|
||||
EscalationPolicy,
|
||||
EscalationPoliciesResponse,
|
||||
LocalizationConfig,
|
||||
LocalizationConfigsResponse,
|
||||
NotifyIncident,
|
||||
NotifyIncidentsResponse,
|
||||
AckRequest,
|
||||
AckResponse,
|
||||
NotifyQueryOptions,
|
||||
} from './notify.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
export interface NotifyApi {
|
||||
// WEB-NOTIFY-38-001: Base notification APIs
|
||||
listChannels(): Observable<NotifyChannel[]>;
|
||||
saveChannel(channel: NotifyChannel): Observable<NotifyChannel>;
|
||||
deleteChannel(channelId: string): Observable<void>;
|
||||
@@ -32,6 +54,29 @@ export interface NotifyApi {
|
||||
listDeliveries(
|
||||
options?: NotifyDeliveriesQueryOptions
|
||||
): Observable<NotifyDeliveriesResponse>;
|
||||
|
||||
// WEB-NOTIFY-39-001: Digest scheduling, quiet-hours, throttle management
|
||||
listDigestSchedules(options?: NotifyQueryOptions): Observable<DigestSchedulesResponse>;
|
||||
saveDigestSchedule(schedule: DigestSchedule): Observable<DigestSchedule>;
|
||||
deleteDigestSchedule(scheduleId: string): Observable<void>;
|
||||
listQuietHours(options?: NotifyQueryOptions): Observable<QuietHoursResponse>;
|
||||
saveQuietHours(quietHours: QuietHours): Observable<QuietHours>;
|
||||
deleteQuietHours(quietHoursId: string): Observable<void>;
|
||||
listThrottleConfigs(options?: NotifyQueryOptions): Observable<ThrottleConfigsResponse>;
|
||||
saveThrottleConfig(config: ThrottleConfig): Observable<ThrottleConfig>;
|
||||
deleteThrottleConfig(throttleId: string): Observable<void>;
|
||||
simulateNotification(request: NotifySimulationRequest, options?: NotifyQueryOptions): Observable<NotifySimulationResult>;
|
||||
|
||||
// WEB-NOTIFY-40-001: Escalation, localization, channel health, ack verification
|
||||
listEscalationPolicies(options?: NotifyQueryOptions): Observable<EscalationPoliciesResponse>;
|
||||
saveEscalationPolicy(policy: EscalationPolicy): Observable<EscalationPolicy>;
|
||||
deleteEscalationPolicy(policyId: string): Observable<void>;
|
||||
listLocalizations(options?: NotifyQueryOptions): Observable<LocalizationConfigsResponse>;
|
||||
saveLocalization(config: LocalizationConfig): Observable<LocalizationConfig>;
|
||||
deleteLocalization(localeId: string): Observable<void>;
|
||||
listIncidents(options?: NotifyQueryOptions): Observable<NotifyIncidentsResponse>;
|
||||
getIncident(incidentId: string, options?: NotifyQueryOptions): Observable<NotifyIncident>;
|
||||
acknowledgeIncident(incidentId: string, request: AckRequest, options?: NotifyQueryOptions): Observable<AckResponse>;
|
||||
}
|
||||
|
||||
export const NOTIFY_API = new InjectionToken<NotifyApi>('NOTIFY_API');
|
||||
@@ -42,10 +87,16 @@ export const NOTIFY_API_BASE_URL = new InjectionToken<string>(
|
||||
|
||||
export const NOTIFY_TENANT_ID = new InjectionToken<string>('NOTIFY_TENANT_ID');
|
||||
|
||||
/**
|
||||
* HTTP Notify Client.
|
||||
* Implements WEB-NOTIFY-38-001, WEB-NOTIFY-39-001, WEB-NOTIFY-40-001.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NotifyApiHttpClient implements NotifyApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly authSession: AuthSessionStore,
|
||||
private readonly tenantService: TenantActivationService,
|
||||
@Inject(NOTIFY_API_BASE_URL) private readonly baseUrl: string,
|
||||
@Optional() @Inject(NOTIFY_TENANT_ID) private readonly tenantId: string | null
|
||||
) {}
|
||||
@@ -131,6 +182,185 @@ export class NotifyApiHttpClient implements NotifyApi {
|
||||
});
|
||||
}
|
||||
|
||||
// WEB-NOTIFY-39-001: Digest scheduling
|
||||
listDigestSchedules(options: NotifyQueryOptions = {}): Observable<DigestSchedulesResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
const params = this.buildPaginationParams(options);
|
||||
|
||||
return this.http.get<DigestSchedulesResponse>(`${this.baseUrl}/digest-schedules`, { headers, params }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
saveDigestSchedule(schedule: DigestSchedule): Observable<DigestSchedule> {
|
||||
const traceId = generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
|
||||
return this.http.post<DigestSchedule>(`${this.baseUrl}/digest-schedules`, schedule, { headers }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
deleteDigestSchedule(scheduleId: string): Observable<void> {
|
||||
const headers = this.buildHeaders();
|
||||
return this.http.delete<void>(`${this.baseUrl}/digest-schedules/${encodeURIComponent(scheduleId)}`, { headers });
|
||||
}
|
||||
|
||||
// WEB-NOTIFY-39-001: Quiet hours
|
||||
listQuietHours(options: NotifyQueryOptions = {}): Observable<QuietHoursResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
const params = this.buildPaginationParams(options);
|
||||
|
||||
return this.http.get<QuietHoursResponse>(`${this.baseUrl}/quiet-hours`, { headers, params }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
saveQuietHours(quietHours: QuietHours): Observable<QuietHours> {
|
||||
const traceId = generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
|
||||
return this.http.post<QuietHours>(`${this.baseUrl}/quiet-hours`, quietHours, { headers }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
deleteQuietHours(quietHoursId: string): Observable<void> {
|
||||
const headers = this.buildHeaders();
|
||||
return this.http.delete<void>(`${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`, { headers });
|
||||
}
|
||||
|
||||
// WEB-NOTIFY-39-001: Throttle configs
|
||||
listThrottleConfigs(options: NotifyQueryOptions = {}): Observable<ThrottleConfigsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
const params = this.buildPaginationParams(options);
|
||||
|
||||
return this.http.get<ThrottleConfigsResponse>(`${this.baseUrl}/throttle-configs`, { headers, params }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
saveThrottleConfig(config: ThrottleConfig): Observable<ThrottleConfig> {
|
||||
const traceId = generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
|
||||
return this.http.post<ThrottleConfig>(`${this.baseUrl}/throttle-configs`, config, { headers }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
deleteThrottleConfig(throttleId: string): Observable<void> {
|
||||
const headers = this.buildHeaders();
|
||||
return this.http.delete<void>(`${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`, { headers });
|
||||
}
|
||||
|
||||
// WEB-NOTIFY-39-001: Simulation
|
||||
simulateNotification(request: NotifySimulationRequest, options: NotifyQueryOptions = {}): Observable<NotifySimulationResult> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
|
||||
return this.http.post<NotifySimulationResult>(`${this.baseUrl}/simulate`, request, { headers }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
// WEB-NOTIFY-40-001: Escalation policies
|
||||
listEscalationPolicies(options: NotifyQueryOptions = {}): Observable<EscalationPoliciesResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
const params = this.buildPaginationParams(options);
|
||||
|
||||
return this.http.get<EscalationPoliciesResponse>(`${this.baseUrl}/escalation-policies`, { headers, params }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
saveEscalationPolicy(policy: EscalationPolicy): Observable<EscalationPolicy> {
|
||||
const traceId = generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
|
||||
return this.http.post<EscalationPolicy>(`${this.baseUrl}/escalation-policies`, policy, { headers }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
deleteEscalationPolicy(policyId: string): Observable<void> {
|
||||
const headers = this.buildHeaders();
|
||||
return this.http.delete<void>(`${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`, { headers });
|
||||
}
|
||||
|
||||
// WEB-NOTIFY-40-001: Localization
|
||||
listLocalizations(options: NotifyQueryOptions = {}): Observable<LocalizationConfigsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
const params = this.buildPaginationParams(options);
|
||||
|
||||
return this.http.get<LocalizationConfigsResponse>(`${this.baseUrl}/localizations`, { headers, params }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
saveLocalization(config: LocalizationConfig): Observable<LocalizationConfig> {
|
||||
const traceId = generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
|
||||
return this.http.post<LocalizationConfig>(`${this.baseUrl}/localizations`, config, { headers }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
deleteLocalization(localeId: string): Observable<void> {
|
||||
const headers = this.buildHeaders();
|
||||
return this.http.delete<void>(`${this.baseUrl}/localizations/${encodeURIComponent(localeId)}`, { headers });
|
||||
}
|
||||
|
||||
// WEB-NOTIFY-40-001: Incidents and acknowledgment
|
||||
listIncidents(options: NotifyQueryOptions = {}): Observable<NotifyIncidentsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
const params = this.buildPaginationParams(options);
|
||||
|
||||
return this.http.get<NotifyIncidentsResponse>(`${this.baseUrl}/incidents`, { headers, params }).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getIncident(incidentId: string, options: NotifyQueryOptions = {}): Observable<NotifyIncident> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
|
||||
return this.http.get<NotifyIncident>(
|
||||
`${this.baseUrl}/incidents/${encodeURIComponent(incidentId)}`,
|
||||
{ headers }
|
||||
).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
acknowledgeIncident(incidentId: string, request: AckRequest, options: NotifyQueryOptions = {}): Observable<AckResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeadersWithTrace(traceId);
|
||||
|
||||
return this.http.post<AckResponse>(
|
||||
`${this.baseUrl}/incidents/${encodeURIComponent(incidentId)}/ack`,
|
||||
request,
|
||||
{ headers }
|
||||
).pipe(
|
||||
map((response) => ({ ...response, traceId })),
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(): HttpHeaders {
|
||||
if (!this.tenantId) {
|
||||
return new HttpHeaders();
|
||||
@@ -138,5 +368,356 @@ export class NotifyApiHttpClient implements NotifyApi {
|
||||
|
||||
return new HttpHeaders({ 'X-StellaOps-Tenant': this.tenantId });
|
||||
}
|
||||
|
||||
private buildHeadersWithTrace(traceId: string): HttpHeaders {
|
||||
const tenant = this.tenantId || this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
private buildPaginationParams(options: NotifyQueryOptions): HttpParams {
|
||||
let params = new HttpParams();
|
||||
if (options.pageToken) {
|
||||
params = params.set('pageToken', options.pageToken);
|
||||
}
|
||||
if (options.pageSize) {
|
||||
params = params.set('pageSize', String(options.pageSize));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
private mapError(err: unknown, traceId: string): Error {
|
||||
if (err instanceof Error) {
|
||||
return new Error(`[${traceId}] Notify error: ${err.message}`);
|
||||
}
|
||||
return new Error(`[${traceId}] Notify error: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Notify Client for quickstart mode.
|
||||
* Implements WEB-NOTIFY-38-001, WEB-NOTIFY-39-001, WEB-NOTIFY-40-001.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockNotifyClient implements NotifyApi {
|
||||
private readonly mockChannels: NotifyChannel[] = [
|
||||
{
|
||||
channelId: 'chn-soc-webhook',
|
||||
tenantId: 'tenant-default',
|
||||
name: 'SOC Webhook',
|
||||
displayName: 'Security Operations Center',
|
||||
type: 'Webhook',
|
||||
enabled: true,
|
||||
config: {
|
||||
secretRef: 'secret://notify/soc-webhook',
|
||||
endpoint: 'https://soc.example.com/webhooks/stellaops',
|
||||
},
|
||||
createdAt: '2025-10-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
channelId: 'chn-slack-dev',
|
||||
tenantId: 'tenant-default',
|
||||
name: 'Slack Dev',
|
||||
displayName: 'Development Team Slack',
|
||||
type: 'Slack',
|
||||
enabled: true,
|
||||
config: {
|
||||
secretRef: 'secret://notify/slack-dev',
|
||||
target: '#dev-alerts',
|
||||
},
|
||||
createdAt: '2025-10-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly mockRules: NotifyRule[] = [
|
||||
{
|
||||
ruleId: 'rule-critical-vulns',
|
||||
tenantId: 'tenant-default',
|
||||
name: 'Critical Vulnerabilities',
|
||||
enabled: true,
|
||||
match: { minSeverity: 'critical', kevOnly: true },
|
||||
actions: [
|
||||
{ actionId: 'act-soc', channel: 'chn-soc-webhook', digest: 'instant', enabled: true },
|
||||
],
|
||||
createdAt: '2025-10-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly mockDigestSchedules: DigestSchedule[] = [
|
||||
{
|
||||
scheduleId: 'digest-daily',
|
||||
tenantId: 'tenant-default',
|
||||
name: 'Daily Digest',
|
||||
frequency: 'daily',
|
||||
timezone: 'UTC',
|
||||
hour: 8,
|
||||
enabled: true,
|
||||
createdAt: '2025-10-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly mockQuietHours: QuietHours[] = [
|
||||
{
|
||||
quietHoursId: 'qh-default',
|
||||
tenantId: 'tenant-default',
|
||||
name: 'Weeknight Quiet',
|
||||
windows: [
|
||||
{ timezone: 'UTC', days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], start: '22:00', end: '06:00' },
|
||||
],
|
||||
exemptions: [
|
||||
{ eventKinds: ['attestor.verification.failed'], reason: 'Always alert on attestation failures' },
|
||||
],
|
||||
enabled: true,
|
||||
createdAt: '2025-10-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly mockThrottleConfigs: ThrottleConfig[] = [
|
||||
{
|
||||
throttleId: 'throttle-default',
|
||||
tenantId: 'tenant-default',
|
||||
name: 'Default Throttle',
|
||||
windowSeconds: 60,
|
||||
maxEvents: 50,
|
||||
burstLimit: 100,
|
||||
enabled: true,
|
||||
createdAt: '2025-10-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly mockEscalationPolicies: EscalationPolicy[] = [
|
||||
{
|
||||
policyId: 'escalate-critical',
|
||||
tenantId: 'tenant-default',
|
||||
name: 'Critical Escalation',
|
||||
levels: [
|
||||
{ level: 1, delayMinutes: 0, channels: ['chn-soc-webhook'], notifyOnAck: false },
|
||||
{ level: 2, delayMinutes: 15, channels: ['chn-slack-dev'], notifyOnAck: true },
|
||||
],
|
||||
enabled: true,
|
||||
createdAt: '2025-10-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly mockLocalizations: LocalizationConfig[] = [
|
||||
{
|
||||
localeId: 'loc-en-us',
|
||||
tenantId: 'tenant-default',
|
||||
locale: 'en-US',
|
||||
name: 'English (US)',
|
||||
templates: { 'vuln.critical': 'Critical vulnerability detected: {{title}}' },
|
||||
dateFormat: 'MM/DD/YYYY',
|
||||
timeFormat: 'HH:mm:ss',
|
||||
enabled: true,
|
||||
createdAt: '2025-10-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly mockIncidents: NotifyIncident[] = [
|
||||
{
|
||||
incidentId: 'inc-001',
|
||||
tenantId: 'tenant-default',
|
||||
title: 'Critical vulnerability CVE-2021-44228',
|
||||
severity: 'critical',
|
||||
status: 'open',
|
||||
eventIds: ['evt-001', 'evt-002'],
|
||||
escalationLevel: 1,
|
||||
escalationPolicyId: 'escalate-critical',
|
||||
createdAt: '2025-12-10T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
// WEB-NOTIFY-38-001: Base APIs
|
||||
listChannels(): Observable<NotifyChannel[]> {
|
||||
return of([...this.mockChannels]).pipe(delay(50));
|
||||
}
|
||||
|
||||
saveChannel(channel: NotifyChannel): Observable<NotifyChannel> {
|
||||
return of(channel).pipe(delay(50));
|
||||
}
|
||||
|
||||
deleteChannel(_channelId: string): Observable<void> {
|
||||
return of(undefined).pipe(delay(50));
|
||||
}
|
||||
|
||||
getChannelHealth(channelId: string): Observable<ChannelHealthResponse> {
|
||||
return of({
|
||||
tenantId: 'tenant-default',
|
||||
channelId,
|
||||
status: 'Healthy' as const,
|
||||
checkedAt: new Date().toISOString(),
|
||||
traceId: generateTraceId(),
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
testChannel(channelId: string, payload: ChannelTestSendRequest): Observable<ChannelTestSendResponse> {
|
||||
return of({
|
||||
tenantId: 'tenant-default',
|
||||
channelId,
|
||||
preview: {
|
||||
channelType: 'Webhook' as const,
|
||||
format: 'Json' as const,
|
||||
target: 'https://soc.example.com/webhooks/stellaops',
|
||||
title: payload.title || 'Test notification',
|
||||
body: payload.body || 'Test notification body',
|
||||
},
|
||||
queuedAt: new Date().toISOString(),
|
||||
traceId: generateTraceId(),
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
|
||||
listRules(): Observable<NotifyRule[]> {
|
||||
return of([...this.mockRules]).pipe(delay(50));
|
||||
}
|
||||
|
||||
saveRule(rule: NotifyRule): Observable<NotifyRule> {
|
||||
return of(rule).pipe(delay(50));
|
||||
}
|
||||
|
||||
deleteRule(_ruleId: string): Observable<void> {
|
||||
return of(undefined).pipe(delay(50));
|
||||
}
|
||||
|
||||
listDeliveries(_options?: NotifyDeliveriesQueryOptions): Observable<NotifyDeliveriesResponse> {
|
||||
return of({ items: [], count: 0 }).pipe(delay(50));
|
||||
}
|
||||
|
||||
// WEB-NOTIFY-39-001: Digest, quiet hours, throttle
|
||||
listDigestSchedules(options: NotifyQueryOptions = {}): Observable<DigestSchedulesResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
items: [...this.mockDigestSchedules],
|
||||
total: this.mockDigestSchedules.length,
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
saveDigestSchedule(schedule: DigestSchedule): Observable<DigestSchedule> {
|
||||
return of(schedule).pipe(delay(50));
|
||||
}
|
||||
|
||||
deleteDigestSchedule(_scheduleId: string): Observable<void> {
|
||||
return of(undefined).pipe(delay(50));
|
||||
}
|
||||
|
||||
listQuietHours(options: NotifyQueryOptions = {}): Observable<QuietHoursResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
items: [...this.mockQuietHours],
|
||||
total: this.mockQuietHours.length,
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
saveQuietHours(quietHours: QuietHours): Observable<QuietHours> {
|
||||
return of(quietHours).pipe(delay(50));
|
||||
}
|
||||
|
||||
deleteQuietHours(_quietHoursId: string): Observable<void> {
|
||||
return of(undefined).pipe(delay(50));
|
||||
}
|
||||
|
||||
listThrottleConfigs(options: NotifyQueryOptions = {}): Observable<ThrottleConfigsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
items: [...this.mockThrottleConfigs],
|
||||
total: this.mockThrottleConfigs.length,
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
saveThrottleConfig(config: ThrottleConfig): Observable<ThrottleConfig> {
|
||||
return of(config).pipe(delay(50));
|
||||
}
|
||||
|
||||
deleteThrottleConfig(_throttleId: string): Observable<void> {
|
||||
return of(undefined).pipe(delay(50));
|
||||
}
|
||||
|
||||
simulateNotification(request: NotifySimulationRequest, options: NotifyQueryOptions = {}): Observable<NotifySimulationResult> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
simulationId: `sim-${Date.now()}`,
|
||||
matchedRules: ['rule-critical-vulns'],
|
||||
wouldNotify: [
|
||||
{
|
||||
channelId: 'chn-soc-webhook',
|
||||
actionId: 'act-soc',
|
||||
template: 'tmpl-default',
|
||||
digest: 'instant' as const,
|
||||
},
|
||||
],
|
||||
throttled: false,
|
||||
quietHoursActive: false,
|
||||
traceId,
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
|
||||
// WEB-NOTIFY-40-001: Escalation, localization, incidents
|
||||
listEscalationPolicies(options: NotifyQueryOptions = {}): Observable<EscalationPoliciesResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
items: [...this.mockEscalationPolicies],
|
||||
total: this.mockEscalationPolicies.length,
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
saveEscalationPolicy(policy: EscalationPolicy): Observable<EscalationPolicy> {
|
||||
return of(policy).pipe(delay(50));
|
||||
}
|
||||
|
||||
deleteEscalationPolicy(_policyId: string): Observable<void> {
|
||||
return of(undefined).pipe(delay(50));
|
||||
}
|
||||
|
||||
listLocalizations(options: NotifyQueryOptions = {}): Observable<LocalizationConfigsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
items: [...this.mockLocalizations],
|
||||
total: this.mockLocalizations.length,
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
saveLocalization(config: LocalizationConfig): Observable<LocalizationConfig> {
|
||||
return of(config).pipe(delay(50));
|
||||
}
|
||||
|
||||
deleteLocalization(_localeId: string): Observable<void> {
|
||||
return of(undefined).pipe(delay(50));
|
||||
}
|
||||
|
||||
listIncidents(options: NotifyQueryOptions = {}): Observable<NotifyIncidentsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
items: [...this.mockIncidents],
|
||||
total: this.mockIncidents.length,
|
||||
traceId,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
getIncident(incidentId: string, _options: NotifyQueryOptions = {}): Observable<NotifyIncident> {
|
||||
const incident = this.mockIncidents.find((i) => i.incidentId === incidentId);
|
||||
if (!incident) {
|
||||
return throwError(() => new Error(`Incident not found: ${incidentId}`));
|
||||
}
|
||||
return of(incident).pipe(delay(50));
|
||||
}
|
||||
|
||||
acknowledgeIncident(incidentId: string, _request: AckRequest, options: NotifyQueryOptions = {}): Observable<AckResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return of({
|
||||
incidentId,
|
||||
acknowledged: true,
|
||||
acknowledgedAt: new Date().toISOString(),
|
||||
acknowledgedBy: 'user@example.com',
|
||||
traceId,
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -192,3 +192,228 @@ export interface ChannelTestSendResponse {
|
||||
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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user