- Added IIssuerDirectory interface for managing VEX document issuers, including methods for registration, revocation, and trust validation. - Created InMemoryIssuerDirectory class as an in-memory implementation of IIssuerDirectory for testing and single-instance deployments. - Introduced ISignatureVerifier interface for verifying signatures on VEX documents, with support for multiple signature formats. - Developed SignatureVerifier class as the default implementation of ISignatureVerifier, allowing extensibility for different signature formats. - Implemented handlers for DSSE and JWS signature formats, including methods for verification and signature extraction. - Defined various records and enums for issuer and signature metadata, enhancing the structure and clarity of the verification process.
870 lines
32 KiB
C#
870 lines
32 KiB
C#
using Microsoft.AspNetCore.Http.HttpResults;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Policy.Engine.Options;
|
|
using StellaOps.Policy.Engine.Services;
|
|
using StellaOps.Policy.Engine.Simulation;
|
|
|
|
namespace StellaOps.Policy.Engine.Endpoints;
|
|
|
|
/// <summary>
|
|
/// Risk simulation endpoints for Policy Engine and Policy Studio.
|
|
/// Enhanced with detailed analytics per POLICY-RISK-68-001.
|
|
/// </summary>
|
|
internal static class RiskSimulationEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapRiskSimulation(this IEndpointRouteBuilder endpoints)
|
|
{
|
|
var group = endpoints.MapGroup("/api/risk/simulation")
|
|
.RequireAuthorization()
|
|
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
|
|
.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);
|
|
|
|
// Policy Studio specific endpoints per POLICY-RISK-68-001
|
|
group.MapPost("/studio/analyze", RunStudioAnalysis)
|
|
.WithName("RunPolicyStudioAnalysis")
|
|
.WithSummary("Run a detailed analysis for Policy Studio with full breakdown analytics.")
|
|
.WithDescription("Provides comprehensive breakdown including signal analysis, override tracking, score distributions, and component breakdowns for policy authoring.")
|
|
.Produces<PolicyStudioAnalysisResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/studio/compare", CompareProfilesWithBreakdown)
|
|
.WithName("CompareProfilesWithBreakdown")
|
|
.WithSummary("Compare profiles with full breakdown analytics and trend analysis.")
|
|
.Produces<PolicyStudioComparisonResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapPost("/studio/preview", PreviewProfileChanges)
|
|
.WithName("PreviewProfileChanges")
|
|
.WithSummary("Preview impact of profile changes before committing.")
|
|
.WithDescription("Simulates findings against both current and proposed profile to show impact.")
|
|
.Produces<ProfileChangePreviewResponse>(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 Policy Studio Endpoints (POLICY-RISK-68-001)
|
|
|
|
private static IResult RunStudioAnalysis(
|
|
HttpContext context,
|
|
[FromBody] PolicyStudioAnalysisRequest 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 breakdownOptions = request.BreakdownOptions ?? RiskSimulationBreakdownOptions.Default;
|
|
var result = simulationService.SimulateWithBreakdown(
|
|
new RiskSimulationRequest(
|
|
ProfileId: request.ProfileId,
|
|
ProfileVersion: request.ProfileVersion,
|
|
Findings: request.Findings,
|
|
IncludeContributions: true,
|
|
IncludeDistribution: true,
|
|
Mode: SimulationMode.Full),
|
|
breakdownOptions);
|
|
|
|
return Results.Ok(new PolicyStudioAnalysisResponse(
|
|
Result: result.Result,
|
|
Breakdown: result.Breakdown,
|
|
TotalExecutionTimeMs: result.TotalExecutionTimeMs));
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Profile not found",
|
|
Detail = ex.Message,
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message.Contains("Breakdown service"))
|
|
{
|
|
return Results.Problem(
|
|
title: "Service unavailable",
|
|
detail: ex.Message,
|
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
}
|
|
|
|
private static IResult CompareProfilesWithBreakdown(
|
|
HttpContext context,
|
|
[FromBody] PolicyStudioComparisonRequest 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
|
|
});
|
|
}
|
|
|
|
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.CompareProfilesWithBreakdown(
|
|
request.BaseProfileId,
|
|
request.CompareProfileId,
|
|
request.Findings,
|
|
request.BreakdownOptions);
|
|
|
|
return Results.Ok(new PolicyStudioComparisonResponse(
|
|
BaselineResult: result.BaselineResult,
|
|
CompareResult: result.CompareResult,
|
|
Breakdown: result.Breakdown,
|
|
ExecutionTimeMs: result.ExecutionTimeMs));
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Profile not found",
|
|
Detail = ex.Message,
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message.Contains("Breakdown service"))
|
|
{
|
|
return Results.Problem(
|
|
title: "Service unavailable",
|
|
detail: ex.Message,
|
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
}
|
|
|
|
private static IResult PreviewProfileChanges(
|
|
HttpContext context,
|
|
[FromBody] ProfileChangePreviewRequest request,
|
|
RiskSimulationService simulationService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
if (request == null || string.IsNullOrWhiteSpace(request.CurrentProfileId))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = "CurrentProfileId is required.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.ProposedProfileId) &&
|
|
(request.ProposedWeightChanges == null || request.ProposedWeightChanges.Count == 0) &&
|
|
(request.ProposedOverrideChanges == null || request.ProposedOverrideChanges.Count == 0))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = "Either ProposedProfileId or at least one proposed change is required.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
// Run simulation against current profile
|
|
var currentRequest = new RiskSimulationRequest(
|
|
ProfileId: request.CurrentProfileId,
|
|
ProfileVersion: request.CurrentProfileVersion,
|
|
Findings: request.Findings,
|
|
IncludeContributions: true,
|
|
IncludeDistribution: true,
|
|
Mode: SimulationMode.Full);
|
|
|
|
var currentResult = simulationService.Simulate(currentRequest);
|
|
|
|
RiskSimulationResult proposedResult;
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.ProposedProfileId))
|
|
{
|
|
// Compare against existing proposed profile
|
|
var proposedRequest = new RiskSimulationRequest(
|
|
ProfileId: request.ProposedProfileId,
|
|
ProfileVersion: request.ProposedProfileVersion,
|
|
Findings: request.Findings,
|
|
IncludeContributions: true,
|
|
IncludeDistribution: true,
|
|
Mode: SimulationMode.Full);
|
|
|
|
proposedResult = simulationService.Simulate(proposedRequest);
|
|
}
|
|
else
|
|
{
|
|
// Inline changes not yet supported - return preview of current only
|
|
proposedResult = currentResult;
|
|
}
|
|
|
|
var impactSummary = ComputePreviewImpact(currentResult, proposedResult);
|
|
|
|
return Results.Ok(new ProfileChangePreviewResponse(
|
|
CurrentResult: new ProfileSimulationSummary(
|
|
currentResult.ProfileId,
|
|
currentResult.ProfileVersion,
|
|
currentResult.AggregateMetrics),
|
|
ProposedResult: new ProfileSimulationSummary(
|
|
proposedResult.ProfileId,
|
|
proposedResult.ProfileVersion,
|
|
proposedResult.AggregateMetrics),
|
|
Impact: impactSummary,
|
|
HighImpactFindings: ComputeHighImpactFindings(currentResult, proposedResult)));
|
|
}
|
|
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 ProfileChangeImpact ComputePreviewImpact(
|
|
RiskSimulationResult current,
|
|
RiskSimulationResult proposed)
|
|
{
|
|
var currentScores = current.FindingScores.ToDictionary(f => f.FindingId);
|
|
var proposedScores = proposed.FindingScores.ToDictionary(f => f.FindingId);
|
|
|
|
var improved = 0;
|
|
var worsened = 0;
|
|
var unchanged = 0;
|
|
var severityEscalations = 0;
|
|
var severityDeescalations = 0;
|
|
var actionChanges = 0;
|
|
|
|
foreach (var (findingId, currentScore) in currentScores)
|
|
{
|
|
if (!proposedScores.TryGetValue(findingId, out var proposedScore))
|
|
continue;
|
|
|
|
var scoreDelta = proposedScore.NormalizedScore - currentScore.NormalizedScore;
|
|
if (Math.Abs(scoreDelta) < 1.0)
|
|
unchanged++;
|
|
else if (scoreDelta < 0)
|
|
improved++;
|
|
else
|
|
worsened++;
|
|
|
|
if (proposedScore.Severity > currentScore.Severity)
|
|
severityEscalations++;
|
|
else if (proposedScore.Severity < currentScore.Severity)
|
|
severityDeescalations++;
|
|
|
|
if (proposedScore.RecommendedAction != currentScore.RecommendedAction)
|
|
actionChanges++;
|
|
}
|
|
|
|
return new ProfileChangeImpact(
|
|
FindingsImproved: improved,
|
|
FindingsWorsened: worsened,
|
|
FindingsUnchanged: unchanged,
|
|
SeverityEscalations: severityEscalations,
|
|
SeverityDeescalations: severityDeescalations,
|
|
ActionChanges: actionChanges,
|
|
MeanScoreDelta: proposed.AggregateMetrics.MeanScore - current.AggregateMetrics.MeanScore,
|
|
CriticalCountDelta: proposed.AggregateMetrics.CriticalCount - current.AggregateMetrics.CriticalCount,
|
|
HighCountDelta: proposed.AggregateMetrics.HighCount - current.AggregateMetrics.HighCount);
|
|
}
|
|
|
|
private static IReadOnlyList<HighImpactFindingPreview> ComputeHighImpactFindings(
|
|
RiskSimulationResult current,
|
|
RiskSimulationResult proposed)
|
|
{
|
|
var currentScores = current.FindingScores.ToDictionary(f => f.FindingId);
|
|
var proposedScores = proposed.FindingScores.ToDictionary(f => f.FindingId);
|
|
|
|
var highImpact = new List<HighImpactFindingPreview>();
|
|
|
|
foreach (var (findingId, currentScore) in currentScores)
|
|
{
|
|
if (!proposedScores.TryGetValue(findingId, out var proposedScore))
|
|
continue;
|
|
|
|
var scoreDelta = Math.Abs(proposedScore.NormalizedScore - currentScore.NormalizedScore);
|
|
var severityChanged = proposedScore.Severity != currentScore.Severity;
|
|
var actionChanged = proposedScore.RecommendedAction != currentScore.RecommendedAction;
|
|
|
|
if (scoreDelta > 10 || severityChanged || actionChanged)
|
|
{
|
|
highImpact.Add(new HighImpactFindingPreview(
|
|
FindingId: findingId,
|
|
CurrentScore: currentScore.NormalizedScore,
|
|
ProposedScore: proposedScore.NormalizedScore,
|
|
ScoreDelta: proposedScore.NormalizedScore - currentScore.NormalizedScore,
|
|
CurrentSeverity: currentScore.Severity.ToString(),
|
|
ProposedSeverity: proposedScore.Severity.ToString(),
|
|
CurrentAction: currentScore.RecommendedAction.ToString(),
|
|
ProposedAction: proposedScore.RecommendedAction.ToString(),
|
|
ImpactReason: DetermineImpactReason(currentScore, proposedScore)));
|
|
}
|
|
}
|
|
|
|
return highImpact
|
|
.OrderByDescending(f => Math.Abs(f.ScoreDelta))
|
|
.Take(20)
|
|
.ToList();
|
|
}
|
|
|
|
private static string DetermineImpactReason(FindingScore current, FindingScore proposed)
|
|
{
|
|
var reasons = new List<string>();
|
|
|
|
if (proposed.Severity != current.Severity)
|
|
{
|
|
reasons.Add($"Severity {(proposed.Severity > current.Severity ? "escalated" : "deescalated")} from {current.Severity} to {proposed.Severity}");
|
|
}
|
|
|
|
if (proposed.RecommendedAction != current.RecommendedAction)
|
|
{
|
|
reasons.Add($"Action changed from {current.RecommendedAction} to {proposed.RecommendedAction}");
|
|
}
|
|
|
|
var scoreDelta = proposed.NormalizedScore - current.NormalizedScore;
|
|
if (Math.Abs(scoreDelta) > 10)
|
|
{
|
|
reasons.Add($"Score {(scoreDelta > 0 ? "increased" : "decreased")} by {Math.Abs(scoreDelta):F1} points");
|
|
}
|
|
|
|
return reasons.Count > 0 ? string.Join("; ", reasons) : "Significant score change";
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
|
|
#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
|
|
|
|
#region Policy Studio DTOs (POLICY-RISK-68-001)
|
|
|
|
internal sealed record PolicyStudioAnalysisRequest(
|
|
string ProfileId,
|
|
string? ProfileVersion,
|
|
IReadOnlyList<SimulationFinding> Findings,
|
|
RiskSimulationBreakdownOptions? BreakdownOptions = null);
|
|
|
|
internal sealed record PolicyStudioAnalysisResponse(
|
|
RiskSimulationResult Result,
|
|
RiskSimulationBreakdown Breakdown,
|
|
double TotalExecutionTimeMs);
|
|
|
|
internal sealed record PolicyStudioComparisonRequest(
|
|
string BaseProfileId,
|
|
string CompareProfileId,
|
|
IReadOnlyList<SimulationFinding> Findings,
|
|
RiskSimulationBreakdownOptions? BreakdownOptions = null);
|
|
|
|
internal sealed record PolicyStudioComparisonResponse(
|
|
RiskSimulationResult BaselineResult,
|
|
RiskSimulationResult CompareResult,
|
|
RiskSimulationBreakdown Breakdown,
|
|
double ExecutionTimeMs);
|
|
|
|
internal sealed record ProfileChangePreviewRequest(
|
|
string CurrentProfileId,
|
|
string? CurrentProfileVersion,
|
|
string? ProposedProfileId,
|
|
string? ProposedProfileVersion,
|
|
IReadOnlyList<SimulationFinding> Findings,
|
|
IReadOnlyDictionary<string, double>? ProposedWeightChanges = null,
|
|
IReadOnlyList<ProposedOverrideChange>? ProposedOverrideChanges = null);
|
|
|
|
internal sealed record ProposedOverrideChange(
|
|
string OverrideType,
|
|
Dictionary<string, object> When,
|
|
object Value,
|
|
string? Reason = null);
|
|
|
|
internal sealed record ProfileChangePreviewResponse(
|
|
ProfileSimulationSummary CurrentResult,
|
|
ProfileSimulationSummary ProposedResult,
|
|
ProfileChangeImpact Impact,
|
|
IReadOnlyList<HighImpactFindingPreview> HighImpactFindings);
|
|
|
|
internal sealed record ProfileChangeImpact(
|
|
int FindingsImproved,
|
|
int FindingsWorsened,
|
|
int FindingsUnchanged,
|
|
int SeverityEscalations,
|
|
int SeverityDeescalations,
|
|
int ActionChanges,
|
|
double MeanScoreDelta,
|
|
int CriticalCountDelta,
|
|
int HighCountDelta);
|
|
|
|
internal sealed record HighImpactFindingPreview(
|
|
string FindingId,
|
|
double CurrentScore,
|
|
double ProposedScore,
|
|
double ScoreDelta,
|
|
string CurrentSeverity,
|
|
string ProposedSeverity,
|
|
string CurrentAction,
|
|
string ProposedAction,
|
|
string ImpactReason);
|
|
|
|
#endregion
|