up
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Violations;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// API/SDK utilities for consumers to request policy decisions with source evidence summaries (POLICY-ENGINE-40-003).
|
||||
/// Combines policy evaluation with severity fusion, conflict detection, and evidence summaries.
|
||||
/// </summary>
|
||||
internal sealed class PolicyDecisionService
|
||||
{
|
||||
private readonly ViolationEventService _eventService;
|
||||
private readonly SeverityFusionService _fusionService;
|
||||
private readonly ConflictHandlingService _conflictService;
|
||||
private readonly EvidenceSummaryService _evidenceService;
|
||||
|
||||
public PolicyDecisionService(
|
||||
ViolationEventService eventService,
|
||||
SeverityFusionService fusionService,
|
||||
ConflictHandlingService conflictService,
|
||||
EvidenceSummaryService evidenceService)
|
||||
{
|
||||
_eventService = eventService ?? throw new ArgumentNullException(nameof(eventService));
|
||||
_fusionService = fusionService ?? throw new ArgumentNullException(nameof(fusionService));
|
||||
_conflictService = conflictService ?? throw new ArgumentNullException(nameof(conflictService));
|
||||
_evidenceService = evidenceService ?? throw new ArgumentNullException(nameof(evidenceService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request policy decisions with source evidence summaries for a given snapshot.
|
||||
/// </summary>
|
||||
public async Task<PolicyDecisionResponse> GetDecisionsAsync(
|
||||
PolicyDecisionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SnapshotId))
|
||||
{
|
||||
throw new ArgumentException("snapshot_id is required", nameof(request));
|
||||
}
|
||||
|
||||
// 1. Emit violation events from snapshot
|
||||
var eventRequest = new ViolationEventRequest(request.SnapshotId);
|
||||
await _eventService.EmitAsync(eventRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 2. Get fused severities with sources
|
||||
var fused = await _fusionService.FuseAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 3. Compute conflicts
|
||||
var conflicts = await _conflictService.ComputeAsync(request.SnapshotId, fused, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 4. Build decision items with evidence summaries
|
||||
var decisions = BuildDecisionItems(request, fused, conflicts);
|
||||
|
||||
// 5. Build summary statistics
|
||||
var summary = BuildSummary(decisions, fused);
|
||||
|
||||
return new PolicyDecisionResponse(
|
||||
SnapshotId: request.SnapshotId,
|
||||
Decisions: decisions,
|
||||
Summary: summary);
|
||||
}
|
||||
|
||||
private IReadOnlyList<PolicyDecisionItem> BuildDecisionItems(
|
||||
PolicyDecisionRequest request,
|
||||
IReadOnlyList<SeverityFusionResult> fused,
|
||||
IReadOnlyList<ConflictRecord> conflicts)
|
||||
{
|
||||
var conflictLookup = conflicts
|
||||
.GroupBy(c => (c.ComponentPurl, c.AdvisoryId))
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Sum(c => c.Conflicts.Count));
|
||||
|
||||
var items = new List<PolicyDecisionItem>(fused.Count);
|
||||
|
||||
foreach (var fusion in fused)
|
||||
{
|
||||
// Apply filters if specified
|
||||
if (!string.IsNullOrWhiteSpace(request.TenantId) &&
|
||||
!string.Equals(fusion.TenantId, request.TenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ComponentPurl) &&
|
||||
!string.Equals(fusion.ComponentPurl, request.ComponentPurl, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.AdvisoryId) &&
|
||||
!string.Equals(fusion.AdvisoryId, request.AdvisoryId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build top sources (limited by MaxSources)
|
||||
var topSources = fusion.Sources
|
||||
.OrderByDescending(s => s.Score)
|
||||
.ThenByDescending(s => s.Weight)
|
||||
.Take(request.MaxSources)
|
||||
.Select((s, index) => new PolicyDecisionSource(
|
||||
Source: s.Source,
|
||||
Weight: s.Weight,
|
||||
Severity: s.Severity,
|
||||
Score: s.Score,
|
||||
Rank: index + 1))
|
||||
.ToList();
|
||||
|
||||
// Build evidence summary if requested
|
||||
PolicyDecisionEvidence? evidence = null;
|
||||
if (request.IncludeEvidence)
|
||||
{
|
||||
evidence = BuildEvidence(fusion);
|
||||
}
|
||||
|
||||
// Get conflict count for this component/advisory pair
|
||||
var conflictKey = (fusion.ComponentPurl, fusion.AdvisoryId);
|
||||
var conflictCount = conflictLookup.GetValueOrDefault(conflictKey, 0);
|
||||
|
||||
// Derive status from severity
|
||||
var status = DeriveStatus(fusion.SeverityFused);
|
||||
|
||||
items.Add(new PolicyDecisionItem(
|
||||
TenantId: fusion.TenantId,
|
||||
ComponentPurl: fusion.ComponentPurl,
|
||||
AdvisoryId: fusion.AdvisoryId,
|
||||
SeverityFused: fusion.SeverityFused,
|
||||
Score: fusion.Score,
|
||||
Status: status,
|
||||
TopSources: topSources,
|
||||
Evidence: evidence,
|
||||
ConflictCount: conflictCount,
|
||||
ReasonCodes: fusion.ReasonCodes));
|
||||
}
|
||||
|
||||
// Return deterministically ordered results
|
||||
return items
|
||||
.OrderBy(i => i.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.AdvisoryId, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.TenantId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private PolicyDecisionEvidence BuildEvidence(SeverityFusionResult fusion)
|
||||
{
|
||||
// Build a deterministic evidence hash from the fusion result
|
||||
var evidenceHash = $"{fusion.ComponentPurl}|{fusion.AdvisoryId}|{fusion.SnapshotId}";
|
||||
|
||||
var evidenceRequest = new EvidenceSummaryRequest(
|
||||
EvidenceHash: evidenceHash,
|
||||
FilePath: fusion.ComponentPurl,
|
||||
Digest: null,
|
||||
IngestedAt: null,
|
||||
ConnectorId: fusion.Sources.FirstOrDefault()?.Source);
|
||||
|
||||
var response = _evidenceService.Summarize(evidenceRequest);
|
||||
|
||||
return new PolicyDecisionEvidence(
|
||||
Headline: response.Summary.Headline,
|
||||
Severity: response.Summary.Severity,
|
||||
Locator: new PolicyDecisionLocator(
|
||||
FilePath: response.Summary.Locator.FilePath,
|
||||
Digest: response.Summary.Locator.Digest),
|
||||
Signals: response.Summary.Signals);
|
||||
}
|
||||
|
||||
private static PolicyDecisionSummary BuildSummary(
|
||||
IReadOnlyList<PolicyDecisionItem> decisions,
|
||||
IReadOnlyList<SeverityFusionResult> fused)
|
||||
{
|
||||
// Count decisions by severity
|
||||
var severityCounts = decisions
|
||||
.GroupBy(d => d.SeverityFused, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Count(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Calculate total conflicts
|
||||
var totalConflicts = decisions.Sum(d => d.ConflictCount);
|
||||
|
||||
// Aggregate source ranks across all fused results
|
||||
var sourceStats = fused
|
||||
.SelectMany(f => f.Sources)
|
||||
.GroupBy(s => s.Source, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => new PolicyDecisionSourceRank(
|
||||
Source: g.Key,
|
||||
TotalWeight: g.Sum(s => s.Weight),
|
||||
DecisionCount: g.Count(),
|
||||
AverageScore: g.Average(s => s.Score)))
|
||||
.OrderByDescending(r => r.TotalWeight)
|
||||
.ThenByDescending(r => r.AverageScore)
|
||||
.ToList();
|
||||
|
||||
return new PolicyDecisionSummary(
|
||||
TotalDecisions: decisions.Count,
|
||||
TotalConflicts: totalConflicts,
|
||||
SeverityCounts: severityCounts,
|
||||
TopSeveritySources: sourceStats);
|
||||
}
|
||||
|
||||
private static string DeriveStatus(string severity) => severity.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => "violation",
|
||||
"high" => "violation",
|
||||
"medium" => "warn",
|
||||
_ => "ok"
|
||||
};
|
||||
}
|
||||
@@ -198,10 +198,11 @@ public sealed class RiskProfileConfigurationService
|
||||
var validation = _validator.Validate(json);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errorMessages = validation.Errors?.Values ?? Enumerable.Empty<string>();
|
||||
_logger.LogWarning(
|
||||
"Risk profile file '{File}' failed validation: {Errors}",
|
||||
file,
|
||||
string.Join("; ", validation.Message ?? "Unknown error"));
|
||||
string.Join("; ", errorMessages.Any() ? errorMessages : new[] { "Unknown error" }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user