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