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:
master
2026-03-16 23:20:26 +02:00
parent 5e850d056b
commit 079284f4b7
5 changed files with 812 additions and 1 deletions

View File

@@ -28,7 +28,7 @@ Dependency: S1-T01
Owners: Developer
### S1-T03 - Full scan policy system
Status: TODO
Status: DONE
Dependency: S1-T02
Owners: Developer

View File

@@ -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; }
}

View File

@@ -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)
{

View File

@@ -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;
}
}
}

View File

@@ -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',