feat(scheduler): audit cleanse plugin + JSON Schema config forms + UI enrichment
Scheduler plugins: - AuditCleanseJobPlugin: purge audit data older than retention (default 365 days) - ScanJobPlugin: proper JSON Schema for mode/scope/parallelism - Plugin discovery endpoints: list, schema, defaults - ISchedulerJobPlugin gains GetDefaultConfig() - Dynamic plugin-config-form Angular component - Schedule create dialog with plugin-aware config Audit UI (Gaps 4+5): - Structured details panel: HTTP context, request body, before state - [REDACTED] PII highlighting with warning badges - Auto-construct diff from details.beforeState - New module types: release, attestor, doctor, signals, advisory-ai, riskengine Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,12 @@ export type AuditModule =
|
||||
| 'scanner'
|
||||
| 'attestor'
|
||||
| 'sbom'
|
||||
| 'scheduler';
|
||||
| 'scheduler'
|
||||
| 'release'
|
||||
| 'doctor'
|
||||
| 'signals'
|
||||
| 'advisory-ai'
|
||||
| 'riskengine';
|
||||
|
||||
/** Audit event action types */
|
||||
export type AuditAction =
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
|
||||
import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule, ActivatedRoute } from '@angular/router';
|
||||
import { AuditLogClient } from '../../core/api/audit-log.client';
|
||||
import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.models';
|
||||
import { AuditEvent, AuditCorrelationCluster, AuditDiff } from '../../core/api/audit-log.models';
|
||||
import { AuditEventDetailsPanelComponent } from './audit-event-details-panel.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-audit-event-detail',
|
||||
imports: [CommonModule, RouterModule],
|
||||
imports: [CommonModule, RouterModule, AuditEventDetailsPanelComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="event-detail-page">
|
||||
@@ -99,7 +100,7 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
|
||||
|
||||
<div class="details-section">
|
||||
<h3>Details</h3>
|
||||
<pre class="json-block">{{ (event()?.details ?? {}) | json }}</pre>
|
||||
<app-audit-event-details-panel [event]="event()" />
|
||||
</div>
|
||||
|
||||
@if (event()?.module === 'policy') {
|
||||
@@ -136,9 +137,9 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (event()?.diff) {
|
||||
@if (event()?.diff || syntheticDiff()) {
|
||||
<div class="diff-section">
|
||||
<h3>Configuration Diff</h3>
|
||||
<h3>{{ event()?.diff ? 'Configuration Diff' : 'State at Time of Action' }}</h3>
|
||||
@if (hasGovernanceDiff()) {
|
||||
<div class="governance-diff">
|
||||
@if (govDiffEntries(event()?.diff?.added).length > 0) {
|
||||
@@ -178,23 +179,27 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="diff-container">
|
||||
<div class="diff-pane before">
|
||||
<h4>Before</h4>
|
||||
<pre>{{ (event()?.diff?.before ?? {}) | json }}</pre>
|
||||
</div>
|
||||
<div class="diff-pane after">
|
||||
<h4>After</h4>
|
||||
<pre>{{ (event()?.diff?.after ?? {}) | json }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@if (event()?.diff?.fields?.length) {
|
||||
<div class="changed-fields">
|
||||
<strong>Changed fields:</strong>
|
||||
@for (field of event()?.diff?.fields; track field) {
|
||||
<span class="field-badge">{{ field }}</span>
|
||||
@if (activeDiff(); as diff) {
|
||||
<div class="diff-container">
|
||||
<div class="diff-pane before">
|
||||
<h4>{{ event()?.diff ? 'Before' : 'State at time of action' }}</h4>
|
||||
<pre>{{ (diff.before ?? {}) | json }}</pre>
|
||||
</div>
|
||||
@if (diff.after !== null && diff.after !== undefined) {
|
||||
<div class="diff-pane after">
|
||||
<h4>After</h4>
|
||||
<pre>{{ (diff.after ?? {}) | json }}</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (diff.fields?.length) {
|
||||
<div class="changed-fields">
|
||||
<strong>{{ event()?.diff ? 'Changed fields:' : 'Fields captured:' }}</strong>
|
||||
@for (field of diff.fields; track field) {
|
||||
<span class="field-badge">{{ field }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -264,6 +269,12 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
|
||||
.badge.module.policy { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
.badge.module.authority { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
|
||||
.badge.module.vex { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.badge.module.release { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
|
||||
.badge.module.attestor { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.badge.module.doctor { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
|
||||
.badge.module.signals { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
.badge.module.advisory-ai { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
|
||||
.badge.module.riskengine { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
|
||||
.badge.action { background: var(--color-surface-elevated); }
|
||||
.badge.action.create { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.badge.action.update { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
@@ -323,6 +334,26 @@ export class AuditEventDetailComponent implements OnInit {
|
||||
readonly event = signal<AuditEvent | null>(null);
|
||||
readonly correlation = signal<AuditCorrelationCluster | null>(null);
|
||||
|
||||
/** Synthetic diff auto-constructed from details.beforeState when event.diff is absent */
|
||||
readonly syntheticDiff = computed<AuditDiff | null>(() => {
|
||||
const ev = this.event();
|
||||
if (!ev || ev.diff) return null;
|
||||
const beforeState = ev.details?.['beforeState'];
|
||||
if (!beforeState || typeof beforeState !== 'object') return null;
|
||||
const responseBody = ev.details?.['requestBody'];
|
||||
const after = (responseBody && typeof responseBody === 'object') ? responseBody : null;
|
||||
return {
|
||||
before: beforeState,
|
||||
after: after ?? undefined,
|
||||
fields: Object.keys(beforeState as Record<string, unknown>),
|
||||
};
|
||||
});
|
||||
|
||||
/** Returns the real diff or the synthetic diff, whichever is active */
|
||||
readonly activeDiff = computed<AuditDiff | null>(() => {
|
||||
return this.event()?.diff ?? this.syntheticDiff();
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.params.subscribe((params) => {
|
||||
const eventId = params['eventId'];
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
import { Component, Input, ChangeDetectionStrategy, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AuditEvent, AuditDiff } from '../../core/api/audit-log.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-audit-event-details-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@if (event) {
|
||||
<!-- HTTP Context Section -->
|
||||
@if (hasHttpContext) {
|
||||
<section class="details-section">
|
||||
<h4 class="section-title">HTTP Context</h4>
|
||||
<div class="http-context">
|
||||
<div class="http-summary">
|
||||
<span class="http-method" [class]="httpMethodClass">{{ event.details?.['httpMethod'] }}</span>
|
||||
<span class="http-arrow">→</span>
|
||||
<span class="http-status" [class]="httpStatusClass">{{ event.details?.['statusCode'] }} {{ httpStatusText }}</span>
|
||||
</div>
|
||||
@if (event.details?.['path']) {
|
||||
<div class="http-detail-row">
|
||||
<span class="http-label">Path</span>
|
||||
<code class="http-value">{{ event.details?.['path'] }}</code>
|
||||
</div>
|
||||
}
|
||||
@if (event.details?.['responseResourceId']) {
|
||||
<div class="http-detail-row">
|
||||
<span class="http-label">Response Resource</span>
|
||||
<code class="http-value">{{ event.details?.['responseResourceId'] }}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Request Body Section -->
|
||||
@if (hasRequestBody) {
|
||||
<section class="details-section">
|
||||
<div class="section-header-row">
|
||||
<h4 class="section-title">Request Body</h4>
|
||||
<div class="section-actions">
|
||||
<button class="btn-copy" type="button" (click)="copyRequestBody()" [title]="copyLabel()">
|
||||
{{ copyLabel() }}
|
||||
</button>
|
||||
@if (requestBodyEntries.length > 5) {
|
||||
<button class="btn-toggle" type="button" (click)="requestBodyExpanded.set(!requestBodyExpanded())">
|
||||
{{ requestBodyExpanded() ? 'Collapse' : 'Expand' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="kv-grid" [class.kv-grid--collapsed]="!requestBodyExpanded() && requestBodyEntries.length > 5">
|
||||
@for (entry of requestBodyEntries; track entry[0]) {
|
||||
<div class="kv-row">
|
||||
<span class="kv-key">{{ entry[0] }}</span>
|
||||
@if (isRedacted(entry[1])) {
|
||||
<span class="kv-value kv-value--redacted">{{ entry[1] }}</span>
|
||||
} @else {
|
||||
<span class="kv-value">{{ formatValue(entry[1]) }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Before State Section -->
|
||||
@if (hasBeforeState) {
|
||||
<section class="details-section">
|
||||
<h4 class="section-title">State at Time of Action</h4>
|
||||
<div class="kv-grid">
|
||||
@for (entry of beforeStateEntries; track entry[0]) {
|
||||
<div class="kv-row" [class.kv-row--changed]="isFieldChanged(entry[0])">
|
||||
<span class="kv-key">{{ entry[0] }}</span>
|
||||
<span class="kv-value" [class.kv-value--redacted]="isRedacted(entry[1])">{{ formatValue(entry[1]) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Synthetic Diff Section (auto-constructed from beforeState when diff is absent) -->
|
||||
@if (syntheticDiff) {
|
||||
<section class="details-section">
|
||||
<h4 class="section-title">{{ syntheticDiffLabel }}</h4>
|
||||
<div class="diff-container">
|
||||
<div class="diff-pane before">
|
||||
<h5>{{ hasBothStates ? 'Before' : 'State at time of action' }}</h5>
|
||||
<pre>{{ syntheticDiff.before | json }}</pre>
|
||||
</div>
|
||||
@if (syntheticDiff.after !== null && syntheticDiff.after !== undefined) {
|
||||
<div class="diff-pane after">
|
||||
<h5>After</h5>
|
||||
<pre>{{ syntheticDiff.after | json }}</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (syntheticDiff.fields?.length) {
|
||||
<div class="changed-fields">
|
||||
<strong>Fields captured:</strong>
|
||||
@for (field of syntheticDiff.fields; track field) {
|
||||
<span class="field-badge">{{ field }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Raw Details Fallback -->
|
||||
@if (remainingDetails.length > 0) {
|
||||
<section class="details-section">
|
||||
<div class="section-header-row">
|
||||
<h4 class="section-title">Additional Details</h4>
|
||||
<button class="btn-toggle" type="button" (click)="rawExpanded.set(!rawExpanded())">
|
||||
{{ rawExpanded() ? 'Collapse' : 'Expand' }}
|
||||
</button>
|
||||
</div>
|
||||
@if (rawExpanded()) {
|
||||
<div class="kv-grid">
|
||||
@for (entry of remainingDetails; track entry[0]) {
|
||||
<div class="kv-row">
|
||||
<span class="kv-key">{{ entry[0] }}</span>
|
||||
<span class="kv-value" [class.kv-value--redacted]="isRedacted(entry[1])">{{ formatValue(entry[1]) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
.details-section {
|
||||
margin-bottom: 1.25rem;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--color-surface-elevated);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
.section-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--color-surface-elevated);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* HTTP Context */
|
||||
.http-context { padding: 0.75rem; }
|
||||
.http-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
.http-method {
|
||||
font-family: monospace;
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.http-method.method-get { color: var(--color-status-info-text); }
|
||||
.http-method.method-post { color: var(--color-status-success-text); }
|
||||
.http-method.method-put, .http-method.method-patch { color: var(--color-status-warning-text); }
|
||||
.http-method.method-delete { color: var(--color-status-error-text); }
|
||||
.http-arrow { color: var(--color-text-muted); }
|
||||
.http-status { font-family: monospace; font-size: 0.85rem; }
|
||||
.http-status.status-2xx { color: var(--color-status-success-text); }
|
||||
.http-status.status-3xx { color: var(--color-status-info-text); }
|
||||
.http-status.status-4xx { color: var(--color-status-warning-text); }
|
||||
.http-status.status-5xx { color: var(--color-status-error-text); }
|
||||
.http-detail-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.http-label {
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 120px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.http-value {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Key-Value Grid */
|
||||
.kv-grid { padding: 0.5rem 0.75rem; }
|
||||
.kv-grid--collapsed { max-height: 200px; overflow: hidden; position: relative; }
|
||||
.kv-grid--collapsed::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(transparent, var(--color-surface-primary));
|
||||
pointer-events: none;
|
||||
}
|
||||
.kv-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.kv-row:last-child { border-bottom: none; }
|
||||
.kv-row--changed { background: rgba(var(--color-status-warning-rgb, 234, 179, 8), 0.08); }
|
||||
.kv-key {
|
||||
min-width: 140px;
|
||||
max-width: 200px;
|
||||
font-family: monospace;
|
||||
font-size: 0.78rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.kv-value {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 0.78rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.kv-value--redacted {
|
||||
color: var(--color-status-warning-text);
|
||||
background: var(--color-status-warning-bg);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-copy, .btn-toggle {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
.btn-copy:hover, .btn-toggle:hover {
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Synthetic Diff */
|
||||
.diff-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.diff-container:has(.diff-pane:only-child) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.diff-pane {
|
||||
background: var(--color-surface-elevated);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.diff-pane h5 {
|
||||
margin: 0;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
.diff-pane.before h5 { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
|
||||
.diff-pane.after h5 { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.diff-pane pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
.changed-fields {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.field-badge {
|
||||
display: inline-block;
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
padding: 0.12rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class AuditEventDetailsPanelComponent {
|
||||
@Input() event: AuditEvent | null = null;
|
||||
|
||||
/** Tracks whether the request body section is expanded */
|
||||
readonly requestBodyExpanded = signal(false);
|
||||
/** Tracks whether the raw details section is expanded */
|
||||
readonly rawExpanded = signal(false);
|
||||
/** Label for the copy button */
|
||||
readonly copyLabel = signal('Copy as JSON');
|
||||
|
||||
/** Keys that are rendered in dedicated sections (not in raw fallback) */
|
||||
private readonly handledKeys = new Set([
|
||||
'httpMethod', 'statusCode', 'path', 'responseResourceId',
|
||||
'requestBody', 'beforeState',
|
||||
]);
|
||||
|
||||
// --- HTTP Context ---
|
||||
|
||||
get hasHttpContext(): boolean {
|
||||
return !!this.event?.details?.['httpMethod'];
|
||||
}
|
||||
|
||||
get httpMethodClass(): string {
|
||||
const method = String(this.event?.details?.['httpMethod'] ?? '').toUpperCase();
|
||||
return 'method-' + method.toLowerCase();
|
||||
}
|
||||
|
||||
get httpStatusClass(): string {
|
||||
const code = Number(this.event?.details?.['statusCode'] ?? 0);
|
||||
if (code >= 200 && code < 300) return 'status-2xx';
|
||||
if (code >= 300 && code < 400) return 'status-3xx';
|
||||
if (code >= 400 && code < 500) return 'status-4xx';
|
||||
if (code >= 500) return 'status-5xx';
|
||||
return '';
|
||||
}
|
||||
|
||||
get httpStatusText(): string {
|
||||
const code = Number(this.event?.details?.['statusCode'] ?? 0);
|
||||
const texts: Record<number, string> = {
|
||||
200: 'OK', 201: 'Created', 204: 'No Content',
|
||||
301: 'Moved Permanently', 302: 'Found', 304: 'Not Modified',
|
||||
400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict',
|
||||
500: 'Internal Server Error', 502: 'Bad Gateway', 503: 'Service Unavailable',
|
||||
};
|
||||
return texts[code] ?? '';
|
||||
}
|
||||
|
||||
// --- Request Body ---
|
||||
|
||||
get hasRequestBody(): boolean {
|
||||
const body = this.event?.details?.['requestBody'];
|
||||
return !!body && typeof body === 'object';
|
||||
}
|
||||
|
||||
get requestBodyEntries(): [string, unknown][] {
|
||||
const body = this.event?.details?.['requestBody'] as Record<string, unknown> | undefined;
|
||||
if (!body || typeof body !== 'object') return [];
|
||||
return Object.entries(body);
|
||||
}
|
||||
|
||||
copyRequestBody(): void {
|
||||
const body = this.event?.details?.['requestBody'];
|
||||
if (body) {
|
||||
navigator.clipboard.writeText(JSON.stringify(body, null, 2)).then(() => {
|
||||
this.copyLabel.set('Copied!');
|
||||
setTimeout(() => this.copyLabel.set('Copy as JSON'), 2000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Before State ---
|
||||
|
||||
get hasBeforeState(): boolean {
|
||||
const state = this.event?.details?.['beforeState'];
|
||||
return !!state && typeof state === 'object';
|
||||
}
|
||||
|
||||
get beforeStateEntries(): [string, unknown][] {
|
||||
const state = this.event?.details?.['beforeState'] as Record<string, unknown> | undefined;
|
||||
if (!state || typeof state !== 'object') return [];
|
||||
return Object.entries(state);
|
||||
}
|
||||
|
||||
isFieldChanged(field: string): boolean {
|
||||
// A field is "changed" if there is no diff but the event has afterState to compare,
|
||||
// or if the diff references this field
|
||||
const diff = this.event?.diff;
|
||||
if (diff?.fields?.includes(field)) return true;
|
||||
if (diff?.modified && field in diff.modified) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Synthetic Diff (Gap 5) ---
|
||||
|
||||
get syntheticDiff(): { before: unknown; after: unknown; fields: string[] } | null {
|
||||
// Only synthesize if there is no real diff but beforeState exists
|
||||
if (this.event?.diff) return null;
|
||||
const beforeState = this.event?.details?.['beforeState'];
|
||||
if (!beforeState || typeof beforeState !== 'object') return null;
|
||||
|
||||
// If we have both beforeState and a response body, show both panes
|
||||
const responseBody = this.event?.details?.['requestBody'];
|
||||
const after = (responseBody && typeof responseBody === 'object') ? responseBody : null;
|
||||
|
||||
return {
|
||||
before: beforeState,
|
||||
after,
|
||||
fields: Object.keys(beforeState as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
|
||||
get syntheticDiffLabel(): string {
|
||||
return this.hasBothStates ? 'State Comparison' : 'State at Time of Action';
|
||||
}
|
||||
|
||||
get hasBothStates(): boolean {
|
||||
const diff = this.syntheticDiff;
|
||||
return !!diff && diff.after !== null && diff.after !== undefined;
|
||||
}
|
||||
|
||||
// --- Raw Details Fallback ---
|
||||
|
||||
get remainingDetails(): [string, unknown][] {
|
||||
const details = this.event?.details;
|
||||
if (!details) return [];
|
||||
return Object.entries(details).filter(([key]) => !this.handledKeys.has(key));
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
isRedacted(value: unknown): boolean {
|
||||
return typeof value === 'string' && value === '[REDACTED]';
|
||||
}
|
||||
|
||||
formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return 'null';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,12 @@ const AUDIT_TABS: StellaPageTab[] = [
|
||||
.badge.module.authority { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
|
||||
.badge.module.vex { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.badge.module.integrations { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
|
||||
.badge.module.release { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
|
||||
.badge.module.attestor { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.badge.module.doctor { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
|
||||
.badge.module.signals { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
.badge.module.advisory-ai { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
|
||||
.badge.module.riskengine { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
|
||||
.badge.action { background: var(--color-surface-elevated); }
|
||||
.badge.action.create { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.badge.action.update { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
@@ -487,6 +493,14 @@ export class AuditLogDashboardComponent implements OnInit {
|
||||
integrations: 'Integrations',
|
||||
release: 'Release',
|
||||
scanner: 'Scanner',
|
||||
attestor: 'Attestor',
|
||||
sbom: 'SBOM',
|
||||
scheduler: 'Scheduler',
|
||||
jobengine: 'JobEngine',
|
||||
doctor: 'Doctor',
|
||||
signals: 'Signals',
|
||||
'advisory-ai': 'Advisory AI',
|
||||
riskengine: 'Risk Engine',
|
||||
};
|
||||
return labels[module] || module;
|
||||
}
|
||||
@@ -499,6 +513,11 @@ export class AuditLogDashboardComponent implements OnInit {
|
||||
integrations: 'M16 18l6-6-6-6|||M8 6l-6 6 6 6',
|
||||
release: 'M12 2L2 7l10 5 10-5-10-5z|||M2 17l10 5 10-5|||M2 12l10 5 10-5',
|
||||
scanner: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0',
|
||||
attestor: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0 1 12 2.944a11.955 11.955 0 0 1-8.618 3.04A12.02 12.02 0 0 0 3 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||
doctor: 'M22 12h-4l-3 9L9 3l-3 9H2',
|
||||
signals: 'M2 20h.01|||M7 20v-4|||M12 20v-8|||M17 20V8|||M22 20V4',
|
||||
'advisory-ai': 'M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1.27A7 7 0 0 1 14 22h-4a7 7 0 0 1-6.73-3H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z|||M10 15.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z|||M14 15.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z',
|
||||
riskengine: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01',
|
||||
};
|
||||
return icons[module] || 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0';
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
|
||||
import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule, ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuditLogClient } from '../../core/api/audit-log.client';
|
||||
import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } from '../../core/api/audit-log.models';
|
||||
import { AuditEvent, AuditDiff, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } from '../../core/api/audit-log.models';
|
||||
import { AuditEventDetailsPanelComponent } from './audit-event-details-panel.component';
|
||||
|
||||
type PolicyCategory = 'all' | 'governance' | 'promotions' | 'approvals' | 'rejections' | 'simulations';
|
||||
|
||||
@@ -19,7 +20,7 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
|
||||
@Component({
|
||||
selector: 'app-audit-log-table',
|
||||
imports: [CommonModule, RouterModule, FormsModule],
|
||||
imports: [CommonModule, RouterModule, FormsModule, AuditEventDetailsPanelComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="audit-table-page">
|
||||
@@ -101,6 +102,12 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
<input type="text" [(ngModel)]="actorFilter" placeholder="Username or email" (keyup.enter)="applyFilters()" />
|
||||
</div>
|
||||
<button class="btn-secondary" (click)="clearFilters()">Clear Filters</button>
|
||||
<div class="column-toggles">
|
||||
<label class="column-toggle">
|
||||
<input type="checkbox" [checked]="showHttpColumns()" (change)="showHttpColumns.set(!showHttpColumns())" />
|
||||
HTTP columns
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -137,6 +144,10 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
<th>Shadow Mode</th>
|
||||
<th>Coverage</th>
|
||||
}
|
||||
@if (showHttpColumns()) {
|
||||
<th>Method</th>
|
||||
<th>Status</th>
|
||||
}
|
||||
@if (!isPolicyOnly) { <th>Description</th> }
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -178,18 +189,22 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
} @else { - }
|
||||
</td>
|
||||
}
|
||||
@if (showHttpColumns()) {
|
||||
<td><span class="mono">{{ getDetail(event, 'httpMethod') || '-' }}</span></td>
|
||||
<td><span class="mono">{{ getDetail(event, 'statusCode') || '-' }}</span></td>
|
||||
}
|
||||
@if (!isPolicyOnly) {
|
||||
<td class="description">{{ event.description }}</td>
|
||||
}
|
||||
<td>
|
||||
<a [routerLink]="[event.id]" class="link">View</a>
|
||||
@if (event.diff) {
|
||||
@if (event.diff || hasBeforeState(event)) {
|
||||
<button class="btn-xs" (click)="openDiffViewer(event); $event.stopPropagation()">Diff</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td [attr.colspan]="isPolicyOnly ? 10 : 8" style="text-align:center;padding:2rem;color:var(--color-text-muted)">No events match the current filters.</td></tr>
|
||||
<tr><td [attr.colspan]="(isPolicyOnly ? 10 : 8) + (showHttpColumns() ? 2 : 0)" style="text-align:center;padding:2rem;color:var(--color-text-muted)">No events match the current filters.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -263,10 +278,10 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
}
|
||||
<div class="detail-section">
|
||||
<h4>Details</h4>
|
||||
<pre class="json-block">{{ (selectedEvent()?.details ?? {}) | json }}</pre>
|
||||
<app-audit-event-details-panel [event]="selectedEvent()" />
|
||||
</div>
|
||||
@if (selectedEvent()?.diff) {
|
||||
<button class="btn-primary" (click)="openDiffViewer(selectedEvent()!)">View Diff</button>
|
||||
@if (selectedEvent()?.diff || hasBeforeState(selectedEvent()!)) {
|
||||
<button class="btn-primary" (click)="openDiffViewer(selectedEvent()!)">{{ selectedEvent()?.diff ? 'View Diff' : 'View State' }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,7 +291,7 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
<div class="diff-modal-backdrop" (click)="closeDiffViewer()">
|
||||
<div class="diff-modal" (click)="$event.stopPropagation()">
|
||||
<header class="modal-header">
|
||||
<h3>Configuration Diff</h3>
|
||||
<h3>{{ diffEvent()?.diff ? 'Configuration Diff' : 'State at Time of Action' }}</h3>
|
||||
<button class="close-btn" (click)="closeDiffViewer()">×</button>
|
||||
</header>
|
||||
<div class="modal-content">
|
||||
@@ -325,24 +340,28 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Standard before/after diff -->
|
||||
<div class="diff-container">
|
||||
<div class="diff-pane before">
|
||||
<h4>Before</h4>
|
||||
<pre>{{ (diffEvent()?.diff?.before ?? {}) | json }}</pre>
|
||||
</div>
|
||||
<div class="diff-pane after">
|
||||
<h4>After</h4>
|
||||
<pre>{{ (diffEvent()?.diff?.after ?? {}) | json }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@if (diffEvent()?.diff?.fields?.length) {
|
||||
<div class="changed-fields">
|
||||
<strong>Changed fields:</strong>
|
||||
@for (field of diffEvent()?.diff?.fields; track field) {
|
||||
<span class="field-badge">{{ field }}</span>
|
||||
<!-- Standard before/after diff or synthetic diff from beforeState -->
|
||||
@if (activeDiffFor(diffEvent()!); as diff) {
|
||||
<div class="diff-container">
|
||||
<div class="diff-pane before">
|
||||
<h4>{{ diffEvent()?.diff ? 'Before' : 'State at time of action' }}</h4>
|
||||
<pre>{{ (diff.before ?? {}) | json }}</pre>
|
||||
</div>
|
||||
@if (diff.after !== null && diff.after !== undefined) {
|
||||
<div class="diff-pane after">
|
||||
<h4>After</h4>
|
||||
<pre>{{ (diff.after ?? {}) | json }}</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (diff.fields?.length) {
|
||||
<div class="changed-fields">
|
||||
<strong>{{ diffEvent()?.diff ? 'Changed fields:' : 'Fields captured:' }}</strong>
|
||||
@for (field of diff.fields; track field) {
|
||||
<span class="field-badge">{{ field }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -385,6 +404,23 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
}
|
||||
.btn-secondary:hover { border-color: var(--color-brand-primary); }
|
||||
|
||||
/* Column toggles */
|
||||
.column-toggles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.column-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.column-toggle input { cursor: pointer; }
|
||||
|
||||
/* Policy sub-category chips */
|
||||
.policy-category-chips {
|
||||
display: flex; gap: 0.5rem; margin-bottom: 1rem;
|
||||
@@ -451,6 +487,11 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
.badge.module.integrations { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
|
||||
.badge.module.jobengine { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
|
||||
.badge.module.scanner { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
|
||||
.badge.module.release { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
|
||||
.badge.module.doctor { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
|
||||
.badge.module.signals { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
.badge.module.advisory-ai { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
|
||||
.badge.module.riskengine { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
|
||||
.badge.action { background: var(--color-surface-elevated); }
|
||||
.badge.action.create, .badge.action.issue { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.badge.action.update, .badge.action.refresh { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
@@ -563,6 +604,7 @@ export class AuditLogTableComponent implements OnInit {
|
||||
readonly cursor = signal<string | null>(null);
|
||||
readonly hasMore = signal(false);
|
||||
readonly hasPrev = signal(false);
|
||||
readonly showHttpColumns = signal(false);
|
||||
private cursorStack: string[] = [];
|
||||
|
||||
// Filter state
|
||||
@@ -591,7 +633,7 @@ export class AuditLogTableComponent implements OnInit {
|
||||
{ id: 'simulations', label: 'Simulations' },
|
||||
];
|
||||
|
||||
readonly allModules: AuditModule[] = ['authority', 'policy', 'jobengine', 'integrations', 'vex', 'scanner', 'attestor', 'sbom', 'scheduler'];
|
||||
readonly allModules: AuditModule[] = ['authority', 'policy', 'jobengine', 'integrations', 'vex', 'scanner', 'attestor', 'sbom', 'scheduler', 'release', 'doctor', 'signals', 'advisory-ai', 'riskengine'];
|
||||
readonly allActions: AuditAction[] = ['create', 'update', 'delete', 'promote', 'demote', 'revoke', 'issue', 'refresh', 'test', 'fail', 'complete', 'start', 'submit', 'approve', 'reject', 'sign', 'verify', 'rotate', 'enable', 'disable', 'deadletter', 'replay'];
|
||||
readonly allSeverities: AuditSeverity[] = ['info', 'warning', 'error', 'critical'];
|
||||
|
||||
@@ -724,6 +766,25 @@ export class AuditLogTableComponent implements OnInit {
|
||||
!!(diff.modified && Object.keys(diff.modified).length);
|
||||
}
|
||||
|
||||
hasBeforeState(event: AuditEvent): boolean {
|
||||
const state = event?.details?.['beforeState'];
|
||||
return !!state && typeof state === 'object';
|
||||
}
|
||||
|
||||
/** Returns the real diff or a synthetic diff auto-constructed from beforeState */
|
||||
activeDiffFor(event: AuditEvent): AuditDiff | null {
|
||||
if (event.diff) return event.diff;
|
||||
const beforeState = event.details?.['beforeState'];
|
||||
if (!beforeState || typeof beforeState !== 'object') return null;
|
||||
const responseBody = event.details?.['requestBody'];
|
||||
const after = (responseBody && typeof responseBody === 'object') ? responseBody : undefined;
|
||||
return {
|
||||
before: beforeState,
|
||||
after,
|
||||
fields: Object.keys(beforeState as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
|
||||
diffEntries(obj: Record<string, unknown> | undefined | null): [string, any][] {
|
||||
if (!obj) return [];
|
||||
return Object.entries(obj);
|
||||
@@ -750,6 +811,11 @@ export class AuditLogTableComponent implements OnInit {
|
||||
attestor: 'Attestor',
|
||||
sbom: 'SBOM',
|
||||
scheduler: 'Scheduler',
|
||||
release: 'Release',
|
||||
doctor: 'Doctor',
|
||||
signals: 'Signals',
|
||||
'advisory-ai': 'Advisory AI',
|
||||
riskengine: 'Risk Engine',
|
||||
};
|
||||
return labels[module] || module;
|
||||
}
|
||||
|
||||
@@ -519,6 +519,11 @@ export class AuditModuleEventsComponent implements OnInit, OnChanges {
|
||||
attestor: 'Attestor',
|
||||
sbom: 'SBOM',
|
||||
scheduler: 'Scheduler',
|
||||
release: 'Release',
|
||||
doctor: 'Doctor',
|
||||
signals: 'Signals',
|
||||
'advisory-ai': 'Advisory AI',
|
||||
riskengine: 'Risk Engine',
|
||||
};
|
||||
return labels[m] || m;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user