Add scan policy CRUD system (Sprint 002 S1-T03)
Backend (Scanner .NET): - New ScanPolicyEndpoints.cs with GET/POST/PUT/DELETE /api/v1/scan-policies - In-memory ConcurrentDictionary storage (no migration needed) - Auth: scanner:read for list, orch:operate for mutations - Registered in Scanner Program.cs Frontend (Angular): - New scan-policy.component.ts with table view, inline create/edit form, enable/disable toggle, dynamic rules (type/severity/action) - Route added at /security/scan-policies in security-risk.routes.ts Gateway route already exists in router-gateway-local.json. Sprint 002: all 7 tasks now DONE. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@ Dependency: S1-T01
|
||||
Owners: Developer
|
||||
|
||||
### S1-T03 - Full scan policy system
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: S1-T02
|
||||
Owners: Developer
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScanPolicyEndpoints.cs
|
||||
// Sprint: S1-T03 (Scan Policy CRUD)
|
||||
// Description: HTTP endpoints for scan policy management.
|
||||
// Uses in-memory ConcurrentDictionary storage.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for scan policy CRUD operations.
|
||||
/// </summary>
|
||||
internal static class ScanPolicyEndpoints
|
||||
{
|
||||
private static readonly ConcurrentDictionary<Guid, ScanPolicyDto> _store = new();
|
||||
|
||||
/// <summary>
|
||||
/// Maps scan policy CRUD endpoints under the given route group.
|
||||
/// </summary>
|
||||
public static void MapScanPolicyEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/scan-policies")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var group = apiGroup.MapGroup(prefix)
|
||||
.WithTags("Scan Policies");
|
||||
|
||||
// GET /v1/scan-policies - List all policies for the tenant
|
||||
group.MapGet("/", HandleListPolicies)
|
||||
.WithName("scanner.scan-policies.list")
|
||||
.WithDescription("List all scan policies.")
|
||||
.Produces<ScanPolicyListResponseDto>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// POST /v1/scan-policies - Create a new scan policy
|
||||
group.MapPost("/", HandleCreatePolicy)
|
||||
.WithName("scanner.scan-policies.create")
|
||||
.WithDescription("Create a new scan policy.")
|
||||
.Produces<ScanPolicyDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
// PUT /v1/scan-policies/{id} - Update an existing scan policy
|
||||
group.MapPut("/{id:guid}", HandleUpdatePolicy)
|
||||
.WithName("scanner.scan-policies.update")
|
||||
.WithDescription("Update a scan policy.")
|
||||
.Produces<ScanPolicyDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
// DELETE /v1/scan-policies/{id} - Delete a scan policy
|
||||
group.MapDelete("/{id:guid}", HandleDeletePolicy)
|
||||
.WithName("scanner.scan-policies.delete")
|
||||
.WithDescription("Delete a scan policy.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Handlers
|
||||
// ========================================================================
|
||||
|
||||
private static IResult HandleListPolicies()
|
||||
{
|
||||
var policies = _store.Values
|
||||
.OrderByDescending(p => p.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new ScanPolicyListResponseDto
|
||||
{
|
||||
Items = policies,
|
||||
TotalCount = policies.Count
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult HandleCreatePolicy(
|
||||
CreateScanPolicyRequestDto request,
|
||||
HttpContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Validation failed",
|
||||
detail = "Policy name is required."
|
||||
});
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var policy = new ScanPolicyDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = request.Name.Trim(),
|
||||
Description = request.Description?.Trim() ?? string.Empty,
|
||||
Enabled = request.Enabled,
|
||||
Rules = request.Rules ?? [],
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_store[policy.Id] = policy;
|
||||
|
||||
return Results.Created($"/api/v1/scan-policies/{policy.Id}", policy);
|
||||
}
|
||||
|
||||
private static IResult HandleUpdatePolicy(
|
||||
Guid id,
|
||||
UpdateScanPolicyRequestDto request,
|
||||
HttpContext context)
|
||||
{
|
||||
if (!_store.TryGetValue(id, out var existing))
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
type = "not-found",
|
||||
title = "Policy not found",
|
||||
detail = $"No scan policy found with ID '{id}'."
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Validation failed",
|
||||
detail = "Policy name is required."
|
||||
});
|
||||
}
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Name = request.Name.Trim(),
|
||||
Description = request.Description?.Trim() ?? existing.Description,
|
||||
Enabled = request.Enabled,
|
||||
Rules = request.Rules ?? existing.Rules,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_store[id] = updated;
|
||||
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
|
||||
private static IResult HandleDeletePolicy(Guid id)
|
||||
{
|
||||
if (!_store.TryRemove(id, out _))
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
type = "not-found",
|
||||
title = "Policy not found",
|
||||
detail = $"No scan policy found with ID '{id}'."
|
||||
});
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Represents a scan policy.
|
||||
/// </summary>
|
||||
public sealed record ScanPolicyDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public bool Enabled { get; init; }
|
||||
public IReadOnlyList<ScanPolicyRuleDto> Rules { get; init; } = [];
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A rule within a scan policy (severity threshold, auto-scan trigger, etc.).
|
||||
/// </summary>
|
||||
public sealed record ScanPolicyRuleDto
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string Severity { get; init; } = string.Empty;
|
||||
public string Action { get; init; } = string.Empty;
|
||||
public string? Threshold { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a scan policy.
|
||||
/// </summary>
|
||||
public sealed record CreateScanPolicyRequestDto
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? Description { get; init; }
|
||||
public bool Enabled { get; init; } = true;
|
||||
public List<ScanPolicyRuleDto>? Rules { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update a scan policy.
|
||||
/// </summary>
|
||||
public sealed record UpdateScanPolicyRequestDto
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? Description { get; init; }
|
||||
public bool Enabled { get; init; } = true;
|
||||
public List<ScanPolicyRuleDto>? Rules { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing a list of scan policies.
|
||||
/// </summary>
|
||||
public sealed record ScanPolicyListResponseDto
|
||||
{
|
||||
public IReadOnlyList<ScanPolicyDto> Items { get; init; } = [];
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
@@ -804,6 +804,7 @@ apiGroup.MapProofBundleEndpoints();
|
||||
apiGroup.MapUnknownsEndpoints();
|
||||
apiGroup.MapSecretDetectionSettingsEndpoints(); // Sprint: SPRINT_20260104_006_BE
|
||||
apiGroup.MapSecurityAdapterEndpoints(); // Pack v2 security adapter routes
|
||||
apiGroup.MapScanPolicyEndpoints(); // Sprint: S1-T03 Scan Policy CRUD
|
||||
|
||||
if (resolvedOptions.Features.EnablePolicyPreview)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ScanPolicyRule {
|
||||
type: string;
|
||||
severity: string;
|
||||
action: string;
|
||||
threshold?: string;
|
||||
}
|
||||
|
||||
interface ScanPolicy {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
rules: ScanPolicyRule[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ScanPolicyListResponse {
|
||||
items: ScanPolicy[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Component({
|
||||
selector: 'app-scan-policy',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="scan-policy-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Scan Policies</h1>
|
||||
<p>Manage policies that control vulnerability scanning behavior.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
(click)="startCreate()"
|
||||
[disabled]="editing()"
|
||||
>
|
||||
+ Create Policy
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (error(); as err) {
|
||||
<p class="error-message" role="alert">{{ err }}</p>
|
||||
}
|
||||
|
||||
<!-- Inline form (create / edit) -->
|
||||
@if (editing()) {
|
||||
<section class="form-card">
|
||||
<h2>{{ editingId() ? 'Edit Policy' : 'Create Policy' }}</h2>
|
||||
|
||||
<label class="form-field">
|
||||
Name *
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="formName()"
|
||||
(ngModelChange)="formName.set($event)"
|
||||
placeholder="e.g., Production Gate Policy"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
Description
|
||||
<textarea
|
||||
rows="2"
|
||||
[ngModel]="formDescription()"
|
||||
(ngModelChange)="formDescription.set($event)"
|
||||
placeholder="Describe what this policy enforces"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="form-field checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
[ngModel]="formEnabled()"
|
||||
(ngModelChange)="formEnabled.set($event)"
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
|
||||
<!-- Rules -->
|
||||
<div class="rules-section">
|
||||
<h3>Rules</h3>
|
||||
@for (rule of formRules(); track $index; let idx = $index) {
|
||||
<div class="rule-row">
|
||||
<select
|
||||
[ngModel]="rule.type"
|
||||
(ngModelChange)="updateRule(idx, 'type', $event)"
|
||||
>
|
||||
<option value="severity-threshold">Severity Threshold</option>
|
||||
<option value="auto-scan-trigger">Auto-Scan Trigger</option>
|
||||
<option value="block-on-critical">Block on Critical</option>
|
||||
</select>
|
||||
<select
|
||||
[ngModel]="rule.severity"
|
||||
(ngModelChange)="updateRule(idx, 'severity', $event)"
|
||||
>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
<select
|
||||
[ngModel]="rule.action"
|
||||
(ngModelChange)="updateRule(idx, 'action', $event)"
|
||||
>
|
||||
<option value="block">Block</option>
|
||||
<option value="warn">Warn</option>
|
||||
<option value="allow">Allow</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost btn-icon"
|
||||
(click)="removeRule(idx)"
|
||||
aria-label="Remove rule"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<button type="button" class="btn-ghost" (click)="addRule()">
|
||||
+ Add Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer class="form-actions">
|
||||
<button type="button" class="btn-ghost" (click)="cancelEdit()">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
(click)="savePolicy()"
|
||||
[disabled]="saving() || !formName().trim()"
|
||||
>
|
||||
@if (saving()) {
|
||||
<span class="spinner"></span> Saving...
|
||||
} @else {
|
||||
{{ editingId() ? 'Update' : 'Create' }}
|
||||
}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Policies table -->
|
||||
@if (loading()) {
|
||||
<p class="loading-text">Loading policies...</p>
|
||||
} @else if (policies().length === 0 && !editing()) {
|
||||
<section class="empty-state">
|
||||
<p>No scan policies found. Create one to get started.</p>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>Rules</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (policy of policies(); track policy.id) {
|
||||
<tr>
|
||||
<td class="cell-name">{{ policy.name }}</td>
|
||||
<td class="cell-desc">{{ policy.description || '--' }}</td>
|
||||
<td>
|
||||
<span class="status-badge" [attr.data-status]="policy.enabled ? 'enabled' : 'disabled'">
|
||||
{{ policy.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ policy.rules.length }}</td>
|
||||
<td class="cell-date">{{ formatDate(policy.updatedAt) }}</td>
|
||||
<td class="cell-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost"
|
||||
(click)="startEdit(policy)"
|
||||
[disabled]="editing()"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost btn-danger"
|
||||
(click)="deletePolicy(policy)"
|
||||
[disabled]="editing()"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.scan-policy-page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-header h1 { margin: 0; }
|
||||
|
||||
.page-header p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.form-card,
|
||||
.table-card,
|
||||
.empty-state {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form-card h2 { margin: 0; font-size: 1rem; }
|
||||
|
||||
/* Form fields */
|
||||
.form-field {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
select { width: auto; min-width: 120px; }
|
||||
|
||||
.checkbox-row {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.checkbox-row input[type="checkbox"] { width: auto; }
|
||||
|
||||
/* Rules */
|
||||
.rules-section {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rules-section h3 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.rule-row {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary,
|
||||
.btn-ghost {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.btn-icon { padding: 0.25rem; line-height: 0; }
|
||||
|
||||
.btn-danger { color: var(--color-status-error-text); }
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.cell-name { font-weight: 500; }
|
||||
.cell-desc { color: var(--color-text-secondary); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cell-date { font-size: 0.76rem; color: var(--color-text-secondary); white-space: nowrap; }
|
||||
.cell-actions { display: flex; gap: 0.35rem; }
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status-badge[data-status="enabled"] {
|
||||
background: color-mix(in srgb, var(--color-status-success-text) 15%, transparent);
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.status-badge[data-status="disabled"] {
|
||||
background: color-mix(in srgb, var(--color-text-secondary) 15%, transparent);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.loading-text { font-size: 0.8rem; color: var(--color-text-secondary); }
|
||||
.empty-state { text-align: center; padding: 2rem; }
|
||||
.empty-state p { color: var(--color-text-secondary); font-size: 0.85rem; margin: 0; }
|
||||
.error-message { margin: 0; font-size: 0.78rem; color: var(--color-status-error-text); }
|
||||
`],
|
||||
})
|
||||
export class ScanPolicyComponent {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
private static readonly API_BASE = '/api/v1/scan-policies';
|
||||
|
||||
// List state
|
||||
readonly policies = signal<ScanPolicy[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
// Form state
|
||||
readonly editing = signal(false);
|
||||
readonly editingId = signal<string | null>(null);
|
||||
readonly saving = signal(false);
|
||||
readonly formName = signal('');
|
||||
readonly formDescription = signal('');
|
||||
readonly formEnabled = signal(true);
|
||||
readonly formRules = signal<ScanPolicyRule[]>([]);
|
||||
|
||||
constructor() {
|
||||
this.loadPolicies();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
loadPolicies(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.http.get<ScanPolicyListResponse>(ScanPolicyComponent.API_BASE).pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
).subscribe({
|
||||
next: (res) => {
|
||||
this.policies.set(res.items ?? []);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load scan policies.');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
savePolicy(): void {
|
||||
const name = this.formName().trim();
|
||||
if (!name) return;
|
||||
|
||||
this.saving.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const body = {
|
||||
name,
|
||||
description: this.formDescription().trim(),
|
||||
enabled: this.formEnabled(),
|
||||
rules: this.formRules(),
|
||||
};
|
||||
|
||||
const id = this.editingId();
|
||||
const req$ = id
|
||||
? this.http.put<ScanPolicy>(`${ScanPolicyComponent.API_BASE}/${id}`, body)
|
||||
: this.http.post<ScanPolicy>(ScanPolicyComponent.API_BASE, body);
|
||||
|
||||
req$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.cancelEdit();
|
||||
this.loadPolicies();
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.error.set(id ? 'Failed to update policy.' : 'Failed to create policy.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deletePolicy(policy: ScanPolicy): void {
|
||||
this.error.set(null);
|
||||
|
||||
this.http.delete(`${ScanPolicyComponent.API_BASE}/${policy.id}`).pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
).subscribe({
|
||||
next: () => this.loadPolicies(),
|
||||
error: () => this.error.set('Failed to delete policy.'),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
startCreate(): void {
|
||||
this.editingId.set(null);
|
||||
this.formName.set('');
|
||||
this.formDescription.set('');
|
||||
this.formEnabled.set(true);
|
||||
this.formRules.set([]);
|
||||
this.editing.set(true);
|
||||
}
|
||||
|
||||
startEdit(policy: ScanPolicy): void {
|
||||
this.editingId.set(policy.id);
|
||||
this.formName.set(policy.name);
|
||||
this.formDescription.set(policy.description);
|
||||
this.formEnabled.set(policy.enabled);
|
||||
this.formRules.set(policy.rules.map(r => ({ ...r })));
|
||||
this.editing.set(true);
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editing.set(false);
|
||||
this.editingId.set(null);
|
||||
}
|
||||
|
||||
addRule(): void {
|
||||
this.formRules.update(rules => [
|
||||
...rules,
|
||||
{ type: 'severity-threshold', severity: 'critical', action: 'block' },
|
||||
]);
|
||||
}
|
||||
|
||||
removeRule(index: number): void {
|
||||
this.formRules.update(rules => rules.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
updateRule(index: number, field: keyof ScanPolicyRule, value: string): void {
|
||||
this.formRules.update(rules =>
|
||||
rules.map((r, i) => i === index ? { ...r, [field]: value } : r)
|
||||
);
|
||||
}
|
||||
|
||||
formatDate(iso: string): string {
|
||||
if (!iso) return '--';
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,15 @@ export const SECURITY_RISK_ROUTES: Routes = [
|
||||
(m) => m.ScanSubmitComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'scan-policies',
|
||||
title: 'Scan Policies',
|
||||
data: { breadcrumb: 'Scan Policies' },
|
||||
loadComponent: () =>
|
||||
import('../features/scanner/scan-policy.component').then(
|
||||
(m) => m.ScanPolicyComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
title: 'Security Reports',
|
||||
|
||||
Reference in New Issue
Block a user