Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Engine/Endpoints/RiskSimulationEndpoints.cs
StellaOps Bot 5e514532df Implement VEX document verification system with issuer management and signature verification
- 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.
2025-12-06 13:41:22 +02:00

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