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.
This commit is contained in:
@@ -7,6 +7,10 @@ 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)
|
||||
@@ -42,6 +46,28 @@ internal static class RiskSimulationEndpoints
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -355,6 +381,344 @@ internal static class RiskSimulationEndpoints
|
||||
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
|
||||
@@ -433,3 +797,73 @@ internal sealed record SeverityShifts(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user