up
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Simulation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class RiskSimulationEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRiskSimulation(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/risk/simulation")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Risk Simulation");
|
||||
|
||||
group.MapPost("/", RunSimulation)
|
||||
.WithName("RunRiskSimulation")
|
||||
.WithSummary("Run a risk simulation with score distributions and contribution breakdowns.")
|
||||
.Produces<RiskSimulationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/quick", RunQuickSimulation)
|
||||
.WithName("RunQuickRiskSimulation")
|
||||
.WithSummary("Run a quick risk simulation without detailed breakdowns.")
|
||||
.Produces<QuickSimulationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/compare", CompareProfiles)
|
||||
.WithName("CompareProfileSimulations")
|
||||
.WithSummary("Compare risk scoring between two profile configurations.")
|
||||
.Produces<ProfileComparisonResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/whatif", RunWhatIfSimulation)
|
||||
.WithName("RunWhatIfSimulation")
|
||||
.WithSummary("Run a what-if simulation with hypothetical signal changes.")
|
||||
.Produces<WhatIfSimulationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult RunSimulation(
|
||||
HttpContext context,
|
||||
[FromBody] RiskSimulationRequest request,
|
||||
RiskSimulationService simulationService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.ProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "ProfileId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request.Findings == null || request.Findings.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "At least one finding is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = simulationService.Simulate(request);
|
||||
return Results.Ok(new RiskSimulationResponse(result));
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult RunQuickSimulation(
|
||||
HttpContext context,
|
||||
[FromBody] QuickSimulationRequest request,
|
||||
RiskSimulationService simulationService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.ProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "ProfileId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var fullRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.ProfileId,
|
||||
ProfileVersion: request.ProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: false,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Quick);
|
||||
|
||||
try
|
||||
{
|
||||
var result = simulationService.Simulate(fullRequest);
|
||||
|
||||
var quickResponse = new QuickSimulationResponse(
|
||||
SimulationId: result.SimulationId,
|
||||
ProfileId: result.ProfileId,
|
||||
ProfileVersion: result.ProfileVersion,
|
||||
Timestamp: result.Timestamp,
|
||||
AggregateMetrics: result.AggregateMetrics,
|
||||
Distribution: result.Distribution,
|
||||
ExecutionTimeMs: result.ExecutionTimeMs);
|
||||
|
||||
return Results.Ok(quickResponse);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CompareProfiles(
|
||||
HttpContext context,
|
||||
[FromBody] ProfileComparisonRequest request,
|
||||
RiskSimulationService simulationService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null ||
|
||||
string.IsNullOrWhiteSpace(request.BaseProfileId) ||
|
||||
string.IsNullOrWhiteSpace(request.CompareProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Both BaseProfileId and CompareProfileId are required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var baseRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.BaseProfileId,
|
||||
ProfileVersion: request.BaseProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full);
|
||||
|
||||
var compareRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.CompareProfileId,
|
||||
ProfileVersion: request.CompareProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full);
|
||||
|
||||
var baseResult = simulationService.Simulate(baseRequest);
|
||||
var compareResult = simulationService.Simulate(compareRequest);
|
||||
|
||||
var deltas = ComputeDeltas(baseResult, compareResult);
|
||||
|
||||
return Results.Ok(new ProfileComparisonResponse(
|
||||
BaseProfile: new ProfileSimulationSummary(
|
||||
baseResult.ProfileId,
|
||||
baseResult.ProfileVersion,
|
||||
baseResult.AggregateMetrics),
|
||||
CompareProfile: new ProfileSimulationSummary(
|
||||
compareResult.ProfileId,
|
||||
compareResult.ProfileVersion,
|
||||
compareResult.AggregateMetrics),
|
||||
Deltas: deltas));
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult RunWhatIfSimulation(
|
||||
HttpContext context,
|
||||
[FromBody] WhatIfSimulationRequest request,
|
||||
RiskSimulationService simulationService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.ProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "ProfileId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Run baseline simulation
|
||||
var baselineRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.ProfileId,
|
||||
ProfileVersion: request.ProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full);
|
||||
|
||||
var baselineResult = simulationService.Simulate(baselineRequest);
|
||||
|
||||
// Apply hypothetical changes to findings and re-simulate
|
||||
var modifiedFindings = ApplyHypotheticalChanges(request.Findings, request.HypotheticalChanges);
|
||||
|
||||
var modifiedRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.ProfileId,
|
||||
ProfileVersion: request.ProfileVersion,
|
||||
Findings: modifiedFindings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.WhatIf);
|
||||
|
||||
var modifiedResult = simulationService.Simulate(modifiedRequest);
|
||||
|
||||
return Results.Ok(new WhatIfSimulationResponse(
|
||||
BaselineResult: baselineResult,
|
||||
ModifiedResult: modifiedResult,
|
||||
ImpactSummary: ComputeImpactSummary(baselineResult, modifiedResult)));
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static ComparisonDeltas ComputeDeltas(
|
||||
RiskSimulationResult baseResult,
|
||||
RiskSimulationResult compareResult)
|
||||
{
|
||||
return new ComparisonDeltas(
|
||||
MeanScoreDelta: compareResult.AggregateMetrics.MeanScore - baseResult.AggregateMetrics.MeanScore,
|
||||
MedianScoreDelta: compareResult.AggregateMetrics.MedianScore - baseResult.AggregateMetrics.MedianScore,
|
||||
CriticalCountDelta: compareResult.AggregateMetrics.CriticalCount - baseResult.AggregateMetrics.CriticalCount,
|
||||
HighCountDelta: compareResult.AggregateMetrics.HighCount - baseResult.AggregateMetrics.HighCount,
|
||||
MediumCountDelta: compareResult.AggregateMetrics.MediumCount - baseResult.AggregateMetrics.MediumCount,
|
||||
LowCountDelta: compareResult.AggregateMetrics.LowCount - baseResult.AggregateMetrics.LowCount);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SimulationFinding> ApplyHypotheticalChanges(
|
||||
IReadOnlyList<SimulationFinding> findings,
|
||||
IReadOnlyList<HypotheticalChange> changes)
|
||||
{
|
||||
var result = new List<SimulationFinding>();
|
||||
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
var modifiedSignals = new Dictionary<string, object?>(finding.Signals);
|
||||
|
||||
foreach (var change in changes)
|
||||
{
|
||||
if (change.ApplyToAll || change.FindingIds.Contains(finding.FindingId))
|
||||
{
|
||||
modifiedSignals[change.SignalName] = change.NewValue;
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(finding with { Signals = modifiedSignals });
|
||||
}
|
||||
|
||||
return result.AsReadOnly();
|
||||
}
|
||||
|
||||
private static WhatIfImpactSummary ComputeImpactSummary(
|
||||
RiskSimulationResult baseline,
|
||||
RiskSimulationResult modified)
|
||||
{
|
||||
var baseScores = baseline.FindingScores.ToDictionary(f => f.FindingId, f => f.NormalizedScore);
|
||||
var modScores = modified.FindingScores.ToDictionary(f => f.FindingId, f => f.NormalizedScore);
|
||||
|
||||
var improved = 0;
|
||||
var worsened = 0;
|
||||
var unchanged = 0;
|
||||
var totalDelta = 0.0;
|
||||
|
||||
foreach (var (findingId, baseScore) in baseScores)
|
||||
{
|
||||
if (modScores.TryGetValue(findingId, out var modScore))
|
||||
{
|
||||
var delta = modScore - baseScore;
|
||||
totalDelta += delta;
|
||||
|
||||
if (Math.Abs(delta) < 0.1)
|
||||
unchanged++;
|
||||
else if (delta < 0)
|
||||
improved++;
|
||||
else
|
||||
worsened++;
|
||||
}
|
||||
}
|
||||
|
||||
return new WhatIfImpactSummary(
|
||||
FindingsImproved: improved,
|
||||
FindingsWorsened: worsened,
|
||||
FindingsUnchanged: unchanged,
|
||||
AverageScoreDelta: baseline.FindingScores.Count > 0
|
||||
? totalDelta / baseline.FindingScores.Count
|
||||
: 0,
|
||||
SeverityShifts: new SeverityShifts(
|
||||
ToLower: improved,
|
||||
ToHigher: worsened,
|
||||
Unchanged: unchanged));
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
internal sealed record RiskSimulationResponse(RiskSimulationResult Result);
|
||||
|
||||
internal sealed record QuickSimulationRequest(
|
||||
string ProfileId,
|
||||
string? ProfileVersion,
|
||||
IReadOnlyList<SimulationFinding> Findings);
|
||||
|
||||
internal sealed record QuickSimulationResponse(
|
||||
string SimulationId,
|
||||
string ProfileId,
|
||||
string ProfileVersion,
|
||||
DateTimeOffset Timestamp,
|
||||
AggregateRiskMetrics AggregateMetrics,
|
||||
RiskDistribution? Distribution,
|
||||
double ExecutionTimeMs);
|
||||
|
||||
internal sealed record ProfileComparisonRequest(
|
||||
string BaseProfileId,
|
||||
string? BaseProfileVersion,
|
||||
string CompareProfileId,
|
||||
string? CompareProfileVersion,
|
||||
IReadOnlyList<SimulationFinding> Findings);
|
||||
|
||||
internal sealed record ProfileComparisonResponse(
|
||||
ProfileSimulationSummary BaseProfile,
|
||||
ProfileSimulationSummary CompareProfile,
|
||||
ComparisonDeltas Deltas);
|
||||
|
||||
internal sealed record ProfileSimulationSummary(
|
||||
string ProfileId,
|
||||
string ProfileVersion,
|
||||
AggregateRiskMetrics Metrics);
|
||||
|
||||
internal sealed record ComparisonDeltas(
|
||||
double MeanScoreDelta,
|
||||
double MedianScoreDelta,
|
||||
int CriticalCountDelta,
|
||||
int HighCountDelta,
|
||||
int MediumCountDelta,
|
||||
int LowCountDelta);
|
||||
|
||||
internal sealed record WhatIfSimulationRequest(
|
||||
string ProfileId,
|
||||
string? ProfileVersion,
|
||||
IReadOnlyList<SimulationFinding> Findings,
|
||||
IReadOnlyList<HypotheticalChange> HypotheticalChanges);
|
||||
|
||||
internal sealed record HypotheticalChange(
|
||||
string SignalName,
|
||||
object? NewValue,
|
||||
bool ApplyToAll = true,
|
||||
IReadOnlyList<string>? FindingIds = null)
|
||||
{
|
||||
public IReadOnlyList<string> FindingIds { get; init; } = FindingIds ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
internal sealed record WhatIfSimulationResponse(
|
||||
RiskSimulationResult BaselineResult,
|
||||
RiskSimulationResult ModifiedResult,
|
||||
WhatIfImpactSummary ImpactSummary);
|
||||
|
||||
internal sealed record WhatIfImpactSummary(
|
||||
int FindingsImproved,
|
||||
int FindingsWorsened,
|
||||
int FindingsUnchanged,
|
||||
double AverageScoreDelta,
|
||||
SeverityShifts SeverityShifts);
|
||||
|
||||
internal sealed record SeverityShifts(
|
||||
int ToLower,
|
||||
int ToHigher,
|
||||
int Unchanged);
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user