501 lines
17 KiB
C#
501 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Policy violation event endpoints for append-only audit trail.
|
|
/// Violations are immutable records of policy rule violations.
|
|
/// </summary>
|
|
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<ViolationListResponse>(StatusCodes.Status200OK);
|
|
|
|
group.MapGet("/{violationId:guid}", GetViolation)
|
|
.WithName("GetPolicyViolation")
|
|
.WithSummary("Get a specific policy violation by ID.")
|
|
.Produces<ViolationResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/by-policy/{policyId:guid}", GetViolationsByPolicy)
|
|
.WithName("GetPolicyViolationsByPolicy")
|
|
.WithSummary("Get violations for a specific policy.")
|
|
.Produces<ViolationListResponse>(StatusCodes.Status200OK);
|
|
|
|
group.MapGet("/by-severity/{severity}", GetViolationsBySeverity)
|
|
.WithName("GetPolicyViolationsBySeverity")
|
|
.WithSummary("Get violations filtered by severity level.")
|
|
.Produces<ViolationListResponse>(StatusCodes.Status200OK);
|
|
|
|
group.MapGet("/by-purl/{purl}", GetViolationsByPurl)
|
|
.WithName("GetPolicyViolationsByPurl")
|
|
.WithSummary("Get violations for a specific package (by PURL).")
|
|
.Produces<ViolationListResponse>(StatusCodes.Status200OK);
|
|
|
|
group.MapGet("/stats/by-severity", GetViolationStatsBySeverity)
|
|
.WithName("GetPolicyViolationStatsBySeverity")
|
|
.WithSummary("Get violation counts grouped by severity.")
|
|
.Produces<ViolationStatsResponse>(StatusCodes.Status200OK);
|
|
|
|
group.MapPost(string.Empty, AppendViolation)
|
|
.WithName("AppendPolicyViolation")
|
|
.WithSummary("Append a new policy violation event (immutable).")
|
|
.Produces<ViolationResponse>(StatusCodes.Status201Created)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapPost("/batch", AppendViolationBatch)
|
|
.WithName("AppendPolicyViolationBatch")
|
|
.WithSummary("Append multiple policy violation events in a batch.")
|
|
.Produces<ViolationBatchResponse>(StatusCodes.Status201Created)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
|
|
|
return endpoints;
|
|
}
|
|
|
|
private static async Task<IResult> 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<ViolationEventEntity> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<ViolationSummary> 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<string, int> 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<CreateViolationRequest> Violations);
|
|
|
|
#endregion
|