Implement InMemory Transport Layer for StellaOps Router

- Added InMemoryTransportOptions class for configuration settings including timeouts and latency.
- Developed InMemoryTransportServer class to handle connections, frame processing, and event management.
- Created ServiceCollectionExtensions for easy registration of InMemory transport services.
- Established project structure and dependencies for InMemory transport library.
- Implemented comprehensive unit tests for endpoint discovery, connection management, request/response flow, and streaming capabilities.
- Ensured proper handling of cancellation, heartbeat, and hello frames within the transport layer.
This commit is contained in:
StellaOps Bot
2025-12-05 01:00:10 +02:00
parent 8768c27f30
commit 175b750e29
111 changed files with 25407 additions and 19242 deletions

View File

@@ -0,0 +1,425 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Policy conflict detection and resolution endpoints.
/// Conflicts track policy rule overlaps and inconsistencies.
/// </summary>
internal static class ConflictEndpoints
{
public static IEndpointRouteBuilder MapConflictsApi(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/policy/conflicts")
.RequireAuthorization()
.WithTags("Policy Conflicts");
group.MapGet(string.Empty, ListOpenConflicts)
.WithName("ListOpenPolicyConflicts")
.WithSummary("List open policy conflicts sorted by severity.")
.Produces<ConflictListResponse>(StatusCodes.Status200OK);
group.MapGet("/{conflictId:guid}", GetConflict)
.WithName("GetPolicyConflict")
.WithSummary("Get a specific policy conflict by ID.")
.Produces<ConflictResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/by-type/{conflictType}", GetConflictsByType)
.WithName("GetPolicyConflictsByType")
.WithSummary("Get conflicts filtered by type.")
.Produces<ConflictListResponse>(StatusCodes.Status200OK);
group.MapGet("/stats/by-severity", GetConflictStatsBySeverity)
.WithName("GetPolicyConflictStatsBySeverity")
.WithSummary("Get open conflict counts grouped by severity.")
.Produces<ConflictStatsResponse>(StatusCodes.Status200OK);
group.MapPost(string.Empty, CreateConflict)
.WithName("CreatePolicyConflict")
.WithSummary("Report a new policy conflict.")
.Produces<ConflictResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/{conflictId:guid}:resolve", ResolveConflict)
.WithName("ResolvePolicyConflict")
.WithSummary("Resolve an open conflict with a resolution description.")
.Produces<ConflictActionResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost("/{conflictId:guid}:dismiss", DismissConflict)
.WithName("DismissPolicyConflict")
.WithSummary("Dismiss an open conflict without resolution.")
.Produces<ConflictActionResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
return endpoints;
}
private static async Task<IResult> ListOpenConflicts(
HttpContext context,
[FromQuery] int limit,
[FromQuery] int offset,
IConflictRepository 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 conflicts = await repository.GetOpenAsync(tenantId, effectiveLimit, effectiveOffset, cancellationToken)
.ConfigureAwait(false);
var items = conflicts.Select(c => new ConflictSummary(
c.Id,
c.ConflictType,
c.Severity,
c.Status,
c.LeftRuleId,
c.RightRuleId,
c.AffectedScope,
c.Description,
c.CreatedAt
)).ToList();
return Results.Ok(new ConflictListResponse(items, effectiveLimit, effectiveOffset));
}
private static async Task<IResult> GetConflict(
HttpContext context,
[FromRoute] Guid conflictId,
IConflictRepository 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 conflict = await repository.GetByIdAsync(tenantId, conflictId, cancellationToken)
.ConfigureAwait(false);
if (conflict is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Conflict not found",
Detail = $"Policy conflict '{conflictId}' was not found.",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(new ConflictResponse(conflict));
}
private static async Task<IResult> GetConflictsByType(
HttpContext context,
[FromRoute] string conflictType,
[FromQuery] string? status,
[FromQuery] int limit,
IConflictRepository 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 conflicts = await repository.GetByTypeAsync(tenantId, conflictType, status, effectiveLimit, cancellationToken)
.ConfigureAwait(false);
var items = conflicts.Select(c => new ConflictSummary(
c.Id,
c.ConflictType,
c.Severity,
c.Status,
c.LeftRuleId,
c.RightRuleId,
c.AffectedScope,
c.Description,
c.CreatedAt
)).ToList();
return Results.Ok(new ConflictListResponse(items, effectiveLimit, 0));
}
private static async Task<IResult> GetConflictStatsBySeverity(
HttpContext context,
IConflictRepository 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.CountOpenBySeverityAsync(tenantId, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new ConflictStatsResponse(stats));
}
private static async Task<IResult> CreateConflict(
HttpContext context,
[FromBody] CreateConflictRequest request,
IConflictRepository repository,
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 actorId = ResolveActorId(context);
var entity = new ConflictEntity
{
Id = Guid.NewGuid(),
TenantId = tenantId,
ConflictType = request.ConflictType,
Severity = request.Severity,
Status = "open",
LeftRuleId = request.LeftRuleId,
RightRuleId = request.RightRuleId,
AffectedScope = request.AffectedScope,
Description = request.Description,
Metadata = request.Metadata ?? "{}",
CreatedBy = actorId
};
try
{
var created = await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/policy/conflicts/{created.Id}", new ConflictResponse(created));
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Failed to create conflict",
Detail = ex.Message,
Status = StatusCodes.Status400BadRequest
});
}
}
private static async Task<IResult> ResolveConflict(
HttpContext context,
[FromRoute] Guid conflictId,
[FromBody] ResolveConflictRequest request,
IConflictRepository repository,
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 actorId = ResolveActorId(context) ?? "system";
if (string.IsNullOrWhiteSpace(request.Resolution))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Resolution required",
Detail = "A resolution description is required to resolve a conflict.",
Status = StatusCodes.Status400BadRequest
});
}
var resolved = await repository.ResolveAsync(tenantId, conflictId, request.Resolution, actorId, cancellationToken)
.ConfigureAwait(false);
if (!resolved)
{
return Results.NotFound(new ProblemDetails
{
Title = "Conflict not found or already resolved",
Detail = $"Policy conflict '{conflictId}' was not found or is not in open status.",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(new ConflictActionResponse(conflictId, "resolved", actorId));
}
private static async Task<IResult> DismissConflict(
HttpContext context,
[FromRoute] Guid conflictId,
IConflictRepository repository,
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 actorId = ResolveActorId(context) ?? "system";
var dismissed = await repository.DismissAsync(tenantId, conflictId, actorId, cancellationToken)
.ConfigureAwait(false);
if (!dismissed)
{
return Results.NotFound(new ProblemDetails
{
Title = "Conflict not found or already resolved",
Detail = $"Policy conflict '{conflictId}' was not found or is not in open status.",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(new ConflictActionResponse(conflictId, "dismissed", actorId));
}
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;
}
private static string? ResolveActorId(HttpContext context)
{
var user = context.User;
return user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? user?.FindFirst("sub")?.Value;
}
}
#region Request/Response DTOs
internal sealed record ConflictListResponse(
IReadOnlyList<ConflictSummary> Conflicts,
int Limit,
int Offset);
internal sealed record ConflictSummary(
Guid Id,
string ConflictType,
string Severity,
string Status,
string? LeftRuleId,
string? RightRuleId,
string? AffectedScope,
string Description,
DateTimeOffset CreatedAt);
internal sealed record ConflictResponse(ConflictEntity Conflict);
internal sealed record ConflictStatsResponse(Dictionary<string, int> CountBySeverity);
internal sealed record ConflictActionResponse(Guid ConflictId, string Action, string ActorId);
internal sealed record CreateConflictRequest(
string ConflictType,
string Severity,
string? LeftRuleId,
string? RightRuleId,
string? AffectedScope,
string Description,
string? Metadata);
internal sealed record ResolveConflictRequest(string Resolution);
#endregion

View File

@@ -0,0 +1,299 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Policy snapshot endpoints for versioned policy state capture.
/// </summary>
internal static class SnapshotEndpoints
{
public static IEndpointRouteBuilder MapPolicySnapshotsApi(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/policy/snapshots")
.RequireAuthorization()
.WithTags("Policy Snapshots");
group.MapGet(string.Empty, ListSnapshots)
.WithName("ListPolicySnapshots")
.WithSummary("List policy snapshots for a policy.")
.Produces<SnapshotListResponse>(StatusCodes.Status200OK);
group.MapGet("/{snapshotId:guid}", GetSnapshot)
.WithName("GetPolicySnapshot")
.WithSummary("Get a specific policy snapshot by ID.")
.Produces<SnapshotResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/by-digest/{digest}", GetSnapshotByDigest)
.WithName("GetPolicySnapshotByDigest")
.WithSummary("Get a policy snapshot by content digest.")
.Produces<SnapshotResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost(string.Empty, CreateSnapshot)
.WithName("CreatePolicySnapshot")
.WithSummary("Create a new policy snapshot.")
.Produces<SnapshotResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapDelete("/{snapshotId:guid}", DeleteSnapshot)
.WithName("DeletePolicySnapshot")
.WithSummary("Delete a policy snapshot.")
.Produces(StatusCodes.Status204NoContent)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
return endpoints;
}
private static async Task<IResult> ListSnapshots(
HttpContext context,
[FromQuery] Guid policyId,
[FromQuery] int limit,
[FromQuery] int offset,
ISnapshotRepository 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 snapshots = await repository.GetByPolicyAsync(tenantId, policyId, effectiveLimit, effectiveOffset, cancellationToken)
.ConfigureAwait(false);
var items = snapshots.Select(s => new SnapshotSummary(
s.Id,
s.PolicyId,
s.Version,
s.ContentDigest,
s.CreatedAt,
s.CreatedBy
)).ToList();
return Results.Ok(new SnapshotListResponse(items, policyId, effectiveLimit, effectiveOffset));
}
private static async Task<IResult> GetSnapshot(
HttpContext context,
[FromRoute] Guid snapshotId,
ISnapshotRepository 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 snapshot = await repository.GetByIdAsync(tenantId, snapshotId, cancellationToken)
.ConfigureAwait(false);
if (snapshot is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Snapshot not found",
Detail = $"Policy snapshot '{snapshotId}' was not found.",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(new SnapshotResponse(snapshot));
}
private static async Task<IResult> GetSnapshotByDigest(
HttpContext context,
[FromRoute] string digest,
ISnapshotRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var snapshot = await repository.GetByDigestAsync(digest, cancellationToken)
.ConfigureAwait(false);
if (snapshot is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Snapshot not found",
Detail = $"Policy snapshot with digest '{digest}' was not found.",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(new SnapshotResponse(snapshot));
}
private static async Task<IResult> CreateSnapshot(
HttpContext context,
[FromBody] CreateSnapshotRequest request,
ISnapshotRepository repository,
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 actorId = ResolveActorId(context) ?? "system";
var entity = new SnapshotEntity
{
Id = Guid.NewGuid(),
TenantId = tenantId,
PolicyId = request.PolicyId,
Version = request.Version,
ContentDigest = request.ContentDigest,
Content = request.Content,
Metadata = request.Metadata ?? "{}",
CreatedBy = actorId
};
try
{
var created = await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/policy/snapshots/{created.Id}", new SnapshotResponse(created));
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Failed to create snapshot",
Detail = ex.Message,
Status = StatusCodes.Status400BadRequest
});
}
}
private static async Task<IResult> DeleteSnapshot(
HttpContext context,
[FromRoute] Guid snapshotId,
ISnapshotRepository repository,
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 deleted = await repository.DeleteAsync(tenantId, snapshotId, cancellationToken)
.ConfigureAwait(false);
if (!deleted)
{
return Results.NotFound(new ProblemDetails
{
Title = "Snapshot not found",
Detail = $"Policy snapshot '{snapshotId}' was not found.",
Status = StatusCodes.Status404NotFound
});
}
return Results.NoContent();
}
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;
}
private static string? ResolveActorId(HttpContext context)
{
var user = context.User;
return user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? user?.FindFirst("sub")?.Value;
}
}
#region Request/Response DTOs
internal sealed record SnapshotListResponse(
IReadOnlyList<SnapshotSummary> Snapshots,
Guid PolicyId,
int Limit,
int Offset);
internal sealed record SnapshotSummary(
Guid Id,
Guid PolicyId,
int Version,
string ContentDigest,
DateTimeOffset CreatedAt,
string CreatedBy);
internal sealed record SnapshotResponse(SnapshotEntity Snapshot);
internal sealed record CreateSnapshotRequest(
Guid PolicyId,
int Version,
string ContentDigest,
string Content,
string? Metadata);
#endregion

View File

@@ -0,0 +1,494 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.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,
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 = Guid.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 ?? DateTimeOffset.UtcNow
};
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,
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 entities = request.Violations.Select(v => new ViolationEventEntity
{
Id = Guid.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 ?? DateTimeOffset.UtcNow
}).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