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:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user