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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -290,4 +290,9 @@ app.MapOverrides();
|
||||
app.MapProfileExport();
|
||||
app.MapProfileEvents();
|
||||
|
||||
// Phase 5: Multi-tenant PostgreSQL-backed API endpoints
|
||||
app.MapPolicySnapshotsApi();
|
||||
app.MapViolationEventsApi();
|
||||
app.MapConflictsApi();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<ProjectReference Include="../../Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Policy.Engine.Tests" />
|
||||
|
||||
Reference in New Issue
Block a user