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

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