455 lines
19 KiB
C#
455 lines
19 KiB
C#
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
using StellaOps.Scheduler.Models;
|
|
using StellaOps.Scheduler.WebService.Auth;
|
|
using StellaOps.Scheduler.WebService.PolicyRuns;
|
|
using StellaOps.Scheduler.WebService.Security;
|
|
using System.Collections.Immutable;
|
|
using System.ComponentModel.DataAnnotations;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace StellaOps.Scheduler.WebService.PolicySimulations;
|
|
|
|
internal static class PolicySimulationEndpointExtensions
|
|
{
|
|
private const string Scope = StellaOpsScopes.PolicySimulate;
|
|
|
|
public static void MapPolicySimulationEndpoints(this IEndpointRouteBuilder builder)
|
|
{
|
|
var group = builder.MapGroup("/api/v1/scheduler/policies/simulations")
|
|
.RequireAuthorization(SchedulerPolicies.Operate)
|
|
.RequireTenant();
|
|
|
|
group.MapGet("/", ListSimulationsAsync)
|
|
.WithName("ListPolicySimulations")
|
|
.WithDescription("Lists policy simulation runs for the tenant with optional filters by status and time range. Simulations are policy evaluations run in dry-run mode without committing verdicts. Requires policy.simulate scope.");
|
|
group.MapGet("/{simulationId}", GetSimulationAsync)
|
|
.WithName("GetPolicySimulation")
|
|
.WithDescription("Returns the full simulation run record for a specific simulation ID including status, policy reference, inputs, and projected verdict counts. Returns 404 if the simulation ID is not found. Requires policy.simulate scope.");
|
|
group.MapGet("/{simulationId}/stream", StreamSimulationAsync)
|
|
.WithName("StreamSimulationEvents")
|
|
.WithDescription("Server-Sent Events stream of real-time simulation progress events for a specific simulation ID. Clients should use the Last-Event-ID header for reconnect. Requires policy.simulate scope.");
|
|
group.MapGet("/metrics", GetMetricsAsync)
|
|
.WithName("GetSimulationMetrics")
|
|
.WithDescription("Returns aggregated simulation throughput metrics for the tenant including queue depth, processing rates, and median latency. Returns 501 if the metrics provider is not configured. Requires policy.simulate scope.");
|
|
group.MapPost("/", CreateSimulationAsync)
|
|
.WithName("CreatePolicySimulation")
|
|
.WithDescription("Enqueues a new policy simulation for the specified policy ID and version with the given SBOM input set. Returns 201 Created with the simulation ID and initial queued status. Requires policy.simulate scope.");
|
|
group.MapPost("/preview", PreviewSimulationAsync)
|
|
.WithName("PreviewPolicySimulation")
|
|
.WithDescription("Enqueues a simulation and returns an immediate preview of the candidate count and estimated run scope before results are computed. Returns 201 Created with the simulation reference and preview payload. Requires policy.simulate scope.");
|
|
group.MapPost("/{simulationId}/cancel", CancelSimulationAsync)
|
|
.WithName("CancelPolicySimulation")
|
|
.WithDescription("Requests cancellation of a queued or running simulation. Returns 200 with the updated simulation record, or 404 if the simulation ID is not found. Requires policy.simulate scope.");
|
|
group.MapPost("/{simulationId}/retry", RetrySimulationAsync)
|
|
.WithName("RetryPolicySimulation")
|
|
.WithDescription("Retries a failed simulation, creating a new simulation record that re-uses the same policy and input configuration. Returns 201 Created with the new simulation ID. Requires policy.simulate scope.");
|
|
}
|
|
|
|
private static async Task<IResult> ListSimulationsAsync(
|
|
HttpContext httpContext,
|
|
[FromServices] ITenantContextAccessor tenantAccessor,
|
|
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
|
[FromServices] IPolicyRunService policyRunService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
|
var tenant = tenantAccessor.GetTenant(httpContext);
|
|
var options = PolicyRunQueryOptions
|
|
.FromRequest(httpContext.Request)
|
|
.ForceMode(PolicyRunMode.Simulate);
|
|
|
|
var simulations = await policyRunService
|
|
.ListAsync(tenant.TenantId, options, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return Results.Ok(new PolicySimulationCollectionResponse(simulations));
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
|
}
|
|
catch (ValidationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> GetSimulationAsync(
|
|
HttpContext httpContext,
|
|
string simulationId,
|
|
[FromServices] ITenantContextAccessor tenantAccessor,
|
|
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
|
[FromServices] IPolicyRunService policyRunService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
|
var tenant = tenantAccessor.GetTenant(httpContext);
|
|
|
|
var simulation = await policyRunService
|
|
.GetAsync(tenant.TenantId, simulationId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return simulation is null
|
|
? Results.NotFound()
|
|
: Results.Ok(new PolicySimulationResponse(simulation));
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
|
}
|
|
catch (ValidationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> GetMetricsAsync(
|
|
HttpContext httpContext,
|
|
[FromServices] ITenantContextAccessor tenantAccessor,
|
|
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
|
[FromServices] IPolicySimulationMetricsProvider? metricsProvider,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
|
var tenant = tenantAccessor.GetTenant(httpContext);
|
|
|
|
if (metricsProvider is null)
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status501NotImplemented);
|
|
}
|
|
|
|
var metrics = await metricsProvider
|
|
.CaptureAsync(tenant.TenantId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return Results.Ok(metrics);
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
|
}
|
|
catch (ValidationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> CreateSimulationAsync(
|
|
HttpContext httpContext,
|
|
PolicySimulationCreateRequest request,
|
|
[FromServices] ITenantContextAccessor tenantAccessor,
|
|
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
|
[FromServices] IPolicyRunService policyRunService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
|
var tenant = tenantAccessor.GetTenant(httpContext);
|
|
var actor = SchedulerEndpointHelpers.ResolveActorId(httpContext);
|
|
|
|
if (string.IsNullOrWhiteSpace(request.PolicyId))
|
|
{
|
|
throw new ValidationException("policyId must be provided.");
|
|
}
|
|
|
|
if (request.PolicyVersion is null || request.PolicyVersion <= 0)
|
|
{
|
|
throw new ValidationException("policyVersion must be provided and greater than zero.");
|
|
}
|
|
|
|
var normalizedMetadata = NormalizeMetadata(request.Metadata);
|
|
var inputs = request.Inputs ?? PolicyRunInputs.Empty;
|
|
|
|
var policyRequest = new PolicyRunRequest(
|
|
tenant.TenantId,
|
|
request.PolicyId,
|
|
PolicyRunMode.Simulate,
|
|
inputs,
|
|
request.Priority,
|
|
runId: null,
|
|
policyVersion: request.PolicyVersion,
|
|
requestedBy: actor,
|
|
queuedAt: null,
|
|
correlationId: request.CorrelationId,
|
|
metadata: normalizedMetadata);
|
|
|
|
var status = await policyRunService
|
|
.EnqueueAsync(tenant.TenantId, policyRequest, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return Results.Created(
|
|
$"/api/v1/scheduler/policies/simulations/{status.RunId}",
|
|
new PolicySimulationResponse(status));
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
|
}
|
|
catch (ValidationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> PreviewSimulationAsync(
|
|
HttpContext httpContext,
|
|
PolicySimulationCreateRequest request,
|
|
[FromServices] ITenantContextAccessor tenantAccessor,
|
|
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
|
[FromServices] IPolicyRunService policyRunService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
|
var tenant = tenantAccessor.GetTenant(httpContext);
|
|
var actor = SchedulerEndpointHelpers.ResolveActorId(httpContext);
|
|
|
|
if (string.IsNullOrWhiteSpace(request.PolicyId))
|
|
{
|
|
throw new ValidationException("policyId must be provided.");
|
|
}
|
|
|
|
if (request.PolicyVersion is null || request.PolicyVersion <= 0)
|
|
{
|
|
throw new ValidationException("policyVersion must be provided and greater than zero.");
|
|
}
|
|
|
|
var normalizedMetadata = NormalizeMetadata(request.Metadata);
|
|
var inputs = request.Inputs ?? PolicyRunInputs.Empty;
|
|
|
|
var policyRequest = new PolicyRunRequest(
|
|
tenant.TenantId,
|
|
request.PolicyId,
|
|
PolicyRunMode.Simulate,
|
|
inputs,
|
|
request.Priority,
|
|
runId: null,
|
|
policyVersion: request.PolicyVersion,
|
|
requestedBy: actor,
|
|
queuedAt: null,
|
|
correlationId: request.CorrelationId,
|
|
metadata: normalizedMetadata);
|
|
|
|
var status = await policyRunService
|
|
.EnqueueAsync(tenant.TenantId, policyRequest, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var preview = new
|
|
{
|
|
candidates = inputs.SbomSet.Length,
|
|
estimatedRuns = inputs.SbomSet.Length,
|
|
message = "preview pending execution; actual diff will be available once job starts"
|
|
};
|
|
|
|
return Results.Created(
|
|
$"/api/v1/scheduler/policies/simulations/{status.RunId}",
|
|
new { simulation = new PolicySimulationResponse(status), preview });
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
|
}
|
|
catch (ValidationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> CancelSimulationAsync(
|
|
HttpContext httpContext,
|
|
string simulationId,
|
|
PolicySimulationCancelRequest? request,
|
|
[FromServices] ITenantContextAccessor tenantAccessor,
|
|
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
|
[FromServices] IPolicyRunService policyRunService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
|
var tenant = tenantAccessor.GetTenant(httpContext);
|
|
var cancellation = await policyRunService
|
|
.RequestCancellationAsync(tenant.TenantId, simulationId, request?.Reason, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return cancellation is null
|
|
? Results.NotFound()
|
|
: Results.Ok(new PolicySimulationResponse(cancellation));
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
|
}
|
|
catch (ValidationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> RetrySimulationAsync(
|
|
HttpContext httpContext,
|
|
string simulationId,
|
|
[FromServices] ITenantContextAccessor tenantAccessor,
|
|
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
|
[FromServices] IPolicyRunService policyRunService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
|
var tenant = tenantAccessor.GetTenant(httpContext);
|
|
var actor = SchedulerEndpointHelpers.ResolveActorId(httpContext);
|
|
|
|
var status = await policyRunService
|
|
.RetryAsync(tenant.TenantId, simulationId, actor, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return Results.Created(
|
|
$"/api/v1/scheduler/policies/simulations/{status.RunId}",
|
|
new PolicySimulationResponse(status));
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status409Conflict);
|
|
}
|
|
catch (ValidationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task StreamSimulationAsync(
|
|
HttpContext httpContext,
|
|
string simulationId,
|
|
[FromServices] ITenantContextAccessor tenantAccessor,
|
|
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
|
[FromServices] IPolicyRunService policyRunService,
|
|
[FromServices] IPolicySimulationStreamCoordinator streamCoordinator,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
|
var tenant = tenantAccessor.GetTenant(httpContext);
|
|
|
|
var simulation = await policyRunService
|
|
.GetAsync(tenant.TenantId, simulationId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (simulation is null)
|
|
{
|
|
await Results.NotFound().ExecuteAsync(httpContext);
|
|
return;
|
|
}
|
|
|
|
await streamCoordinator
|
|
.StreamAsync(httpContext, tenant.TenantId, simulation, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
await Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized)
|
|
.ExecuteAsync(httpContext);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
await Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden)
|
|
.ExecuteAsync(httpContext);
|
|
}
|
|
catch (ValidationException ex)
|
|
{
|
|
await Results.BadRequest(new { error = ex.Message }).ExecuteAsync(httpContext);
|
|
}
|
|
}
|
|
|
|
private static ImmutableSortedDictionary<string, string>? NormalizeMetadata(IReadOnlyDictionary<string, string>? metadata)
|
|
{
|
|
if (metadata is null || metadata.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
|
foreach (var (key, value) in metadata)
|
|
{
|
|
var normalizedKey = key?.Trim();
|
|
var normalizedValue = value?.Trim();
|
|
if (string.IsNullOrEmpty(normalizedKey) || string.IsNullOrEmpty(normalizedValue))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var lowerKey = normalizedKey.ToLowerInvariant();
|
|
if (!builder.ContainsKey(lowerKey))
|
|
{
|
|
builder[lowerKey] = normalizedValue;
|
|
}
|
|
}
|
|
|
|
return builder.Count == 0 ? null : builder.ToImmutable();
|
|
}
|
|
}
|
|
|
|
internal sealed record PolicySimulationCreateRequest(
|
|
[property: JsonPropertyName("policyId")] string PolicyId,
|
|
[property: JsonPropertyName("policyVersion")] int? PolicyVersion,
|
|
[property: JsonPropertyName("priority")] PolicyRunPriority Priority = PolicyRunPriority.Normal,
|
|
[property: JsonPropertyName("correlationId")] string? CorrelationId = null,
|
|
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string>? Metadata = null,
|
|
[property: JsonPropertyName("inputs")] PolicyRunInputs? Inputs = null);
|
|
|
|
internal sealed record PolicySimulationCancelRequest(
|
|
[property: JsonPropertyName("reason")] string? Reason);
|
|
|
|
internal sealed record PolicySimulationCollectionResponse(
|
|
[property: JsonPropertyName("simulations")] IReadOnlyList<PolicyRunStatus> Simulations);
|
|
|
|
internal sealed record PolicySimulationResponse(
|
|
[property: JsonPropertyName("simulation")] PolicyRunStatus Simulation);
|