using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using StellaOps.Auth.Abstractions; using StellaOps.Determinism; using StellaOps.Policy.Engine.Services; using StellaOps.Policy.Persistence.Postgres.Models; using StellaOps.Policy.Persistence.Postgres.Repositories; namespace StellaOps.Policy.Engine.Endpoints; /// /// Policy violation event endpoints for append-only audit trail. /// Violations are immutable records of policy rule violations. /// internal static class ViolationEndpoints { public static IEndpointRouteBuilder MapViolationEventsApi(this IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup("/api/policy/violations") .RequireAuthorization() .WithTags("Policy Violations"); group.MapGet(string.Empty, ListViolations) .WithName("ListPolicyViolations") .WithSummary("List policy violations with optional filters.") .Produces(StatusCodes.Status200OK); group.MapGet("/{violationId:guid}", GetViolation) .WithName("GetPolicyViolation") .WithSummary("Get a specific policy violation by ID.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); group.MapGet("/by-policy/{policyId:guid}", GetViolationsByPolicy) .WithName("GetPolicyViolationsByPolicy") .WithSummary("Get violations for a specific policy.") .Produces(StatusCodes.Status200OK); group.MapGet("/by-severity/{severity}", GetViolationsBySeverity) .WithName("GetPolicyViolationsBySeverity") .WithSummary("Get violations filtered by severity level.") .Produces(StatusCodes.Status200OK); group.MapGet("/by-purl/{purl}", GetViolationsByPurl) .WithName("GetPolicyViolationsByPurl") .WithSummary("Get violations for a specific package (by PURL).") .Produces(StatusCodes.Status200OK); group.MapGet("/stats/by-severity", GetViolationStatsBySeverity) .WithName("GetPolicyViolationStatsBySeverity") .WithSummary("Get violation counts grouped by severity.") .Produces(StatusCodes.Status200OK); group.MapPost(string.Empty, AppendViolation) .WithName("AppendPolicyViolation") .WithSummary("Append a new policy violation event (immutable).") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest); group.MapPost("/batch", AppendViolationBatch) .WithName("AppendPolicyViolationBatch") .WithSummary("Append multiple policy violation events in a batch.") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest); return endpoints; } private static async Task ListViolations( HttpContext context, [FromQuery] Guid? policyId, [FromQuery] string? severity, [FromQuery] DateTimeOffset? since, [FromQuery] int limit, [FromQuery] int offset, IViolationEventRepository repository, CancellationToken cancellationToken) { var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead); if (scopeResult is not null) { return scopeResult; } var tenantId = ResolveTenantId(context); if (string.IsNullOrEmpty(tenantId)) { return Results.BadRequest(new ProblemDetails { Title = "Tenant required", Detail = "Tenant ID must be provided.", Status = StatusCodes.Status400BadRequest }); } var effectiveLimit = limit > 0 ? limit : 100; var effectiveOffset = offset > 0 ? offset : 0; IReadOnlyList violations; if (policyId.HasValue) { violations = await repository.GetByPolicyAsync(tenantId, policyId.Value, since, effectiveLimit, effectiveOffset, cancellationToken) .ConfigureAwait(false); } else if (!string.IsNullOrEmpty(severity)) { violations = await repository.GetBySeverityAsync(tenantId, severity, since, effectiveLimit, cancellationToken) .ConfigureAwait(false); } else { // Default: get critical violations violations = await repository.GetBySeverityAsync(tenantId, "critical", since, effectiveLimit, cancellationToken) .ConfigureAwait(false); } var items = violations.Select(v => new ViolationSummary( v.Id, v.PolicyId, v.RuleId, v.Severity, v.SubjectPurl, v.SubjectCve, v.OccurredAt, v.CreatedAt )).ToList(); return Results.Ok(new ViolationListResponse(items, effectiveLimit, effectiveOffset)); } private static async Task GetViolation( HttpContext context, [FromRoute] Guid violationId, IViolationEventRepository repository, CancellationToken cancellationToken) { var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead); if (scopeResult is not null) { return scopeResult; } var tenantId = ResolveTenantId(context); if (string.IsNullOrEmpty(tenantId)) { return Results.BadRequest(new ProblemDetails { Title = "Tenant required", Detail = "Tenant ID must be provided.", Status = StatusCodes.Status400BadRequest }); } var violation = await repository.GetByIdAsync(tenantId, violationId, cancellationToken) .ConfigureAwait(false); if (violation is null) { return Results.NotFound(new ProblemDetails { Title = "Violation not found", Detail = $"Policy violation '{violationId}' was not found.", Status = StatusCodes.Status404NotFound }); } return Results.Ok(new ViolationResponse(violation)); } private static async Task GetViolationsByPolicy( HttpContext context, [FromRoute] Guid policyId, [FromQuery] DateTimeOffset? since, [FromQuery] int limit, [FromQuery] int offset, IViolationEventRepository repository, CancellationToken cancellationToken) { var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead); if (scopeResult is not null) { return scopeResult; } var tenantId = ResolveTenantId(context); if (string.IsNullOrEmpty(tenantId)) { return Results.BadRequest(new ProblemDetails { Title = "Tenant required", Detail = "Tenant ID must be provided.", Status = StatusCodes.Status400BadRequest }); } var effectiveLimit = limit > 0 ? limit : 100; var effectiveOffset = offset > 0 ? offset : 0; var violations = await repository.GetByPolicyAsync(tenantId, policyId, since, effectiveLimit, effectiveOffset, cancellationToken) .ConfigureAwait(false); var items = violations.Select(v => new ViolationSummary( v.Id, v.PolicyId, v.RuleId, v.Severity, v.SubjectPurl, v.SubjectCve, v.OccurredAt, v.CreatedAt )).ToList(); return Results.Ok(new ViolationListResponse(items, effectiveLimit, effectiveOffset)); } private static async Task GetViolationsBySeverity( HttpContext context, [FromRoute] string severity, [FromQuery] DateTimeOffset? since, [FromQuery] int limit, IViolationEventRepository repository, CancellationToken cancellationToken) { var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead); if (scopeResult is not null) { return scopeResult; } var tenantId = ResolveTenantId(context); if (string.IsNullOrEmpty(tenantId)) { return Results.BadRequest(new ProblemDetails { Title = "Tenant required", Detail = "Tenant ID must be provided.", Status = StatusCodes.Status400BadRequest }); } var effectiveLimit = limit > 0 ? limit : 100; var violations = await repository.GetBySeverityAsync(tenantId, severity, since, effectiveLimit, cancellationToken) .ConfigureAwait(false); var items = violations.Select(v => new ViolationSummary( v.Id, v.PolicyId, v.RuleId, v.Severity, v.SubjectPurl, v.SubjectCve, v.OccurredAt, v.CreatedAt )).ToList(); return Results.Ok(new ViolationListResponse(items, effectiveLimit, 0)); } private static async Task GetViolationsByPurl( HttpContext context, [FromRoute] string purl, [FromQuery] int limit, IViolationEventRepository repository, CancellationToken cancellationToken) { var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead); if (scopeResult is not null) { return scopeResult; } var tenantId = ResolveTenantId(context); if (string.IsNullOrEmpty(tenantId)) { return Results.BadRequest(new ProblemDetails { Title = "Tenant required", Detail = "Tenant ID must be provided.", Status = StatusCodes.Status400BadRequest }); } var effectiveLimit = limit > 0 ? limit : 100; var decodedPurl = Uri.UnescapeDataString(purl); var violations = await repository.GetByPurlAsync(tenantId, decodedPurl, effectiveLimit, cancellationToken) .ConfigureAwait(false); var items = violations.Select(v => new ViolationSummary( v.Id, v.PolicyId, v.RuleId, v.Severity, v.SubjectPurl, v.SubjectCve, v.OccurredAt, v.CreatedAt )).ToList(); return Results.Ok(new ViolationListResponse(items, effectiveLimit, 0)); } private static async Task GetViolationStatsBySeverity( HttpContext context, [FromQuery] DateTimeOffset since, [FromQuery] DateTimeOffset until, IViolationEventRepository repository, CancellationToken cancellationToken) { var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead); if (scopeResult is not null) { return scopeResult; } var tenantId = ResolveTenantId(context); if (string.IsNullOrEmpty(tenantId)) { return Results.BadRequest(new ProblemDetails { Title = "Tenant required", Detail = "Tenant ID must be provided.", Status = StatusCodes.Status400BadRequest }); } var stats = await repository.CountBySeverityAsync(tenantId, since, until, cancellationToken) .ConfigureAwait(false); return Results.Ok(new ViolationStatsResponse(stats, since, until)); } private static async Task AppendViolation( HttpContext context, [FromBody] CreateViolationRequest request, IViolationEventRepository repository, TimeProvider timeProvider, IGuidProvider guidProvider, CancellationToken cancellationToken) { var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit); if (scopeResult is not null) { return scopeResult; } var tenantId = ResolveTenantId(context); if (string.IsNullOrEmpty(tenantId)) { return Results.BadRequest(new ProblemDetails { Title = "Tenant required", Detail = "Tenant ID must be provided.", Status = StatusCodes.Status400BadRequest }); } var entity = new ViolationEventEntity { Id = guidProvider.NewGuid(), TenantId = tenantId, PolicyId = request.PolicyId, RuleId = request.RuleId, Severity = request.Severity, SubjectPurl = request.SubjectPurl, SubjectCve = request.SubjectCve, Details = request.Details ?? "{}", Remediation = request.Remediation, CorrelationId = request.CorrelationId, OccurredAt = request.OccurredAt ?? timeProvider.GetUtcNow() }; try { var created = await repository.AppendAsync(entity, cancellationToken).ConfigureAwait(false); return Results.Created($"/api/policy/violations/{created.Id}", new ViolationResponse(created)); } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) { return Results.BadRequest(new ProblemDetails { Title = "Failed to append violation", Detail = ex.Message, Status = StatusCodes.Status400BadRequest }); } } private static async Task AppendViolationBatch( HttpContext context, [FromBody] CreateViolationBatchRequest request, IViolationEventRepository repository, TimeProvider timeProvider, IGuidProvider guidProvider, CancellationToken cancellationToken) { var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit); if (scopeResult is not null) { return scopeResult; } var tenantId = ResolveTenantId(context); if (string.IsNullOrEmpty(tenantId)) { return Results.BadRequest(new ProblemDetails { Title = "Tenant required", Detail = "Tenant ID must be provided.", Status = StatusCodes.Status400BadRequest }); } var now = timeProvider.GetUtcNow(); var entities = request.Violations.Select(v => new ViolationEventEntity { Id = guidProvider.NewGuid(), TenantId = tenantId, PolicyId = v.PolicyId, RuleId = v.RuleId, Severity = v.Severity, SubjectPurl = v.SubjectPurl, SubjectCve = v.SubjectCve, Details = v.Details ?? "{}", Remediation = v.Remediation, CorrelationId = v.CorrelationId, OccurredAt = v.OccurredAt ?? now }).ToList(); try { var count = await repository.AppendBatchAsync(entities, cancellationToken).ConfigureAwait(false); return Results.Created("/api/policy/violations", new ViolationBatchResponse(count)); } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) { return Results.BadRequest(new ProblemDetails { Title = "Failed to append violations", Detail = ex.Message, Status = StatusCodes.Status400BadRequest }); } } private static string? ResolveTenantId(HttpContext context) { if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader) && !string.IsNullOrWhiteSpace(tenantHeader)) { return tenantHeader.ToString(); } return context.User?.FindFirst("tenant_id")?.Value; } } #region Request/Response DTOs internal sealed record ViolationListResponse( IReadOnlyList Violations, int Limit, int Offset); internal sealed record ViolationSummary( Guid Id, Guid PolicyId, string RuleId, string Severity, string? SubjectPurl, string? SubjectCve, DateTimeOffset OccurredAt, DateTimeOffset CreatedAt); internal sealed record ViolationResponse(ViolationEventEntity Violation); internal sealed record ViolationStatsResponse( Dictionary CountBySeverity, DateTimeOffset Since, DateTimeOffset Until); internal sealed record ViolationBatchResponse(int AppendedCount); internal sealed record CreateViolationRequest( Guid PolicyId, string RuleId, string Severity, string? SubjectPurl, string? SubjectCve, string? Details, string? Remediation, string? CorrelationId, DateTimeOffset? OccurredAt); internal sealed record CreateViolationBatchRequest( IReadOnlyList Violations); #endregion