up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,283 +1,283 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Compilation;
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic complexity metrics for compiled policies.
|
||||
/// </summary>
|
||||
internal sealed class PolicyComplexityAnalyzer
|
||||
{
|
||||
public PolicyComplexityReport Analyze(PolicyIrDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var metrics = new ComplexityMetrics();
|
||||
metrics.RuleCount = document.Rules.IsDefault ? 0 : document.Rules.Length;
|
||||
|
||||
VisitMetadata(document.Metadata.Values, metrics);
|
||||
VisitMetadata(document.Settings.Values, metrics);
|
||||
VisitProfiles(document.Profiles, metrics);
|
||||
|
||||
if (!document.Rules.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
metrics.ConditionCount++;
|
||||
VisitExpression(rule.When, metrics, depth: 0);
|
||||
|
||||
VisitActions(rule.ThenActions, metrics);
|
||||
VisitActions(rule.ElseActions, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
var score = CalculateScore(metrics);
|
||||
var roundedScore = Math.Round(score, 3, MidpointRounding.AwayFromZero);
|
||||
|
||||
return new PolicyComplexityReport(
|
||||
roundedScore,
|
||||
metrics.RuleCount,
|
||||
metrics.ActionCount,
|
||||
metrics.ExpressionCount,
|
||||
metrics.InvocationCount,
|
||||
metrics.MemberAccessCount,
|
||||
metrics.IdentifierCount,
|
||||
metrics.LiteralCount,
|
||||
metrics.MaxDepth,
|
||||
metrics.ProfileCount,
|
||||
metrics.ProfileBindings,
|
||||
metrics.ConditionCount,
|
||||
metrics.ListItems);
|
||||
}
|
||||
|
||||
private static void VisitProfiles(ImmutableArray<PolicyIrProfile> profiles, ComplexityMetrics metrics)
|
||||
{
|
||||
if (profiles.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
metrics.ProfileCount++;
|
||||
|
||||
if (!profile.Maps.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var map in profile.Maps)
|
||||
{
|
||||
if (map.Entries.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in map.Entries)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
metrics.LiteralCount++; // weight values contribute to literal count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!profile.Environments.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var environment in profile.Environments)
|
||||
{
|
||||
if (environment.Entries.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in environment.Entries)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
metrics.ConditionCount++;
|
||||
VisitExpression(entry.Condition, metrics, depth: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!profile.Scalars.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var scalar in profile.Scalars)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
VisitLiteral(scalar.Value, metrics);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitMetadata(IEnumerable<PolicyIrLiteral> literals, ComplexityMetrics metrics)
|
||||
{
|
||||
foreach (var literal in literals)
|
||||
{
|
||||
VisitLiteral(literal, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitLiteral(PolicyIrLiteral literal, ComplexityMetrics metrics)
|
||||
{
|
||||
switch (literal)
|
||||
{
|
||||
case PolicyIrListLiteral list when !list.Items.IsDefaultOrEmpty:
|
||||
foreach (var item in list.Items)
|
||||
{
|
||||
VisitLiteral(item, metrics);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
metrics.LiteralCount++;
|
||||
}
|
||||
|
||||
private static void VisitActions(ImmutableArray<PolicyIrAction> actions, ComplexityMetrics metrics)
|
||||
{
|
||||
if (actions.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
metrics.ActionCount++;
|
||||
switch (action)
|
||||
{
|
||||
case PolicyIrAssignmentAction assignment:
|
||||
VisitExpression(assignment.Value, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrAnnotateAction annotate:
|
||||
VisitExpression(annotate.Value, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrIgnoreAction ignore when ignore.Until is not null:
|
||||
VisitExpression(ignore.Until, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrEscalateAction escalate:
|
||||
VisitExpression(escalate.To, metrics, depth: 0);
|
||||
VisitExpression(escalate.When, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrRequireVexAction require when !require.Conditions.IsEmpty:
|
||||
foreach (var condition in require.Conditions.Values)
|
||||
{
|
||||
VisitExpression(condition, metrics, depth: 0);
|
||||
}
|
||||
break;
|
||||
case PolicyIrWarnAction warn when warn.Message is not null:
|
||||
VisitExpression(warn.Message, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrDeferAction defer when defer.Until is not null:
|
||||
VisitExpression(defer.Until, metrics, depth: 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitExpression(PolicyExpression? expression, ComplexityMetrics metrics, int depth)
|
||||
{
|
||||
if (expression is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.ExpressionCount++;
|
||||
var currentDepth = depth + 1;
|
||||
if (currentDepth > metrics.MaxDepth)
|
||||
{
|
||||
metrics.MaxDepth = currentDepth;
|
||||
}
|
||||
|
||||
switch (expression)
|
||||
{
|
||||
case PolicyLiteralExpression:
|
||||
metrics.LiteralCount++;
|
||||
break;
|
||||
case PolicyListExpression listExpression:
|
||||
if (!listExpression.Items.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var item in listExpression.Items)
|
||||
{
|
||||
metrics.ListItems++;
|
||||
VisitExpression(item, metrics, currentDepth);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case PolicyIdentifierExpression:
|
||||
metrics.IdentifierCount++;
|
||||
break;
|
||||
case PolicyMemberAccessExpression member:
|
||||
metrics.MemberAccessCount++;
|
||||
VisitExpression(member.Target, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyInvocationExpression invocation:
|
||||
metrics.InvocationCount++;
|
||||
VisitExpression(invocation.Target, metrics, currentDepth);
|
||||
if (!invocation.Arguments.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var argument in invocation.Arguments)
|
||||
{
|
||||
VisitExpression(argument, metrics, currentDepth);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case PolicyIndexerExpression indexer:
|
||||
VisitExpression(indexer.Target, metrics, currentDepth);
|
||||
VisitExpression(indexer.Index, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyUnaryExpression unary:
|
||||
VisitExpression(unary.Operand, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyBinaryExpression binary:
|
||||
VisitExpression(binary.Left, metrics, currentDepth);
|
||||
VisitExpression(binary.Right, metrics, currentDepth);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static double CalculateScore(ComplexityMetrics metrics)
|
||||
{
|
||||
return metrics.RuleCount * 5d
|
||||
+ metrics.ActionCount * 1.5d
|
||||
+ metrics.ExpressionCount * 0.75d
|
||||
+ metrics.InvocationCount * 1.5d
|
||||
+ metrics.MemberAccessCount * 1.0d
|
||||
+ metrics.IdentifierCount * 0.5d
|
||||
+ metrics.LiteralCount * 0.25d
|
||||
+ metrics.ProfileBindings * 0.5d
|
||||
+ metrics.ConditionCount * 1.25d
|
||||
+ metrics.MaxDepth * 2d
|
||||
+ metrics.ListItems * 0.25d;
|
||||
}
|
||||
|
||||
private sealed class ComplexityMetrics
|
||||
{
|
||||
public int RuleCount;
|
||||
public int ActionCount;
|
||||
public int ExpressionCount;
|
||||
public int InvocationCount;
|
||||
public int MemberAccessCount;
|
||||
public int IdentifierCount;
|
||||
public int LiteralCount;
|
||||
public int ProfileCount;
|
||||
public int ProfileBindings;
|
||||
public int ConditionCount;
|
||||
public int MaxDepth;
|
||||
public int ListItems;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyComplexityReport(
|
||||
double Score,
|
||||
int RuleCount,
|
||||
int ActionCount,
|
||||
int ExpressionCount,
|
||||
int InvocationCount,
|
||||
int MemberAccessCount,
|
||||
int IdentifierCount,
|
||||
int LiteralCount,
|
||||
int MaxExpressionDepth,
|
||||
int ProfileCount,
|
||||
int ProfileBindingCount,
|
||||
int ConditionCount,
|
||||
int ListItemCount);
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Compilation;
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic complexity metrics for compiled policies.
|
||||
/// </summary>
|
||||
internal sealed class PolicyComplexityAnalyzer
|
||||
{
|
||||
public PolicyComplexityReport Analyze(PolicyIrDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var metrics = new ComplexityMetrics();
|
||||
metrics.RuleCount = document.Rules.IsDefault ? 0 : document.Rules.Length;
|
||||
|
||||
VisitMetadata(document.Metadata.Values, metrics);
|
||||
VisitMetadata(document.Settings.Values, metrics);
|
||||
VisitProfiles(document.Profiles, metrics);
|
||||
|
||||
if (!document.Rules.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
metrics.ConditionCount++;
|
||||
VisitExpression(rule.When, metrics, depth: 0);
|
||||
|
||||
VisitActions(rule.ThenActions, metrics);
|
||||
VisitActions(rule.ElseActions, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
var score = CalculateScore(metrics);
|
||||
var roundedScore = Math.Round(score, 3, MidpointRounding.AwayFromZero);
|
||||
|
||||
return new PolicyComplexityReport(
|
||||
roundedScore,
|
||||
metrics.RuleCount,
|
||||
metrics.ActionCount,
|
||||
metrics.ExpressionCount,
|
||||
metrics.InvocationCount,
|
||||
metrics.MemberAccessCount,
|
||||
metrics.IdentifierCount,
|
||||
metrics.LiteralCount,
|
||||
metrics.MaxDepth,
|
||||
metrics.ProfileCount,
|
||||
metrics.ProfileBindings,
|
||||
metrics.ConditionCount,
|
||||
metrics.ListItems);
|
||||
}
|
||||
|
||||
private static void VisitProfiles(ImmutableArray<PolicyIrProfile> profiles, ComplexityMetrics metrics)
|
||||
{
|
||||
if (profiles.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
metrics.ProfileCount++;
|
||||
|
||||
if (!profile.Maps.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var map in profile.Maps)
|
||||
{
|
||||
if (map.Entries.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in map.Entries)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
metrics.LiteralCount++; // weight values contribute to literal count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!profile.Environments.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var environment in profile.Environments)
|
||||
{
|
||||
if (environment.Entries.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in environment.Entries)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
metrics.ConditionCount++;
|
||||
VisitExpression(entry.Condition, metrics, depth: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!profile.Scalars.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var scalar in profile.Scalars)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
VisitLiteral(scalar.Value, metrics);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitMetadata(IEnumerable<PolicyIrLiteral> literals, ComplexityMetrics metrics)
|
||||
{
|
||||
foreach (var literal in literals)
|
||||
{
|
||||
VisitLiteral(literal, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitLiteral(PolicyIrLiteral literal, ComplexityMetrics metrics)
|
||||
{
|
||||
switch (literal)
|
||||
{
|
||||
case PolicyIrListLiteral list when !list.Items.IsDefaultOrEmpty:
|
||||
foreach (var item in list.Items)
|
||||
{
|
||||
VisitLiteral(item, metrics);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
metrics.LiteralCount++;
|
||||
}
|
||||
|
||||
private static void VisitActions(ImmutableArray<PolicyIrAction> actions, ComplexityMetrics metrics)
|
||||
{
|
||||
if (actions.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
metrics.ActionCount++;
|
||||
switch (action)
|
||||
{
|
||||
case PolicyIrAssignmentAction assignment:
|
||||
VisitExpression(assignment.Value, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrAnnotateAction annotate:
|
||||
VisitExpression(annotate.Value, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrIgnoreAction ignore when ignore.Until is not null:
|
||||
VisitExpression(ignore.Until, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrEscalateAction escalate:
|
||||
VisitExpression(escalate.To, metrics, depth: 0);
|
||||
VisitExpression(escalate.When, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrRequireVexAction require when !require.Conditions.IsEmpty:
|
||||
foreach (var condition in require.Conditions.Values)
|
||||
{
|
||||
VisitExpression(condition, metrics, depth: 0);
|
||||
}
|
||||
break;
|
||||
case PolicyIrWarnAction warn when warn.Message is not null:
|
||||
VisitExpression(warn.Message, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrDeferAction defer when defer.Until is not null:
|
||||
VisitExpression(defer.Until, metrics, depth: 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitExpression(PolicyExpression? expression, ComplexityMetrics metrics, int depth)
|
||||
{
|
||||
if (expression is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.ExpressionCount++;
|
||||
var currentDepth = depth + 1;
|
||||
if (currentDepth > metrics.MaxDepth)
|
||||
{
|
||||
metrics.MaxDepth = currentDepth;
|
||||
}
|
||||
|
||||
switch (expression)
|
||||
{
|
||||
case PolicyLiteralExpression:
|
||||
metrics.LiteralCount++;
|
||||
break;
|
||||
case PolicyListExpression listExpression:
|
||||
if (!listExpression.Items.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var item in listExpression.Items)
|
||||
{
|
||||
metrics.ListItems++;
|
||||
VisitExpression(item, metrics, currentDepth);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case PolicyIdentifierExpression:
|
||||
metrics.IdentifierCount++;
|
||||
break;
|
||||
case PolicyMemberAccessExpression member:
|
||||
metrics.MemberAccessCount++;
|
||||
VisitExpression(member.Target, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyInvocationExpression invocation:
|
||||
metrics.InvocationCount++;
|
||||
VisitExpression(invocation.Target, metrics, currentDepth);
|
||||
if (!invocation.Arguments.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var argument in invocation.Arguments)
|
||||
{
|
||||
VisitExpression(argument, metrics, currentDepth);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case PolicyIndexerExpression indexer:
|
||||
VisitExpression(indexer.Target, metrics, currentDepth);
|
||||
VisitExpression(indexer.Index, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyUnaryExpression unary:
|
||||
VisitExpression(unary.Operand, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyBinaryExpression binary:
|
||||
VisitExpression(binary.Left, metrics, currentDepth);
|
||||
VisitExpression(binary.Right, metrics, currentDepth);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static double CalculateScore(ComplexityMetrics metrics)
|
||||
{
|
||||
return metrics.RuleCount * 5d
|
||||
+ metrics.ActionCount * 1.5d
|
||||
+ metrics.ExpressionCount * 0.75d
|
||||
+ metrics.InvocationCount * 1.5d
|
||||
+ metrics.MemberAccessCount * 1.0d
|
||||
+ metrics.IdentifierCount * 0.5d
|
||||
+ metrics.LiteralCount * 0.25d
|
||||
+ metrics.ProfileBindings * 0.5d
|
||||
+ metrics.ConditionCount * 1.25d
|
||||
+ metrics.MaxDepth * 2d
|
||||
+ metrics.ListItems * 0.25d;
|
||||
}
|
||||
|
||||
private sealed class ComplexityMetrics
|
||||
{
|
||||
public int RuleCount;
|
||||
public int ActionCount;
|
||||
public int ExpressionCount;
|
||||
public int InvocationCount;
|
||||
public int MemberAccessCount;
|
||||
public int IdentifierCount;
|
||||
public int LiteralCount;
|
||||
public int ProfileCount;
|
||||
public int ProfileBindings;
|
||||
public int ConditionCount;
|
||||
public int MaxDepth;
|
||||
public int ListItems;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyComplexityReport(
|
||||
double Score,
|
||||
int RuleCount,
|
||||
int ActionCount,
|
||||
int ExpressionCount,
|
||||
int InvocationCount,
|
||||
int MemberAccessCount,
|
||||
int IdentifierCount,
|
||||
int LiteralCount,
|
||||
int MaxExpressionDepth,
|
||||
int ProfileCount,
|
||||
int ProfileBindingCount,
|
||||
int ConditionCount,
|
||||
int ListItemCount);
|
||||
|
||||
@@ -1,176 +1,176 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Domain;
|
||||
|
||||
internal sealed class PolicyPackRecord
|
||||
{
|
||||
private readonly ConcurrentDictionary<int, PolicyRevisionRecord> revisions = new();
|
||||
|
||||
public PolicyPackRecord(string packId, string? displayName, DateTimeOffset createdAt)
|
||||
{
|
||||
PackId = packId ?? throw new ArgumentNullException(nameof(packId));
|
||||
DisplayName = displayName;
|
||||
CreatedAt = createdAt;
|
||||
}
|
||||
|
||||
public string PackId { get; }
|
||||
|
||||
public string? DisplayName { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public ImmutableArray<PolicyRevisionRecord> GetRevisions()
|
||||
=> revisions.Values
|
||||
.OrderBy(r => r.Version)
|
||||
.ToImmutableArray();
|
||||
|
||||
public PolicyRevisionRecord GetOrAddRevision(int version, Func<int, PolicyRevisionRecord> factory)
|
||||
=> revisions.GetOrAdd(version, factory);
|
||||
|
||||
public bool TryGetRevision(int version, out PolicyRevisionRecord revision)
|
||||
=> revisions.TryGetValue(version, out revision!);
|
||||
|
||||
public int GetNextVersion()
|
||||
=> revisions.IsEmpty ? 1 : revisions.Keys.Max() + 1;
|
||||
}
|
||||
|
||||
internal sealed class PolicyRevisionRecord
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PolicyActivationApproval> approvals = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PolicyBundleRecord? Bundle { get; private set; }
|
||||
|
||||
public PolicyRevisionRecord(int version, bool requiresTwoPerson, PolicyRevisionStatus status, DateTimeOffset createdAt)
|
||||
{
|
||||
Version = version;
|
||||
RequiresTwoPersonApproval = requiresTwoPerson;
|
||||
Status = status;
|
||||
CreatedAt = createdAt;
|
||||
}
|
||||
|
||||
public int Version { get; }
|
||||
|
||||
public bool RequiresTwoPersonApproval { get; }
|
||||
|
||||
public PolicyRevisionStatus Status { get; private set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public DateTimeOffset? ActivatedAt { get; private set; }
|
||||
|
||||
public ImmutableArray<PolicyActivationApproval> Approvals
|
||||
=> approvals.Values
|
||||
.OrderBy(approval => approval.ApprovedAt)
|
||||
.ToImmutableArray();
|
||||
|
||||
public void SetStatus(PolicyRevisionStatus status, DateTimeOffset timestamp)
|
||||
{
|
||||
Status = status;
|
||||
if (status == PolicyRevisionStatus.Active)
|
||||
{
|
||||
ActivatedAt = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
public PolicyActivationApprovalStatus AddApproval(PolicyActivationApproval approval)
|
||||
{
|
||||
if (!approvals.TryAdd(approval.ActorId, approval))
|
||||
{
|
||||
return PolicyActivationApprovalStatus.Duplicate;
|
||||
}
|
||||
|
||||
return approvals.Count >= 2
|
||||
? PolicyActivationApprovalStatus.ThresholdReached
|
||||
: PolicyActivationApprovalStatus.Pending;
|
||||
}
|
||||
|
||||
public void SetBundle(PolicyBundleRecord bundle)
|
||||
{
|
||||
Bundle = bundle ?? throw new ArgumentNullException(nameof(bundle));
|
||||
}
|
||||
}
|
||||
|
||||
internal enum PolicyRevisionStatus
|
||||
{
|
||||
Draft,
|
||||
Approved,
|
||||
Active
|
||||
}
|
||||
|
||||
internal sealed record PolicyActivationApproval(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
|
||||
|
||||
internal enum PolicyActivationApprovalStatus
|
||||
{
|
||||
Pending,
|
||||
ThresholdReached,
|
||||
Duplicate
|
||||
}
|
||||
|
||||
internal sealed record PolicyBundleRecord(
|
||||
string Digest,
|
||||
string Signature,
|
||||
int Size,
|
||||
DateTimeOffset CreatedAt,
|
||||
ImmutableArray<byte> Payload,
|
||||
PolicyIrDocument? CompiledDocument = null,
|
||||
PolicyAocMetadata? AocMetadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Attestation of Compliance metadata for a policy revision.
|
||||
/// Links policy decisions to explanation trees and AOC chain.
|
||||
/// </summary>
|
||||
internal sealed record PolicyAocMetadata(
|
||||
/// <summary>Unique identifier for this compilation run.</summary>
|
||||
string CompilationId,
|
||||
/// <summary>Version of the compiler used (e.g., "stella-dsl@1").</summary>
|
||||
string CompilerVersion,
|
||||
/// <summary>Timestamp when compilation started.</summary>
|
||||
DateTimeOffset CompiledAt,
|
||||
/// <summary>SHA256 digest of the source policy document.</summary>
|
||||
string SourceDigest,
|
||||
/// <summary>SHA256 digest of the compiled artifact.</summary>
|
||||
string ArtifactDigest,
|
||||
/// <summary>Complexity score from compilation analysis.</summary>
|
||||
double ComplexityScore,
|
||||
/// <summary>Number of rules in the compiled policy.</summary>
|
||||
int RuleCount,
|
||||
/// <summary>Compilation duration in milliseconds.</summary>
|
||||
long DurationMilliseconds,
|
||||
/// <summary>Provenance information about the source.</summary>
|
||||
PolicyProvenance? Provenance = null,
|
||||
/// <summary>Reference to the signed attestation envelope.</summary>
|
||||
PolicyAttestationRef? AttestationRef = null);
|
||||
|
||||
/// <summary>
|
||||
/// Provenance information for policy source tracking.
|
||||
/// </summary>
|
||||
internal sealed record PolicyProvenance(
|
||||
/// <summary>Type of source (git, upload, api).</summary>
|
||||
string SourceType,
|
||||
/// <summary>URL or path to the source.</summary>
|
||||
string? SourceUrl,
|
||||
/// <summary>User or service that submitted the policy.</summary>
|
||||
string? Submitter,
|
||||
/// <summary>Git commit SHA if applicable.</summary>
|
||||
string? CommitSha,
|
||||
/// <summary>Git branch if applicable.</summary>
|
||||
string? Branch,
|
||||
/// <summary>Timestamp when source was ingested.</summary>
|
||||
DateTimeOffset IngestedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a signed DSSE attestation for the policy compilation.
|
||||
/// </summary>
|
||||
internal sealed record PolicyAttestationRef(
|
||||
/// <summary>Unique identifier for the attestation.</summary>
|
||||
string AttestationId,
|
||||
/// <summary>SHA256 digest of the attestation envelope.</summary>
|
||||
string EnvelopeDigest,
|
||||
/// <summary>URI where the attestation can be retrieved.</summary>
|
||||
string? Uri,
|
||||
/// <summary>Key identifier used for signing.</summary>
|
||||
string? SigningKeyId,
|
||||
/// <summary>Timestamp when attestation was created.</summary>
|
||||
DateTimeOffset CreatedAt);
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Domain;
|
||||
|
||||
internal sealed class PolicyPackRecord
|
||||
{
|
||||
private readonly ConcurrentDictionary<int, PolicyRevisionRecord> revisions = new();
|
||||
|
||||
public PolicyPackRecord(string packId, string? displayName, DateTimeOffset createdAt)
|
||||
{
|
||||
PackId = packId ?? throw new ArgumentNullException(nameof(packId));
|
||||
DisplayName = displayName;
|
||||
CreatedAt = createdAt;
|
||||
}
|
||||
|
||||
public string PackId { get; }
|
||||
|
||||
public string? DisplayName { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public ImmutableArray<PolicyRevisionRecord> GetRevisions()
|
||||
=> revisions.Values
|
||||
.OrderBy(r => r.Version)
|
||||
.ToImmutableArray();
|
||||
|
||||
public PolicyRevisionRecord GetOrAddRevision(int version, Func<int, PolicyRevisionRecord> factory)
|
||||
=> revisions.GetOrAdd(version, factory);
|
||||
|
||||
public bool TryGetRevision(int version, out PolicyRevisionRecord revision)
|
||||
=> revisions.TryGetValue(version, out revision!);
|
||||
|
||||
public int GetNextVersion()
|
||||
=> revisions.IsEmpty ? 1 : revisions.Keys.Max() + 1;
|
||||
}
|
||||
|
||||
internal sealed class PolicyRevisionRecord
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PolicyActivationApproval> approvals = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PolicyBundleRecord? Bundle { get; private set; }
|
||||
|
||||
public PolicyRevisionRecord(int version, bool requiresTwoPerson, PolicyRevisionStatus status, DateTimeOffset createdAt)
|
||||
{
|
||||
Version = version;
|
||||
RequiresTwoPersonApproval = requiresTwoPerson;
|
||||
Status = status;
|
||||
CreatedAt = createdAt;
|
||||
}
|
||||
|
||||
public int Version { get; }
|
||||
|
||||
public bool RequiresTwoPersonApproval { get; }
|
||||
|
||||
public PolicyRevisionStatus Status { get; private set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public DateTimeOffset? ActivatedAt { get; private set; }
|
||||
|
||||
public ImmutableArray<PolicyActivationApproval> Approvals
|
||||
=> approvals.Values
|
||||
.OrderBy(approval => approval.ApprovedAt)
|
||||
.ToImmutableArray();
|
||||
|
||||
public void SetStatus(PolicyRevisionStatus status, DateTimeOffset timestamp)
|
||||
{
|
||||
Status = status;
|
||||
if (status == PolicyRevisionStatus.Active)
|
||||
{
|
||||
ActivatedAt = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
public PolicyActivationApprovalStatus AddApproval(PolicyActivationApproval approval)
|
||||
{
|
||||
if (!approvals.TryAdd(approval.ActorId, approval))
|
||||
{
|
||||
return PolicyActivationApprovalStatus.Duplicate;
|
||||
}
|
||||
|
||||
return approvals.Count >= 2
|
||||
? PolicyActivationApprovalStatus.ThresholdReached
|
||||
: PolicyActivationApprovalStatus.Pending;
|
||||
}
|
||||
|
||||
public void SetBundle(PolicyBundleRecord bundle)
|
||||
{
|
||||
Bundle = bundle ?? throw new ArgumentNullException(nameof(bundle));
|
||||
}
|
||||
}
|
||||
|
||||
internal enum PolicyRevisionStatus
|
||||
{
|
||||
Draft,
|
||||
Approved,
|
||||
Active
|
||||
}
|
||||
|
||||
internal sealed record PolicyActivationApproval(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
|
||||
|
||||
internal enum PolicyActivationApprovalStatus
|
||||
{
|
||||
Pending,
|
||||
ThresholdReached,
|
||||
Duplicate
|
||||
}
|
||||
|
||||
internal sealed record PolicyBundleRecord(
|
||||
string Digest,
|
||||
string Signature,
|
||||
int Size,
|
||||
DateTimeOffset CreatedAt,
|
||||
ImmutableArray<byte> Payload,
|
||||
PolicyIrDocument? CompiledDocument = null,
|
||||
PolicyAocMetadata? AocMetadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Attestation of Compliance metadata for a policy revision.
|
||||
/// Links policy decisions to explanation trees and AOC chain.
|
||||
/// </summary>
|
||||
internal sealed record PolicyAocMetadata(
|
||||
/// <summary>Unique identifier for this compilation run.</summary>
|
||||
string CompilationId,
|
||||
/// <summary>Version of the compiler used (e.g., "stella-dsl@1").</summary>
|
||||
string CompilerVersion,
|
||||
/// <summary>Timestamp when compilation started.</summary>
|
||||
DateTimeOffset CompiledAt,
|
||||
/// <summary>SHA256 digest of the source policy document.</summary>
|
||||
string SourceDigest,
|
||||
/// <summary>SHA256 digest of the compiled artifact.</summary>
|
||||
string ArtifactDigest,
|
||||
/// <summary>Complexity score from compilation analysis.</summary>
|
||||
double ComplexityScore,
|
||||
/// <summary>Number of rules in the compiled policy.</summary>
|
||||
int RuleCount,
|
||||
/// <summary>Compilation duration in milliseconds.</summary>
|
||||
long DurationMilliseconds,
|
||||
/// <summary>Provenance information about the source.</summary>
|
||||
PolicyProvenance? Provenance = null,
|
||||
/// <summary>Reference to the signed attestation envelope.</summary>
|
||||
PolicyAttestationRef? AttestationRef = null);
|
||||
|
||||
/// <summary>
|
||||
/// Provenance information for policy source tracking.
|
||||
/// </summary>
|
||||
internal sealed record PolicyProvenance(
|
||||
/// <summary>Type of source (git, upload, api).</summary>
|
||||
string SourceType,
|
||||
/// <summary>URL or path to the source.</summary>
|
||||
string? SourceUrl,
|
||||
/// <summary>User or service that submitted the policy.</summary>
|
||||
string? Submitter,
|
||||
/// <summary>Git commit SHA if applicable.</summary>
|
||||
string? CommitSha,
|
||||
/// <summary>Git branch if applicable.</summary>
|
||||
string? Branch,
|
||||
/// <summary>Timestamp when source was ingested.</summary>
|
||||
DateTimeOffset IngestedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a signed DSSE attestation for the policy compilation.
|
||||
/// </summary>
|
||||
internal sealed record PolicyAttestationRef(
|
||||
/// <summary>Unique identifier for the attestation.</summary>
|
||||
string AttestationId,
|
||||
/// <summary>SHA256 digest of the attestation envelope.</summary>
|
||||
string EnvelopeDigest,
|
||||
/// <summary>URI where the attestation can be retrieved.</summary>
|
||||
string? Uri,
|
||||
/// <summary>Key identifier used for signing.</summary>
|
||||
string? SigningKeyId,
|
||||
/// <summary>Timestamp when attestation was created.</summary>
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
@@ -1,150 +1,150 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class PolicyCompilationEndpoints
|
||||
{
|
||||
private const string CompileRoute = "/api/policy/policies/{policyId}/versions/{version}:compile";
|
||||
|
||||
public static IEndpointRouteBuilder MapPolicyCompilation(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost(CompileRoute, CompilePolicy)
|
||||
.WithName("CompilePolicy")
|
||||
.WithSummary("Compile and lint a policy DSL document.")
|
||||
.WithDescription("Compiles a stella-dsl@1 policy document and returns deterministic digest and statistics.")
|
||||
.Produces<PolicyCompileResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(); // scopes enforced by policy middleware.
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult CompilePolicy(
|
||||
[FromRoute] string policyId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] PolicyCompileRequest request,
|
||||
PolicyCompilationService compilationService)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Request body missing.", policyId, version));
|
||||
}
|
||||
|
||||
var result = compilationService.Compile(request);
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Policy compilation failed.", policyId, version, result.Diagnostics));
|
||||
}
|
||||
|
||||
var response = new PolicyCompileResponse(
|
||||
result.Digest!,
|
||||
result.Statistics ?? new PolicyCompilationStatistics(0, ImmutableDictionary<string, int>.Empty),
|
||||
MapComplexity(result.Complexity),
|
||||
result.DurationMilliseconds,
|
||||
ConvertDiagnostics(result.Diagnostics));
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static PolicyProblemDetails BuildProblem(string code, string message, string policyId, int version, ImmutableArray<PolicyIssue>? diagnostics = null)
|
||||
{
|
||||
var problem = new PolicyProblemDetails
|
||||
{
|
||||
Code = code,
|
||||
Title = "Policy compilation error",
|
||||
Detail = message,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = version
|
||||
};
|
||||
|
||||
if (diagnostics is { Length: > 0 } diag)
|
||||
{
|
||||
problem.Diagnostics = diag;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyDiagnosticDto> ConvertDiagnostics(ImmutableArray<PolicyIssue> issues)
|
||||
{
|
||||
if (issues.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<PolicyDiagnosticDto>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyDiagnosticDto>(issues.Length);
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
if (issue.Severity != PolicyIssueSeverity.Warning)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(new PolicyDiagnosticDto(issue.Code, issue.Message, issue.Path));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyComplexityReportDto? MapComplexity(PolicyComplexityReport? report)
|
||||
{
|
||||
if (report is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PolicyComplexityReportDto(
|
||||
report.Score,
|
||||
report.RuleCount,
|
||||
report.ActionCount,
|
||||
report.ExpressionCount,
|
||||
report.InvocationCount,
|
||||
report.MemberAccessCount,
|
||||
report.IdentifierCount,
|
||||
report.LiteralCount,
|
||||
report.MaxExpressionDepth,
|
||||
report.ProfileCount,
|
||||
report.ProfileBindingCount,
|
||||
report.ConditionCount,
|
||||
report.ListItemCount);
|
||||
}
|
||||
|
||||
private sealed class PolicyProblemDetails : ProblemDetails
|
||||
{
|
||||
public string Code { get; set; } = "ERR_POL_001";
|
||||
|
||||
public string? PolicyId { get; set; }
|
||||
|
||||
public int PolicyVersion { get; set; }
|
||||
|
||||
public ImmutableArray<PolicyIssue> Diagnostics { get; set; } = ImmutableArray<PolicyIssue>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompileResponse(
|
||||
string Digest,
|
||||
PolicyCompilationStatistics Statistics,
|
||||
PolicyComplexityReportDto? Complexity,
|
||||
long DurationMilliseconds,
|
||||
ImmutableArray<PolicyDiagnosticDto> Warnings);
|
||||
|
||||
internal sealed record PolicyDiagnosticDto(string Code, string Message, string Path);
|
||||
|
||||
internal sealed record PolicyComplexityReportDto(
|
||||
double Score,
|
||||
int RuleCount,
|
||||
int ActionCount,
|
||||
int ExpressionCount,
|
||||
int InvocationCount,
|
||||
int MemberAccessCount,
|
||||
int IdentifierCount,
|
||||
int LiteralCount,
|
||||
int MaxExpressionDepth,
|
||||
int ProfileCount,
|
||||
int ProfileBindingCount,
|
||||
int ConditionCount,
|
||||
int ListItemCount);
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class PolicyCompilationEndpoints
|
||||
{
|
||||
private const string CompileRoute = "/api/policy/policies/{policyId}/versions/{version}:compile";
|
||||
|
||||
public static IEndpointRouteBuilder MapPolicyCompilation(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost(CompileRoute, CompilePolicy)
|
||||
.WithName("CompilePolicy")
|
||||
.WithSummary("Compile and lint a policy DSL document.")
|
||||
.WithDescription("Compiles a stella-dsl@1 policy document and returns deterministic digest and statistics.")
|
||||
.Produces<PolicyCompileResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(); // scopes enforced by policy middleware.
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult CompilePolicy(
|
||||
[FromRoute] string policyId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] PolicyCompileRequest request,
|
||||
PolicyCompilationService compilationService)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Request body missing.", policyId, version));
|
||||
}
|
||||
|
||||
var result = compilationService.Compile(request);
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Policy compilation failed.", policyId, version, result.Diagnostics));
|
||||
}
|
||||
|
||||
var response = new PolicyCompileResponse(
|
||||
result.Digest!,
|
||||
result.Statistics ?? new PolicyCompilationStatistics(0, ImmutableDictionary<string, int>.Empty),
|
||||
MapComplexity(result.Complexity),
|
||||
result.DurationMilliseconds,
|
||||
ConvertDiagnostics(result.Diagnostics));
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static PolicyProblemDetails BuildProblem(string code, string message, string policyId, int version, ImmutableArray<PolicyIssue>? diagnostics = null)
|
||||
{
|
||||
var problem = new PolicyProblemDetails
|
||||
{
|
||||
Code = code,
|
||||
Title = "Policy compilation error",
|
||||
Detail = message,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = version
|
||||
};
|
||||
|
||||
if (diagnostics is { Length: > 0 } diag)
|
||||
{
|
||||
problem.Diagnostics = diag;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyDiagnosticDto> ConvertDiagnostics(ImmutableArray<PolicyIssue> issues)
|
||||
{
|
||||
if (issues.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<PolicyDiagnosticDto>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyDiagnosticDto>(issues.Length);
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
if (issue.Severity != PolicyIssueSeverity.Warning)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(new PolicyDiagnosticDto(issue.Code, issue.Message, issue.Path));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyComplexityReportDto? MapComplexity(PolicyComplexityReport? report)
|
||||
{
|
||||
if (report is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PolicyComplexityReportDto(
|
||||
report.Score,
|
||||
report.RuleCount,
|
||||
report.ActionCount,
|
||||
report.ExpressionCount,
|
||||
report.InvocationCount,
|
||||
report.MemberAccessCount,
|
||||
report.IdentifierCount,
|
||||
report.LiteralCount,
|
||||
report.MaxExpressionDepth,
|
||||
report.ProfileCount,
|
||||
report.ProfileBindingCount,
|
||||
report.ConditionCount,
|
||||
report.ListItemCount);
|
||||
}
|
||||
|
||||
private sealed class PolicyProblemDetails : ProblemDetails
|
||||
{
|
||||
public string Code { get; set; } = "ERR_POL_001";
|
||||
|
||||
public string? PolicyId { get; set; }
|
||||
|
||||
public int PolicyVersion { get; set; }
|
||||
|
||||
public ImmutableArray<PolicyIssue> Diagnostics { get; set; } = ImmutableArray<PolicyIssue>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompileResponse(
|
||||
string Digest,
|
||||
PolicyCompilationStatistics Statistics,
|
||||
PolicyComplexityReportDto? Complexity,
|
||||
long DurationMilliseconds,
|
||||
ImmutableArray<PolicyDiagnosticDto> Warnings);
|
||||
|
||||
internal sealed record PolicyDiagnosticDto(string Code, string Message, string Path);
|
||||
|
||||
internal sealed record PolicyComplexityReportDto(
|
||||
double Score,
|
||||
int RuleCount,
|
||||
int ActionCount,
|
||||
int ExpressionCount,
|
||||
int InvocationCount,
|
||||
int MemberAccessCount,
|
||||
int IdentifierCount,
|
||||
int LiteralCount,
|
||||
int MaxExpressionDepth,
|
||||
int ProfileCount,
|
||||
int ProfileBindingCount,
|
||||
int ConditionCount,
|
||||
int ListItemCount);
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class PolicyPackEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPolicyPacks(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/policy/packs")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Policy Packs");
|
||||
|
||||
group.MapPost(string.Empty, CreatePack)
|
||||
.WithName("CreatePolicyPack")
|
||||
.WithSummary("Create a new policy pack container.")
|
||||
.Produces<PolicyPackDto>(StatusCodes.Status201Created);
|
||||
|
||||
group.MapGet(string.Empty, ListPacks)
|
||||
.WithName("ListPolicyPacks")
|
||||
.WithSummary("List policy packs for the current tenant.")
|
||||
.Produces<IReadOnlyList<PolicyPackSummaryDto>>(StatusCodes.Status200OK);
|
||||
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class PolicyPackEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPolicyPacks(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/policy/packs")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Policy Packs");
|
||||
|
||||
group.MapPost(string.Empty, CreatePack)
|
||||
.WithName("CreatePolicyPack")
|
||||
.WithSummary("Create a new policy pack container.")
|
||||
.Produces<PolicyPackDto>(StatusCodes.Status201Created);
|
||||
|
||||
group.MapGet(string.Empty, ListPacks)
|
||||
.WithName("ListPolicyPacks")
|
||||
.WithSummary("List policy packs for the current tenant.")
|
||||
.Produces<IReadOnlyList<PolicyPackSummaryDto>>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/{packId}/revisions", CreateRevision)
|
||||
.WithName("CreatePolicyRevision")
|
||||
.WithSummary("Create or update policy revision metadata.")
|
||||
@@ -49,149 +49,149 @@ internal static class PolicyPackEndpoints
|
||||
.WithSummary("Activate an approved policy revision, enforcing two-person approval when required.")
|
||||
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status202Accepted)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePack(
|
||||
HttpContext context,
|
||||
[FromBody] CreatePolicyPackRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var packId = string.IsNullOrWhiteSpace(request.PackId)
|
||||
? $"pack-{Guid.NewGuid():n}"
|
||||
: request.PackId.Trim();
|
||||
|
||||
var pack = await repository.CreateAsync(packId, request.DisplayName?.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
var dto = PolicyPackMapper.ToDto(pack);
|
||||
return Results.Created($"/api/policy/packs/{dto.PackId}", dto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPacks(
|
||||
HttpContext context,
|
||||
IPolicyPackRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var packs = await repository.ListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var summaries = packs.Select(PolicyPackMapper.ToSummaryDto).ToArray();
|
||||
return Results.Ok(summaries);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromBody] CreatePolicyRevisionRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
IPolicyActivationSettings activationSettings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request.InitialStatus is not (PolicyRevisionStatus.Draft or PolicyRevisionStatus.Approved))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid status",
|
||||
Detail = "Only Draft or Approved statuses are supported for new revisions.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var requiresTwoPersonApproval = activationSettings.ResolveRequirement(request.RequiresTwoPersonApproval);
|
||||
|
||||
var revision = await repository.UpsertRevisionAsync(
|
||||
packId,
|
||||
request.Version ?? 0,
|
||||
requiresTwoPersonApproval,
|
||||
request.InitialStatus,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/policy/packs/{packId}/revisions/{revision.Version}",
|
||||
PolicyPackMapper.ToDto(packId, revision));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ActivateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] ActivatePolicyRevisionRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
IPolicyActivationAuditor auditor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
if (actorId is null)
|
||||
{
|
||||
return Results.Problem("Actor identity required.", statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
var result = await repository.RecordActivationAsync(
|
||||
packId,
|
||||
version,
|
||||
actorId,
|
||||
DateTimeOffset.UtcNow,
|
||||
request.Comment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tenantId = context.User?.FindFirst(StellaOpsClaimTypes.Tenant)?.Value;
|
||||
auditor.RecordActivation(packId, version, actorId, tenantId, result, request.Comment);
|
||||
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePack(
|
||||
HttpContext context,
|
||||
[FromBody] CreatePolicyPackRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var packId = string.IsNullOrWhiteSpace(request.PackId)
|
||||
? $"pack-{Guid.NewGuid():n}"
|
||||
: request.PackId.Trim();
|
||||
|
||||
var pack = await repository.CreateAsync(packId, request.DisplayName?.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
var dto = PolicyPackMapper.ToDto(pack);
|
||||
return Results.Created($"/api/policy/packs/{dto.PackId}", dto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPacks(
|
||||
HttpContext context,
|
||||
IPolicyPackRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var packs = await repository.ListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var summaries = packs.Select(PolicyPackMapper.ToSummaryDto).ToArray();
|
||||
return Results.Ok(summaries);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromBody] CreatePolicyRevisionRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
IPolicyActivationSettings activationSettings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request.InitialStatus is not (PolicyRevisionStatus.Draft or PolicyRevisionStatus.Approved))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid status",
|
||||
Detail = "Only Draft or Approved statuses are supported for new revisions.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var requiresTwoPersonApproval = activationSettings.ResolveRequirement(request.RequiresTwoPersonApproval);
|
||||
|
||||
var revision = await repository.UpsertRevisionAsync(
|
||||
packId,
|
||||
request.Version ?? 0,
|
||||
requiresTwoPersonApproval,
|
||||
request.InitialStatus,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/policy/packs/{packId}/revisions/{revision.Version}",
|
||||
PolicyPackMapper.ToDto(packId, revision));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ActivateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] ActivatePolicyRevisionRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
IPolicyActivationAuditor auditor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
if (actorId is null)
|
||||
{
|
||||
return Results.Problem("Actor identity required.", statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
var result = await repository.RecordActivationAsync(
|
||||
packId,
|
||||
version,
|
||||
actorId,
|
||||
DateTimeOffset.UtcNow,
|
||||
request.Comment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tenantId = context.User?.FindFirst(StellaOpsClaimTypes.Tenant)?.Value;
|
||||
auditor.RecordActivation(packId, version, actorId, tenantId, result, request.Comment);
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
PolicyActivationResultStatus.PackNotFound => Results.NotFound(new ProblemDetails
|
||||
@@ -199,29 +199,29 @@ internal static class PolicyPackEndpoints
|
||||
Title = "Policy pack not found",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
}),
|
||||
PolicyActivationResultStatus.RevisionNotFound => Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Policy revision not found",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
}),
|
||||
PolicyActivationResultStatus.NotApproved => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Revision not approved",
|
||||
Detail = "Only approved revisions may be activated.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
}),
|
||||
PolicyActivationResultStatus.DuplicateApproval => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Approval already recorded",
|
||||
Detail = "This approver has already approved activation.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
}),
|
||||
PolicyActivationResultStatus.PendingSecondApproval => Results.Accepted(
|
||||
$"/api/policy/packs/{packId}/revisions/{version}",
|
||||
new PolicyRevisionActivationResponse("pending_second_approval", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
PolicyActivationResultStatus.Activated => Results.Ok(new PolicyRevisionActivationResponse("activated", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
PolicyActivationResultStatus.AlreadyActive => Results.Ok(new PolicyRevisionActivationResponse("already_active", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
_ => Results.BadRequest(new ProblemDetails
|
||||
PolicyActivationResultStatus.RevisionNotFound => Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Policy revision not found",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
}),
|
||||
PolicyActivationResultStatus.NotApproved => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Revision not approved",
|
||||
Detail = "Only approved revisions may be activated.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
}),
|
||||
PolicyActivationResultStatus.DuplicateApproval => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Approval already recorded",
|
||||
Detail = "This approver has already approved activation.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
}),
|
||||
PolicyActivationResultStatus.PendingSecondApproval => Results.Accepted(
|
||||
$"/api/policy/packs/{packId}/revisions/{version}",
|
||||
new PolicyRevisionActivationResponse("pending_second_approval", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
PolicyActivationResultStatus.Activated => Results.Ok(new PolicyRevisionActivationResponse("activated", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
PolicyActivationResultStatus.AlreadyActive => Results.Ok(new PolicyRevisionActivationResponse("already_active", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
_ => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Activation failed",
|
||||
Detail = "Unknown activation result.",
|
||||
@@ -323,57 +323,57 @@ internal static class PolicyPackEndpoints
|
||||
}
|
||||
|
||||
private static string? ResolveActorId(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user?.FindFirst(ClaimTypes.Upn)?.Value
|
||||
?? user?.FindFirst("sub")?.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PolicyPackMapper
|
||||
{
|
||||
public static PolicyPackDto ToDto(PolicyPackRecord record)
|
||||
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => ToDto(record.PackId, r)).ToArray());
|
||||
|
||||
public static PolicyPackSummaryDto ToSummaryDto(PolicyPackRecord record)
|
||||
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => r.Version).ToArray());
|
||||
|
||||
public static PolicyRevisionDto ToDto(string packId, PolicyRevisionRecord revision)
|
||||
=> new(
|
||||
packId,
|
||||
revision.Version,
|
||||
revision.Status.ToString(),
|
||||
revision.RequiresTwoPersonApproval,
|
||||
revision.CreatedAt,
|
||||
revision.ActivatedAt,
|
||||
revision.Approvals.Select(a => new PolicyActivationApprovalDto(a.ActorId, a.ApprovedAt, a.Comment)).ToArray());
|
||||
}
|
||||
|
||||
internal sealed record CreatePolicyPackRequest(string? PackId, string? DisplayName);
|
||||
|
||||
internal sealed record PolicyPackDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<PolicyRevisionDto> Revisions);
|
||||
|
||||
internal sealed record PolicyPackSummaryDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<int> Versions);
|
||||
|
||||
internal sealed record CreatePolicyRevisionRequest(int? Version, bool? RequiresTwoPersonApproval, PolicyRevisionStatus InitialStatus = PolicyRevisionStatus.Approved);
|
||||
|
||||
internal sealed record PolicyRevisionDto(string PackId, int Version, string Status, bool RequiresTwoPersonApproval, DateTimeOffset CreatedAt, DateTimeOffset? ActivatedAt, IReadOnlyList<PolicyActivationApprovalDto> Approvals);
|
||||
|
||||
internal sealed record PolicyActivationApprovalDto(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
|
||||
|
||||
internal sealed record ActivatePolicyRevisionRequest(string? Comment);
|
||||
|
||||
internal sealed record PolicyRevisionActivationResponse(string Status, PolicyRevisionDto Revision);
|
||||
{
|
||||
var user = context.User;
|
||||
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user?.FindFirst(ClaimTypes.Upn)?.Value
|
||||
?? user?.FindFirst("sub")?.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PolicyPackMapper
|
||||
{
|
||||
public static PolicyPackDto ToDto(PolicyPackRecord record)
|
||||
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => ToDto(record.PackId, r)).ToArray());
|
||||
|
||||
public static PolicyPackSummaryDto ToSummaryDto(PolicyPackRecord record)
|
||||
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => r.Version).ToArray());
|
||||
|
||||
public static PolicyRevisionDto ToDto(string packId, PolicyRevisionRecord revision)
|
||||
=> new(
|
||||
packId,
|
||||
revision.Version,
|
||||
revision.Status.ToString(),
|
||||
revision.RequiresTwoPersonApproval,
|
||||
revision.CreatedAt,
|
||||
revision.ActivatedAt,
|
||||
revision.Approvals.Select(a => new PolicyActivationApprovalDto(a.ActorId, a.ApprovedAt, a.Comment)).ToArray());
|
||||
}
|
||||
|
||||
internal sealed record CreatePolicyPackRequest(string? PackId, string? DisplayName);
|
||||
|
||||
internal sealed record PolicyPackDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<PolicyRevisionDto> Revisions);
|
||||
|
||||
internal sealed record PolicyPackSummaryDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<int> Versions);
|
||||
|
||||
internal sealed record CreatePolicyRevisionRequest(int? Version, bool? RequiresTwoPersonApproval, PolicyRevisionStatus InitialStatus = PolicyRevisionStatus.Approved);
|
||||
|
||||
internal sealed record PolicyRevisionDto(string PackId, int Version, string Status, bool RequiresTwoPersonApproval, DateTimeOffset CreatedAt, DateTimeOffset? ActivatedAt, IReadOnlyList<PolicyActivationApprovalDto> Approvals);
|
||||
|
||||
internal sealed record PolicyActivationApprovalDto(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
|
||||
|
||||
internal sealed record ActivatePolicyRevisionRequest(string? Comment);
|
||||
|
||||
internal sealed record PolicyRevisionActivationResponse(string Status, PolicyRevisionDto Revision);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,121 +1,121 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using StellaOps.Policy.RiskProfile.Schema;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class RiskProfileSchemaEndpoints
|
||||
{
|
||||
private const string JsonSchemaMediaType = "application/schema+json";
|
||||
|
||||
public static IEndpointRouteBuilder MapRiskProfileSchema(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet("/.well-known/risk-profile-schema", GetSchema)
|
||||
.WithName("GetRiskProfileSchema")
|
||||
.WithSummary("Get the JSON Schema for risk profile definitions.")
|
||||
.WithTags("Schema Discovery")
|
||||
.Produces<string>(StatusCodes.Status200OK, contentType: JsonSchemaMediaType)
|
||||
.Produces(StatusCodes.Status304NotModified)
|
||||
.AllowAnonymous();
|
||||
|
||||
endpoints.MapPost("/api/risk/schema/validate", ValidateProfile)
|
||||
.WithName("ValidateRiskProfile")
|
||||
.WithSummary("Validate a risk profile document against the schema.")
|
||||
.WithTags("Schema Validation")
|
||||
.Produces<RiskProfileValidationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult GetSchema(HttpContext context)
|
||||
{
|
||||
var schemaText = RiskProfileSchemaProvider.GetSchemaText();
|
||||
var etag = RiskProfileSchemaProvider.GetETag();
|
||||
var version = RiskProfileSchemaProvider.GetSchemaVersion();
|
||||
|
||||
context.Response.Headers[HeaderNames.ETag] = etag;
|
||||
context.Response.Headers[HeaderNames.CacheControl] = "public, max-age=86400";
|
||||
context.Response.Headers["X-StellaOps-Schema-Version"] = version;
|
||||
|
||||
var ifNoneMatch = context.Request.Headers[HeaderNames.IfNoneMatch].ToString();
|
||||
if (!string.IsNullOrEmpty(ifNoneMatch) && ifNoneMatch.Contains(etag.Trim('"')))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
|
||||
return Results.Text(schemaText, JsonSchemaMediaType);
|
||||
}
|
||||
|
||||
private static IResult ValidateProfile(
|
||||
HttpContext context,
|
||||
[FromBody] JsonElement profileDocument)
|
||||
{
|
||||
if (profileDocument.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Profile document is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var schema = RiskProfileSchemaProvider.GetSchema();
|
||||
var jsonText = profileDocument.GetRawText();
|
||||
|
||||
var result = schema.Evaluate(System.Text.Json.Nodes.JsonNode.Parse(jsonText));
|
||||
var issues = new List<RiskProfileValidationIssue>();
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
CollectValidationIssues(result, issues);
|
||||
}
|
||||
|
||||
return Results.Ok(new RiskProfileValidationResponse(
|
||||
IsValid: result.IsValid,
|
||||
SchemaVersion: RiskProfileSchemaProvider.GetSchemaVersion(),
|
||||
Issues: issues));
|
||||
}
|
||||
|
||||
private static void CollectValidationIssues(
|
||||
Json.Schema.EvaluationResults results,
|
||||
List<RiskProfileValidationIssue> issues,
|
||||
string path = "")
|
||||
{
|
||||
if (results.Errors is not null)
|
||||
{
|
||||
foreach (var (key, message) in results.Errors)
|
||||
{
|
||||
var instancePath = results.InstanceLocation?.ToString() ?? path;
|
||||
issues.Add(new RiskProfileValidationIssue(
|
||||
Path: instancePath,
|
||||
Error: key,
|
||||
Message: message));
|
||||
}
|
||||
}
|
||||
|
||||
if (results.Details is not null)
|
||||
{
|
||||
foreach (var detail in results.Details)
|
||||
{
|
||||
if (!detail.IsValid)
|
||||
{
|
||||
CollectValidationIssues(detail, issues, detail.InstanceLocation?.ToString() ?? path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RiskProfileValidationResponse(
|
||||
bool IsValid,
|
||||
string SchemaVersion,
|
||||
IReadOnlyList<RiskProfileValidationIssue> Issues);
|
||||
|
||||
internal sealed record RiskProfileValidationIssue(
|
||||
string Path,
|
||||
string Error,
|
||||
string Message);
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using StellaOps.Policy.RiskProfile.Schema;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class RiskProfileSchemaEndpoints
|
||||
{
|
||||
private const string JsonSchemaMediaType = "application/schema+json";
|
||||
|
||||
public static IEndpointRouteBuilder MapRiskProfileSchema(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet("/.well-known/risk-profile-schema", GetSchema)
|
||||
.WithName("GetRiskProfileSchema")
|
||||
.WithSummary("Get the JSON Schema for risk profile definitions.")
|
||||
.WithTags("Schema Discovery")
|
||||
.Produces<string>(StatusCodes.Status200OK, contentType: JsonSchemaMediaType)
|
||||
.Produces(StatusCodes.Status304NotModified)
|
||||
.AllowAnonymous();
|
||||
|
||||
endpoints.MapPost("/api/risk/schema/validate", ValidateProfile)
|
||||
.WithName("ValidateRiskProfile")
|
||||
.WithSummary("Validate a risk profile document against the schema.")
|
||||
.WithTags("Schema Validation")
|
||||
.Produces<RiskProfileValidationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult GetSchema(HttpContext context)
|
||||
{
|
||||
var schemaText = RiskProfileSchemaProvider.GetSchemaText();
|
||||
var etag = RiskProfileSchemaProvider.GetETag();
|
||||
var version = RiskProfileSchemaProvider.GetSchemaVersion();
|
||||
|
||||
context.Response.Headers[HeaderNames.ETag] = etag;
|
||||
context.Response.Headers[HeaderNames.CacheControl] = "public, max-age=86400";
|
||||
context.Response.Headers["X-StellaOps-Schema-Version"] = version;
|
||||
|
||||
var ifNoneMatch = context.Request.Headers[HeaderNames.IfNoneMatch].ToString();
|
||||
if (!string.IsNullOrEmpty(ifNoneMatch) && ifNoneMatch.Contains(etag.Trim('"')))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
|
||||
return Results.Text(schemaText, JsonSchemaMediaType);
|
||||
}
|
||||
|
||||
private static IResult ValidateProfile(
|
||||
HttpContext context,
|
||||
[FromBody] JsonElement profileDocument)
|
||||
{
|
||||
if (profileDocument.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Profile document is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var schema = RiskProfileSchemaProvider.GetSchema();
|
||||
var jsonText = profileDocument.GetRawText();
|
||||
|
||||
var result = schema.Evaluate(System.Text.Json.Nodes.JsonNode.Parse(jsonText));
|
||||
var issues = new List<RiskProfileValidationIssue>();
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
CollectValidationIssues(result, issues);
|
||||
}
|
||||
|
||||
return Results.Ok(new RiskProfileValidationResponse(
|
||||
IsValid: result.IsValid,
|
||||
SchemaVersion: RiskProfileSchemaProvider.GetSchemaVersion(),
|
||||
Issues: issues));
|
||||
}
|
||||
|
||||
private static void CollectValidationIssues(
|
||||
Json.Schema.EvaluationResults results,
|
||||
List<RiskProfileValidationIssue> issues,
|
||||
string path = "")
|
||||
{
|
||||
if (results.Errors is not null)
|
||||
{
|
||||
foreach (var (key, message) in results.Errors)
|
||||
{
|
||||
var instancePath = results.InstanceLocation?.ToString() ?? path;
|
||||
issues.Add(new RiskProfileValidationIssue(
|
||||
Path: instancePath,
|
||||
Error: key,
|
||||
Message: message));
|
||||
}
|
||||
}
|
||||
|
||||
if (results.Details is not null)
|
||||
{
|
||||
foreach (var detail in results.Details)
|
||||
{
|
||||
if (!detail.IsValid)
|
||||
{
|
||||
CollectValidationIssues(detail, issues, detail.InstanceLocation?.ToString() ?? path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RiskProfileValidationResponse(
|
||||
bool IsValid,
|
||||
string SchemaVersion,
|
||||
IReadOnlyList<RiskProfileValidationIssue> Issues);
|
||||
|
||||
internal sealed record RiskProfileValidationIssue(
|
||||
string Path,
|
||||
string Error,
|
||||
string Message);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
internal sealed record PolicyEvaluationRequest(
|
||||
PolicyIrDocument Document,
|
||||
PolicyEvaluationContext Context);
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
internal sealed record PolicyEvaluationRequest(
|
||||
PolicyIrDocument Document,
|
||||
PolicyEvaluationContext Context);
|
||||
|
||||
internal sealed record PolicyEvaluationContext(
|
||||
PolicyEvaluationSeverity Severity,
|
||||
PolicyEvaluationEnvironment Environment,
|
||||
@@ -21,173 +21,173 @@ internal sealed record PolicyEvaluationContext(
|
||||
PolicyEvaluationReachability Reachability,
|
||||
PolicyEvaluationEntropy Entropy,
|
||||
DateTimeOffset? EvaluationTimestamp = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the evaluation timestamp for deterministic time-based operations.
|
||||
/// This value is injected at evaluation time rather than using DateTime.UtcNow
|
||||
/// to ensure deterministic, reproducible results.
|
||||
/// </summary>
|
||||
public DateTimeOffset Now => EvaluationTimestamp ?? DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a context without reachability data (for backwards compatibility).
|
||||
/// </summary>
|
||||
public PolicyEvaluationContext(
|
||||
PolicyEvaluationSeverity severity,
|
||||
PolicyEvaluationEnvironment environment,
|
||||
PolicyEvaluationAdvisory advisory,
|
||||
PolicyEvaluationVexEvidence vex,
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the evaluation timestamp for deterministic time-based operations.
|
||||
/// This value is injected at evaluation time rather than using DateTime.UtcNow
|
||||
/// to ensure deterministic, reproducible results.
|
||||
/// </summary>
|
||||
public DateTimeOffset Now => EvaluationTimestamp ?? DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a context without reachability data (for backwards compatibility).
|
||||
/// </summary>
|
||||
public PolicyEvaluationContext(
|
||||
PolicyEvaluationSeverity severity,
|
||||
PolicyEvaluationEnvironment environment,
|
||||
PolicyEvaluationAdvisory advisory,
|
||||
PolicyEvaluationVexEvidence vex,
|
||||
PolicyEvaluationSbom sbom,
|
||||
PolicyEvaluationExceptions exceptions,
|
||||
DateTimeOffset? evaluationTimestamp = null)
|
||||
: this(severity, environment, advisory, vex, sbom, exceptions, PolicyEvaluationReachability.Unknown, PolicyEvaluationEntropy.Unknown, evaluationTimestamp)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationSeverity(string Normalized, decimal? Score = null);
|
||||
|
||||
internal sealed record PolicyEvaluationEnvironment(
|
||||
ImmutableDictionary<string, string> Properties)
|
||||
{
|
||||
public string? Get(string key) => Properties.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationAdvisory(
|
||||
string Source,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
internal sealed record PolicyEvaluationVexEvidence(
|
||||
ImmutableArray<PolicyEvaluationVexStatement> Statements)
|
||||
{
|
||||
public static readonly PolicyEvaluationVexEvidence Empty = new(ImmutableArray<PolicyEvaluationVexStatement>.Empty);
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationVexStatement(
|
||||
string Status,
|
||||
string Justification,
|
||||
string StatementId,
|
||||
DateTimeOffset? Timestamp = null);
|
||||
|
||||
internal sealed record PolicyEvaluationSbom(
|
||||
ImmutableHashSet<string> Tags,
|
||||
ImmutableArray<PolicyEvaluationComponent> Components)
|
||||
{
|
||||
public PolicyEvaluationSbom(ImmutableHashSet<string> Tags)
|
||||
: this(Tags, ImmutableArray<PolicyEvaluationComponent>.Empty)
|
||||
{
|
||||
}
|
||||
|
||||
public static readonly PolicyEvaluationSbom Empty = new(
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableArray<PolicyEvaluationComponent>.Empty);
|
||||
|
||||
public bool HasTag(string tag) => Tags.Contains(tag);
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationComponent(
|
||||
string Name,
|
||||
string Version,
|
||||
string Type,
|
||||
string? Purl,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
internal sealed record PolicyEvaluationResult(
|
||||
bool Matched,
|
||||
string Status,
|
||||
string? Severity,
|
||||
string? RuleName,
|
||||
int? Priority,
|
||||
ImmutableDictionary<string, string> Annotations,
|
||||
ImmutableArray<string> Warnings,
|
||||
PolicyExceptionApplication? AppliedException)
|
||||
{
|
||||
public static PolicyEvaluationResult CreateDefault(string? severity) => new(
|
||||
Matched: false,
|
||||
Status: "affected",
|
||||
Severity: severity,
|
||||
RuleName: null,
|
||||
Priority: null,
|
||||
Annotations: ImmutableDictionary<string, string>.Empty,
|
||||
Warnings: ImmutableArray<string>.Empty,
|
||||
AppliedException: null);
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationExceptions(
|
||||
ImmutableDictionary<string, PolicyExceptionEffect> Effects,
|
||||
ImmutableArray<PolicyEvaluationExceptionInstance> Instances)
|
||||
{
|
||||
public static readonly PolicyEvaluationExceptions Empty = new(
|
||||
ImmutableDictionary<string, PolicyExceptionEffect>.Empty,
|
||||
ImmutableArray<PolicyEvaluationExceptionInstance>.Empty);
|
||||
|
||||
public bool IsEmpty => Instances.IsDefaultOrEmpty || Instances.Length == 0;
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationExceptionInstance(
|
||||
string Id,
|
||||
string EffectId,
|
||||
PolicyEvaluationExceptionScope Scope,
|
||||
DateTimeOffset CreatedAt,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
internal sealed record PolicyEvaluationExceptionScope(
|
||||
ImmutableHashSet<string> RuleNames,
|
||||
ImmutableHashSet<string> Severities,
|
||||
ImmutableHashSet<string> Sources,
|
||||
ImmutableHashSet<string> Tags)
|
||||
{
|
||||
public static PolicyEvaluationExceptionScope Empty { get; } = new(
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
public bool IsEmpty => RuleNames.Count == 0
|
||||
&& Severities.Count == 0
|
||||
&& Sources.Count == 0
|
||||
&& Tags.Count == 0;
|
||||
|
||||
public static PolicyEvaluationExceptionScope Create(
|
||||
IEnumerable<string>? ruleNames = null,
|
||||
IEnumerable<string>? severities = null,
|
||||
IEnumerable<string>? sources = null,
|
||||
IEnumerable<string>? tags = null)
|
||||
{
|
||||
return new PolicyEvaluationExceptionScope(
|
||||
Normalize(ruleNames),
|
||||
Normalize(severities),
|
||||
Normalize(sources),
|
||||
Normalize(tags));
|
||||
}
|
||||
|
||||
private static ImmutableHashSet<string> Normalize(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyExceptionApplication(
|
||||
string ExceptionId,
|
||||
string EffectId,
|
||||
PolicyExceptionEffectType EffectType,
|
||||
string OriginalStatus,
|
||||
string? OriginalSeverity,
|
||||
string AppliedStatus,
|
||||
string? AppliedSeverity,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Reachability evidence for policy evaluation.
|
||||
/// </summary>
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationSeverity(string Normalized, decimal? Score = null);
|
||||
|
||||
internal sealed record PolicyEvaluationEnvironment(
|
||||
ImmutableDictionary<string, string> Properties)
|
||||
{
|
||||
public string? Get(string key) => Properties.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationAdvisory(
|
||||
string Source,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
internal sealed record PolicyEvaluationVexEvidence(
|
||||
ImmutableArray<PolicyEvaluationVexStatement> Statements)
|
||||
{
|
||||
public static readonly PolicyEvaluationVexEvidence Empty = new(ImmutableArray<PolicyEvaluationVexStatement>.Empty);
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationVexStatement(
|
||||
string Status,
|
||||
string Justification,
|
||||
string StatementId,
|
||||
DateTimeOffset? Timestamp = null);
|
||||
|
||||
internal sealed record PolicyEvaluationSbom(
|
||||
ImmutableHashSet<string> Tags,
|
||||
ImmutableArray<PolicyEvaluationComponent> Components)
|
||||
{
|
||||
public PolicyEvaluationSbom(ImmutableHashSet<string> Tags)
|
||||
: this(Tags, ImmutableArray<PolicyEvaluationComponent>.Empty)
|
||||
{
|
||||
}
|
||||
|
||||
public static readonly PolicyEvaluationSbom Empty = new(
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableArray<PolicyEvaluationComponent>.Empty);
|
||||
|
||||
public bool HasTag(string tag) => Tags.Contains(tag);
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationComponent(
|
||||
string Name,
|
||||
string Version,
|
||||
string Type,
|
||||
string? Purl,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
internal sealed record PolicyEvaluationResult(
|
||||
bool Matched,
|
||||
string Status,
|
||||
string? Severity,
|
||||
string? RuleName,
|
||||
int? Priority,
|
||||
ImmutableDictionary<string, string> Annotations,
|
||||
ImmutableArray<string> Warnings,
|
||||
PolicyExceptionApplication? AppliedException)
|
||||
{
|
||||
public static PolicyEvaluationResult CreateDefault(string? severity) => new(
|
||||
Matched: false,
|
||||
Status: "affected",
|
||||
Severity: severity,
|
||||
RuleName: null,
|
||||
Priority: null,
|
||||
Annotations: ImmutableDictionary<string, string>.Empty,
|
||||
Warnings: ImmutableArray<string>.Empty,
|
||||
AppliedException: null);
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationExceptions(
|
||||
ImmutableDictionary<string, PolicyExceptionEffect> Effects,
|
||||
ImmutableArray<PolicyEvaluationExceptionInstance> Instances)
|
||||
{
|
||||
public static readonly PolicyEvaluationExceptions Empty = new(
|
||||
ImmutableDictionary<string, PolicyExceptionEffect>.Empty,
|
||||
ImmutableArray<PolicyEvaluationExceptionInstance>.Empty);
|
||||
|
||||
public bool IsEmpty => Instances.IsDefaultOrEmpty || Instances.Length == 0;
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationExceptionInstance(
|
||||
string Id,
|
||||
string EffectId,
|
||||
PolicyEvaluationExceptionScope Scope,
|
||||
DateTimeOffset CreatedAt,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
internal sealed record PolicyEvaluationExceptionScope(
|
||||
ImmutableHashSet<string> RuleNames,
|
||||
ImmutableHashSet<string> Severities,
|
||||
ImmutableHashSet<string> Sources,
|
||||
ImmutableHashSet<string> Tags)
|
||||
{
|
||||
public static PolicyEvaluationExceptionScope Empty { get; } = new(
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
public bool IsEmpty => RuleNames.Count == 0
|
||||
&& Severities.Count == 0
|
||||
&& Sources.Count == 0
|
||||
&& Tags.Count == 0;
|
||||
|
||||
public static PolicyEvaluationExceptionScope Create(
|
||||
IEnumerable<string>? ruleNames = null,
|
||||
IEnumerable<string>? severities = null,
|
||||
IEnumerable<string>? sources = null,
|
||||
IEnumerable<string>? tags = null)
|
||||
{
|
||||
return new PolicyEvaluationExceptionScope(
|
||||
Normalize(ruleNames),
|
||||
Normalize(severities),
|
||||
Normalize(sources),
|
||||
Normalize(tags));
|
||||
}
|
||||
|
||||
private static ImmutableHashSet<string> Normalize(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyExceptionApplication(
|
||||
string ExceptionId,
|
||||
string EffectId,
|
||||
PolicyExceptionEffectType EffectType,
|
||||
string OriginalStatus,
|
||||
string? OriginalSeverity,
|
||||
string AppliedStatus,
|
||||
string? AppliedSeverity,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Reachability evidence for policy evaluation.
|
||||
/// </summary>
|
||||
internal sealed record PolicyEvaluationReachability(
|
||||
string State,
|
||||
decimal Confidence,
|
||||
@@ -197,85 +197,85 @@ internal sealed record PolicyEvaluationReachability(
|
||||
string? Method,
|
||||
string? EvidenceRef)
|
||||
{
|
||||
/// <summary>
|
||||
/// Default unknown reachability state.
|
||||
/// </summary>
|
||||
public static readonly PolicyEvaluationReachability Unknown = new(
|
||||
State: "unknown",
|
||||
Confidence: 0m,
|
||||
Score: 0m,
|
||||
HasRuntimeEvidence: false,
|
||||
Source: null,
|
||||
Method: null,
|
||||
EvidenceRef: null);
|
||||
|
||||
/// <summary>
|
||||
/// Reachable state.
|
||||
/// </summary>
|
||||
public static PolicyEvaluationReachability Reachable(
|
||||
decimal confidence = 1m,
|
||||
decimal score = 1m,
|
||||
bool hasRuntimeEvidence = false,
|
||||
string? source = null,
|
||||
string? method = null) => new(
|
||||
State: "reachable",
|
||||
Confidence: confidence,
|
||||
Score: score,
|
||||
HasRuntimeEvidence: hasRuntimeEvidence,
|
||||
Source: source,
|
||||
Method: method,
|
||||
EvidenceRef: null);
|
||||
|
||||
/// <summary>
|
||||
/// Unreachable state.
|
||||
/// </summary>
|
||||
public static PolicyEvaluationReachability Unreachable(
|
||||
decimal confidence = 1m,
|
||||
bool hasRuntimeEvidence = false,
|
||||
string? source = null,
|
||||
string? method = null) => new(
|
||||
State: "unreachable",
|
||||
Confidence: confidence,
|
||||
Score: 0m,
|
||||
HasRuntimeEvidence: hasRuntimeEvidence,
|
||||
Source: source,
|
||||
Method: method,
|
||||
EvidenceRef: null);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the reachability state is definitively reachable.
|
||||
/// </summary>
|
||||
public bool IsReachable => State.Equals("reachable", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the reachability state is definitively unreachable.
|
||||
/// </summary>
|
||||
public bool IsUnreachable => State.Equals("unreachable", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the reachability state is unknown.
|
||||
/// </summary>
|
||||
public bool IsUnknown => State.Equals("unknown", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the reachability state is under investigation.
|
||||
/// </summary>
|
||||
public bool IsUnderInvestigation => State.Equals("under_investigation", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether this reachability data has high confidence (>= 0.8).
|
||||
/// </summary>
|
||||
public bool IsHighConfidence => Confidence >= 0.8m;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this reachability data has medium confidence (>= 0.5 and < 0.8).
|
||||
/// </summary>
|
||||
public bool IsMediumConfidence => Confidence >= 0.5m && Confidence < 0.8m;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this reachability data has low confidence (< 0.5).
|
||||
/// </summary>
|
||||
public bool IsLowConfidence => Confidence < 0.5m;
|
||||
/// <summary>
|
||||
/// Default unknown reachability state.
|
||||
/// </summary>
|
||||
public static readonly PolicyEvaluationReachability Unknown = new(
|
||||
State: "unknown",
|
||||
Confidence: 0m,
|
||||
Score: 0m,
|
||||
HasRuntimeEvidence: false,
|
||||
Source: null,
|
||||
Method: null,
|
||||
EvidenceRef: null);
|
||||
|
||||
/// <summary>
|
||||
/// Reachable state.
|
||||
/// </summary>
|
||||
public static PolicyEvaluationReachability Reachable(
|
||||
decimal confidence = 1m,
|
||||
decimal score = 1m,
|
||||
bool hasRuntimeEvidence = false,
|
||||
string? source = null,
|
||||
string? method = null) => new(
|
||||
State: "reachable",
|
||||
Confidence: confidence,
|
||||
Score: score,
|
||||
HasRuntimeEvidence: hasRuntimeEvidence,
|
||||
Source: source,
|
||||
Method: method,
|
||||
EvidenceRef: null);
|
||||
|
||||
/// <summary>
|
||||
/// Unreachable state.
|
||||
/// </summary>
|
||||
public static PolicyEvaluationReachability Unreachable(
|
||||
decimal confidence = 1m,
|
||||
bool hasRuntimeEvidence = false,
|
||||
string? source = null,
|
||||
string? method = null) => new(
|
||||
State: "unreachable",
|
||||
Confidence: confidence,
|
||||
Score: 0m,
|
||||
HasRuntimeEvidence: hasRuntimeEvidence,
|
||||
Source: source,
|
||||
Method: method,
|
||||
EvidenceRef: null);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the reachability state is definitively reachable.
|
||||
/// </summary>
|
||||
public bool IsReachable => State.Equals("reachable", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the reachability state is definitively unreachable.
|
||||
/// </summary>
|
||||
public bool IsUnreachable => State.Equals("unreachable", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the reachability state is unknown.
|
||||
/// </summary>
|
||||
public bool IsUnknown => State.Equals("unknown", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the reachability state is under investigation.
|
||||
/// </summary>
|
||||
public bool IsUnderInvestigation => State.Equals("under_investigation", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether this reachability data has high confidence (>= 0.8).
|
||||
/// </summary>
|
||||
public bool IsHighConfidence => Confidence >= 0.8m;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this reachability data has medium confidence (>= 0.5 and < 0.8).
|
||||
/// </summary>
|
||||
public bool IsMediumConfidence => Confidence >= 0.5m && Confidence < 0.8m;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this reachability data has low confidence (< 0.5).
|
||||
/// </summary>
|
||||
public bool IsLowConfidence => Confidence < 0.5m;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,420 +1,420 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministically evaluates compiled policy IR against advisory/VEX/SBOM inputs.
|
||||
/// </summary>
|
||||
internal sealed class PolicyEvaluator
|
||||
{
|
||||
public PolicyEvaluationResult Evaluate(PolicyEvaluationRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (request.Document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request.Document));
|
||||
}
|
||||
|
||||
var evaluator = new PolicyExpressionEvaluator(request.Context);
|
||||
var orderedRules = request.Document.Rules
|
||||
.Select(static (rule, index) => new { rule, index })
|
||||
.OrderBy(x => x.rule.Priority)
|
||||
.ThenBy(x => x.index)
|
||||
.ToImmutableArray();
|
||||
|
||||
foreach (var entry in orderedRules)
|
||||
{
|
||||
var rule = entry.rule;
|
||||
if (!evaluator.EvaluateBoolean(rule.When))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var runtime = new PolicyRuntimeState(request.Context.Severity.Normalized);
|
||||
foreach (var action in rule.ThenActions)
|
||||
{
|
||||
ApplyAction(rule.Name, action, evaluator, runtime);
|
||||
}
|
||||
|
||||
if (runtime.Status is null)
|
||||
{
|
||||
runtime.Status = "affected";
|
||||
}
|
||||
|
||||
var baseResult = new PolicyEvaluationResult(
|
||||
Matched: true,
|
||||
Status: runtime.Status,
|
||||
Severity: runtime.Severity,
|
||||
RuleName: rule.Name,
|
||||
Priority: rule.Priority,
|
||||
Annotations: runtime.Annotations.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
Warnings: runtime.Warnings.ToImmutableArray(),
|
||||
AppliedException: null);
|
||||
|
||||
return ApplyExceptions(request, baseResult);
|
||||
}
|
||||
|
||||
var defaultResult = PolicyEvaluationResult.CreateDefault(request.Context.Severity.Normalized);
|
||||
return ApplyExceptions(request, defaultResult);
|
||||
}
|
||||
|
||||
private static void ApplyAction(
|
||||
string ruleName,
|
||||
PolicyIrAction action,
|
||||
PolicyExpressionEvaluator evaluator,
|
||||
PolicyRuntimeState runtime)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case PolicyIrAssignmentAction assign:
|
||||
ApplyAssignment(assign, evaluator, runtime);
|
||||
break;
|
||||
case PolicyIrAnnotateAction annotate:
|
||||
ApplyAnnotate(annotate, evaluator, runtime);
|
||||
break;
|
||||
case PolicyIrWarnAction warn:
|
||||
ApplyWarn(warn, evaluator, runtime);
|
||||
break;
|
||||
case PolicyIrEscalateAction escalate:
|
||||
ApplyEscalate(escalate, evaluator, runtime);
|
||||
break;
|
||||
case PolicyIrRequireVexAction require:
|
||||
var allSatisfied = true;
|
||||
foreach (var condition in require.Conditions.Values)
|
||||
{
|
||||
if (!evaluator.EvaluateBoolean(condition))
|
||||
{
|
||||
allSatisfied = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Status ??= allSatisfied ? "affected" : "suppressed";
|
||||
break;
|
||||
case PolicyIrIgnoreAction ignore:
|
||||
runtime.Status = "ignored";
|
||||
break;
|
||||
case PolicyIrDeferAction defer:
|
||||
runtime.Status = "deferred";
|
||||
break;
|
||||
default:
|
||||
runtime.Warnings.Add($"Unhandled action '{action.GetType().Name}' in rule '{ruleName}'.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyAssignment(PolicyIrAssignmentAction assign, PolicyExpressionEvaluator evaluator, PolicyRuntimeState runtime)
|
||||
{
|
||||
var value = evaluator.Evaluate(assign.Value);
|
||||
var stringValue = value.AsString();
|
||||
if (assign.Target.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var target = assign.Target[0];
|
||||
switch (target)
|
||||
{
|
||||
case "status":
|
||||
runtime.Status = stringValue ?? runtime.Status ?? "affected";
|
||||
break;
|
||||
case "severity":
|
||||
runtime.Severity = stringValue;
|
||||
break;
|
||||
default:
|
||||
runtime.Annotations[target] = stringValue ?? value.Raw?.ToString() ?? string.Empty;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyAnnotate(PolicyIrAnnotateAction annotate, PolicyExpressionEvaluator evaluator, PolicyRuntimeState runtime)
|
||||
{
|
||||
var key = annotate.Target.Length > 0 ? annotate.Target[^1] : "annotation";
|
||||
var value = evaluator.Evaluate(annotate.Value).AsString() ?? string.Empty;
|
||||
runtime.Annotations[key] = value;
|
||||
}
|
||||
|
||||
private static void ApplyWarn(PolicyIrWarnAction warn, PolicyExpressionEvaluator evaluator, PolicyRuntimeState runtime)
|
||||
{
|
||||
var message = warn.Message is null ? "" : evaluator.Evaluate(warn.Message).AsString();
|
||||
if (!string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
runtime.Warnings.Add(message!);
|
||||
}
|
||||
else
|
||||
{
|
||||
runtime.Warnings.Add("Policy rule emitted a warning.");
|
||||
}
|
||||
|
||||
runtime.Status ??= "warned";
|
||||
}
|
||||
|
||||
private static void ApplyEscalate(PolicyIrEscalateAction escalate, PolicyExpressionEvaluator evaluator, PolicyRuntimeState runtime)
|
||||
{
|
||||
if (escalate.To is not null)
|
||||
{
|
||||
runtime.Severity = evaluator.Evaluate(escalate.To).AsString() ?? runtime.Severity;
|
||||
}
|
||||
|
||||
if (escalate.When is not null && !evaluator.EvaluateBoolean(escalate.When))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PolicyRuntimeState
|
||||
{
|
||||
public PolicyRuntimeState(string? initialSeverity)
|
||||
{
|
||||
Severity = initialSeverity;
|
||||
}
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public string? Severity { get; set; }
|
||||
|
||||
public Dictionary<string, string> Annotations { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public List<string> Warnings { get; } = new();
|
||||
}
|
||||
|
||||
private static PolicyEvaluationResult ApplyExceptions(PolicyEvaluationRequest request, PolicyEvaluationResult baseResult)
|
||||
{
|
||||
var exceptions = request.Context.Exceptions;
|
||||
if (exceptions.IsEmpty)
|
||||
{
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
PolicyEvaluationExceptionInstance? winningInstance = null;
|
||||
PolicyExceptionEffect? winningEffect = null;
|
||||
var winningScore = -1;
|
||||
|
||||
foreach (var instance in exceptions.Instances)
|
||||
{
|
||||
if (!exceptions.Effects.TryGetValue(instance.EffectId, out var effect))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!MatchesScope(instance.Scope, request, baseResult))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var specificity = ComputeSpecificity(instance.Scope);
|
||||
if (specificity < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (winningInstance is null
|
||||
|| specificity > winningScore
|
||||
|| (specificity == winningScore && instance.CreatedAt > winningInstance.CreatedAt)
|
||||
|| (specificity == winningScore && instance.CreatedAt == winningInstance!.CreatedAt
|
||||
&& string.CompareOrdinal(instance.Id, winningInstance.Id) < 0))
|
||||
{
|
||||
winningInstance = instance;
|
||||
winningEffect = effect;
|
||||
winningScore = specificity;
|
||||
}
|
||||
}
|
||||
|
||||
if (winningInstance is null || winningEffect is null)
|
||||
{
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
return ApplyExceptionEffect(baseResult, winningInstance, winningEffect);
|
||||
}
|
||||
|
||||
private static bool MatchesScope(
|
||||
PolicyEvaluationExceptionScope scope,
|
||||
PolicyEvaluationRequest request,
|
||||
PolicyEvaluationResult baseResult)
|
||||
{
|
||||
if (scope.RuleNames.Count > 0)
|
||||
{
|
||||
if (string.IsNullOrEmpty(baseResult.RuleName)
|
||||
|| !scope.RuleNames.Contains(baseResult.RuleName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (scope.Severities.Count > 0)
|
||||
{
|
||||
var severity = request.Context.Severity.Normalized;
|
||||
if (string.IsNullOrEmpty(severity)
|
||||
|| !scope.Severities.Contains(severity))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (scope.Sources.Count > 0)
|
||||
{
|
||||
var source = request.Context.Advisory.Source;
|
||||
if (string.IsNullOrEmpty(source)
|
||||
|| !scope.Sources.Contains(source))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (scope.Tags.Count > 0)
|
||||
{
|
||||
var sbom = request.Context.Sbom;
|
||||
var hasMatch = scope.Tags.Any(sbom.HasTag);
|
||||
if (!hasMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int ComputeSpecificity(PolicyEvaluationExceptionScope scope)
|
||||
{
|
||||
var score = 0;
|
||||
|
||||
if (scope.RuleNames.Count > 0)
|
||||
{
|
||||
score += 1_000 + scope.RuleNames.Count * 25;
|
||||
}
|
||||
|
||||
if (scope.Severities.Count > 0)
|
||||
{
|
||||
score += 500 + scope.Severities.Count * 10;
|
||||
}
|
||||
|
||||
if (scope.Sources.Count > 0)
|
||||
{
|
||||
score += 250 + scope.Sources.Count * 10;
|
||||
}
|
||||
|
||||
if (scope.Tags.Count > 0)
|
||||
{
|
||||
score += 100 + scope.Tags.Count * 5;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static PolicyEvaluationResult ApplyExceptionEffect(
|
||||
PolicyEvaluationResult baseResult,
|
||||
PolicyEvaluationExceptionInstance instance,
|
||||
PolicyExceptionEffect effect)
|
||||
{
|
||||
var annotationsBuilder = baseResult.Annotations.ToBuilder();
|
||||
annotationsBuilder["exception.id"] = instance.Id;
|
||||
annotationsBuilder["exception.effectId"] = effect.Id;
|
||||
annotationsBuilder["exception.effectType"] = effect.Effect.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effect.Name))
|
||||
{
|
||||
annotationsBuilder["exception.effectName"] = effect.Name!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
|
||||
{
|
||||
annotationsBuilder["exception.routingTemplate"] = effect.RoutingTemplate!;
|
||||
}
|
||||
|
||||
if (effect.MaxDurationDays is int durationDays)
|
||||
{
|
||||
annotationsBuilder["exception.maxDurationDays"] = durationDays.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
foreach (var pair in instance.Metadata)
|
||||
{
|
||||
annotationsBuilder[$"exception.meta.{pair.Key}"] = pair.Value;
|
||||
}
|
||||
|
||||
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
|
||||
{
|
||||
metadataBuilder["routingTemplate"] = effect.RoutingTemplate!;
|
||||
}
|
||||
|
||||
if (effect.MaxDurationDays is int metadataDuration)
|
||||
{
|
||||
metadataBuilder["maxDurationDays"] = metadataDuration.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
|
||||
{
|
||||
metadataBuilder["requiredControlId"] = effect.RequiredControlId!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effect.Name))
|
||||
{
|
||||
metadataBuilder["effectName"] = effect.Name!;
|
||||
}
|
||||
|
||||
foreach (var pair in instance.Metadata)
|
||||
{
|
||||
metadataBuilder[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
var newStatus = baseResult.Status;
|
||||
var newSeverity = baseResult.Severity;
|
||||
var warnings = baseResult.Warnings;
|
||||
|
||||
switch (effect.Effect)
|
||||
{
|
||||
case PolicyExceptionEffectType.Suppress:
|
||||
newStatus = "suppressed";
|
||||
annotationsBuilder["exception.status"] = newStatus;
|
||||
break;
|
||||
case PolicyExceptionEffectType.Defer:
|
||||
newStatus = "deferred";
|
||||
annotationsBuilder["exception.status"] = newStatus;
|
||||
break;
|
||||
case PolicyExceptionEffectType.Downgrade:
|
||||
if (effect.DowngradeSeverity is { } downgradeSeverity)
|
||||
{
|
||||
newSeverity = downgradeSeverity.ToString();
|
||||
annotationsBuilder["exception.severity"] = newSeverity!;
|
||||
}
|
||||
break;
|
||||
case PolicyExceptionEffectType.RequireControl:
|
||||
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
|
||||
{
|
||||
annotationsBuilder["exception.requiredControl"] = effect.RequiredControlId!;
|
||||
warnings = warnings.Add($"Exception '{instance.Id}' requires control '{effect.RequiredControlId}'.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
var application = new PolicyExceptionApplication(
|
||||
ExceptionId: instance.Id,
|
||||
EffectId: instance.EffectId,
|
||||
EffectType: effect.Effect,
|
||||
OriginalStatus: baseResult.Status,
|
||||
OriginalSeverity: baseResult.Severity,
|
||||
AppliedStatus: newStatus,
|
||||
AppliedSeverity: newSeverity,
|
||||
Metadata: metadataBuilder.ToImmutable());
|
||||
|
||||
return baseResult with
|
||||
{
|
||||
Status = newStatus,
|
||||
Severity = newSeverity,
|
||||
Annotations = annotationsBuilder.ToImmutable(),
|
||||
Warnings = warnings,
|
||||
AppliedException = application,
|
||||
};
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministically evaluates compiled policy IR against advisory/VEX/SBOM inputs.
|
||||
/// </summary>
|
||||
internal sealed class PolicyEvaluator
|
||||
{
|
||||
public PolicyEvaluationResult Evaluate(PolicyEvaluationRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (request.Document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request.Document));
|
||||
}
|
||||
|
||||
var evaluator = new PolicyExpressionEvaluator(request.Context);
|
||||
var orderedRules = request.Document.Rules
|
||||
.Select(static (rule, index) => new { rule, index })
|
||||
.OrderBy(x => x.rule.Priority)
|
||||
.ThenBy(x => x.index)
|
||||
.ToImmutableArray();
|
||||
|
||||
foreach (var entry in orderedRules)
|
||||
{
|
||||
var rule = entry.rule;
|
||||
if (!evaluator.EvaluateBoolean(rule.When))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var runtime = new PolicyRuntimeState(request.Context.Severity.Normalized);
|
||||
foreach (var action in rule.ThenActions)
|
||||
{
|
||||
ApplyAction(rule.Name, action, evaluator, runtime);
|
||||
}
|
||||
|
||||
if (runtime.Status is null)
|
||||
{
|
||||
runtime.Status = "affected";
|
||||
}
|
||||
|
||||
var baseResult = new PolicyEvaluationResult(
|
||||
Matched: true,
|
||||
Status: runtime.Status,
|
||||
Severity: runtime.Severity,
|
||||
RuleName: rule.Name,
|
||||
Priority: rule.Priority,
|
||||
Annotations: runtime.Annotations.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
Warnings: runtime.Warnings.ToImmutableArray(),
|
||||
AppliedException: null);
|
||||
|
||||
return ApplyExceptions(request, baseResult);
|
||||
}
|
||||
|
||||
var defaultResult = PolicyEvaluationResult.CreateDefault(request.Context.Severity.Normalized);
|
||||
return ApplyExceptions(request, defaultResult);
|
||||
}
|
||||
|
||||
private static void ApplyAction(
|
||||
string ruleName,
|
||||
PolicyIrAction action,
|
||||
PolicyExpressionEvaluator evaluator,
|
||||
PolicyRuntimeState runtime)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case PolicyIrAssignmentAction assign:
|
||||
ApplyAssignment(assign, evaluator, runtime);
|
||||
break;
|
||||
case PolicyIrAnnotateAction annotate:
|
||||
ApplyAnnotate(annotate, evaluator, runtime);
|
||||
break;
|
||||
case PolicyIrWarnAction warn:
|
||||
ApplyWarn(warn, evaluator, runtime);
|
||||
break;
|
||||
case PolicyIrEscalateAction escalate:
|
||||
ApplyEscalate(escalate, evaluator, runtime);
|
||||
break;
|
||||
case PolicyIrRequireVexAction require:
|
||||
var allSatisfied = true;
|
||||
foreach (var condition in require.Conditions.Values)
|
||||
{
|
||||
if (!evaluator.EvaluateBoolean(condition))
|
||||
{
|
||||
allSatisfied = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Status ??= allSatisfied ? "affected" : "suppressed";
|
||||
break;
|
||||
case PolicyIrIgnoreAction ignore:
|
||||
runtime.Status = "ignored";
|
||||
break;
|
||||
case PolicyIrDeferAction defer:
|
||||
runtime.Status = "deferred";
|
||||
break;
|
||||
default:
|
||||
runtime.Warnings.Add($"Unhandled action '{action.GetType().Name}' in rule '{ruleName}'.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyAssignment(PolicyIrAssignmentAction assign, PolicyExpressionEvaluator evaluator, PolicyRuntimeState runtime)
|
||||
{
|
||||
var value = evaluator.Evaluate(assign.Value);
|
||||
var stringValue = value.AsString();
|
||||
if (assign.Target.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var target = assign.Target[0];
|
||||
switch (target)
|
||||
{
|
||||
case "status":
|
||||
runtime.Status = stringValue ?? runtime.Status ?? "affected";
|
||||
break;
|
||||
case "severity":
|
||||
runtime.Severity = stringValue;
|
||||
break;
|
||||
default:
|
||||
runtime.Annotations[target] = stringValue ?? value.Raw?.ToString() ?? string.Empty;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyAnnotate(PolicyIrAnnotateAction annotate, PolicyExpressionEvaluator evaluator, PolicyRuntimeState runtime)
|
||||
{
|
||||
var key = annotate.Target.Length > 0 ? annotate.Target[^1] : "annotation";
|
||||
var value = evaluator.Evaluate(annotate.Value).AsString() ?? string.Empty;
|
||||
runtime.Annotations[key] = value;
|
||||
}
|
||||
|
||||
private static void ApplyWarn(PolicyIrWarnAction warn, PolicyExpressionEvaluator evaluator, PolicyRuntimeState runtime)
|
||||
{
|
||||
var message = warn.Message is null ? "" : evaluator.Evaluate(warn.Message).AsString();
|
||||
if (!string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
runtime.Warnings.Add(message!);
|
||||
}
|
||||
else
|
||||
{
|
||||
runtime.Warnings.Add("Policy rule emitted a warning.");
|
||||
}
|
||||
|
||||
runtime.Status ??= "warned";
|
||||
}
|
||||
|
||||
private static void ApplyEscalate(PolicyIrEscalateAction escalate, PolicyExpressionEvaluator evaluator, PolicyRuntimeState runtime)
|
||||
{
|
||||
if (escalate.To is not null)
|
||||
{
|
||||
runtime.Severity = evaluator.Evaluate(escalate.To).AsString() ?? runtime.Severity;
|
||||
}
|
||||
|
||||
if (escalate.When is not null && !evaluator.EvaluateBoolean(escalate.When))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PolicyRuntimeState
|
||||
{
|
||||
public PolicyRuntimeState(string? initialSeverity)
|
||||
{
|
||||
Severity = initialSeverity;
|
||||
}
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public string? Severity { get; set; }
|
||||
|
||||
public Dictionary<string, string> Annotations { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public List<string> Warnings { get; } = new();
|
||||
}
|
||||
|
||||
private static PolicyEvaluationResult ApplyExceptions(PolicyEvaluationRequest request, PolicyEvaluationResult baseResult)
|
||||
{
|
||||
var exceptions = request.Context.Exceptions;
|
||||
if (exceptions.IsEmpty)
|
||||
{
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
PolicyEvaluationExceptionInstance? winningInstance = null;
|
||||
PolicyExceptionEffect? winningEffect = null;
|
||||
var winningScore = -1;
|
||||
|
||||
foreach (var instance in exceptions.Instances)
|
||||
{
|
||||
if (!exceptions.Effects.TryGetValue(instance.EffectId, out var effect))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!MatchesScope(instance.Scope, request, baseResult))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var specificity = ComputeSpecificity(instance.Scope);
|
||||
if (specificity < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (winningInstance is null
|
||||
|| specificity > winningScore
|
||||
|| (specificity == winningScore && instance.CreatedAt > winningInstance.CreatedAt)
|
||||
|| (specificity == winningScore && instance.CreatedAt == winningInstance!.CreatedAt
|
||||
&& string.CompareOrdinal(instance.Id, winningInstance.Id) < 0))
|
||||
{
|
||||
winningInstance = instance;
|
||||
winningEffect = effect;
|
||||
winningScore = specificity;
|
||||
}
|
||||
}
|
||||
|
||||
if (winningInstance is null || winningEffect is null)
|
||||
{
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
return ApplyExceptionEffect(baseResult, winningInstance, winningEffect);
|
||||
}
|
||||
|
||||
private static bool MatchesScope(
|
||||
PolicyEvaluationExceptionScope scope,
|
||||
PolicyEvaluationRequest request,
|
||||
PolicyEvaluationResult baseResult)
|
||||
{
|
||||
if (scope.RuleNames.Count > 0)
|
||||
{
|
||||
if (string.IsNullOrEmpty(baseResult.RuleName)
|
||||
|| !scope.RuleNames.Contains(baseResult.RuleName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (scope.Severities.Count > 0)
|
||||
{
|
||||
var severity = request.Context.Severity.Normalized;
|
||||
if (string.IsNullOrEmpty(severity)
|
||||
|| !scope.Severities.Contains(severity))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (scope.Sources.Count > 0)
|
||||
{
|
||||
var source = request.Context.Advisory.Source;
|
||||
if (string.IsNullOrEmpty(source)
|
||||
|| !scope.Sources.Contains(source))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (scope.Tags.Count > 0)
|
||||
{
|
||||
var sbom = request.Context.Sbom;
|
||||
var hasMatch = scope.Tags.Any(sbom.HasTag);
|
||||
if (!hasMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int ComputeSpecificity(PolicyEvaluationExceptionScope scope)
|
||||
{
|
||||
var score = 0;
|
||||
|
||||
if (scope.RuleNames.Count > 0)
|
||||
{
|
||||
score += 1_000 + scope.RuleNames.Count * 25;
|
||||
}
|
||||
|
||||
if (scope.Severities.Count > 0)
|
||||
{
|
||||
score += 500 + scope.Severities.Count * 10;
|
||||
}
|
||||
|
||||
if (scope.Sources.Count > 0)
|
||||
{
|
||||
score += 250 + scope.Sources.Count * 10;
|
||||
}
|
||||
|
||||
if (scope.Tags.Count > 0)
|
||||
{
|
||||
score += 100 + scope.Tags.Count * 5;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static PolicyEvaluationResult ApplyExceptionEffect(
|
||||
PolicyEvaluationResult baseResult,
|
||||
PolicyEvaluationExceptionInstance instance,
|
||||
PolicyExceptionEffect effect)
|
||||
{
|
||||
var annotationsBuilder = baseResult.Annotations.ToBuilder();
|
||||
annotationsBuilder["exception.id"] = instance.Id;
|
||||
annotationsBuilder["exception.effectId"] = effect.Id;
|
||||
annotationsBuilder["exception.effectType"] = effect.Effect.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effect.Name))
|
||||
{
|
||||
annotationsBuilder["exception.effectName"] = effect.Name!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
|
||||
{
|
||||
annotationsBuilder["exception.routingTemplate"] = effect.RoutingTemplate!;
|
||||
}
|
||||
|
||||
if (effect.MaxDurationDays is int durationDays)
|
||||
{
|
||||
annotationsBuilder["exception.maxDurationDays"] = durationDays.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
foreach (var pair in instance.Metadata)
|
||||
{
|
||||
annotationsBuilder[$"exception.meta.{pair.Key}"] = pair.Value;
|
||||
}
|
||||
|
||||
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
|
||||
{
|
||||
metadataBuilder["routingTemplate"] = effect.RoutingTemplate!;
|
||||
}
|
||||
|
||||
if (effect.MaxDurationDays is int metadataDuration)
|
||||
{
|
||||
metadataBuilder["maxDurationDays"] = metadataDuration.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
|
||||
{
|
||||
metadataBuilder["requiredControlId"] = effect.RequiredControlId!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effect.Name))
|
||||
{
|
||||
metadataBuilder["effectName"] = effect.Name!;
|
||||
}
|
||||
|
||||
foreach (var pair in instance.Metadata)
|
||||
{
|
||||
metadataBuilder[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
var newStatus = baseResult.Status;
|
||||
var newSeverity = baseResult.Severity;
|
||||
var warnings = baseResult.Warnings;
|
||||
|
||||
switch (effect.Effect)
|
||||
{
|
||||
case PolicyExceptionEffectType.Suppress:
|
||||
newStatus = "suppressed";
|
||||
annotationsBuilder["exception.status"] = newStatus;
|
||||
break;
|
||||
case PolicyExceptionEffectType.Defer:
|
||||
newStatus = "deferred";
|
||||
annotationsBuilder["exception.status"] = newStatus;
|
||||
break;
|
||||
case PolicyExceptionEffectType.Downgrade:
|
||||
if (effect.DowngradeSeverity is { } downgradeSeverity)
|
||||
{
|
||||
newSeverity = downgradeSeverity.ToString();
|
||||
annotationsBuilder["exception.severity"] = newSeverity!;
|
||||
}
|
||||
break;
|
||||
case PolicyExceptionEffectType.RequireControl:
|
||||
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
|
||||
{
|
||||
annotationsBuilder["exception.requiredControl"] = effect.RequiredControlId!;
|
||||
warnings = warnings.Add($"Exception '{instance.Id}' requires control '{effect.RequiredControlId}'.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
var application = new PolicyExceptionApplication(
|
||||
ExceptionId: instance.Id,
|
||||
EffectId: instance.EffectId,
|
||||
EffectType: effect.Effect,
|
||||
OriginalStatus: baseResult.Status,
|
||||
OriginalSeverity: baseResult.Severity,
|
||||
AppliedStatus: newStatus,
|
||||
AppliedSeverity: newSeverity,
|
||||
Metadata: metadataBuilder.ToImmutable());
|
||||
|
||||
return baseResult with
|
||||
{
|
||||
Status = newStatus,
|
||||
Severity = newSeverity,
|
||||
Annotations = annotationsBuilder.ToImmutable(),
|
||||
Warnings = warnings,
|
||||
AppliedException = application,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Hosting;
|
||||
|
||||
internal sealed class PolicyEngineStartupDiagnostics
|
||||
{
|
||||
private int isReady;
|
||||
|
||||
public bool IsReady => Volatile.Read(ref isReady) == 1;
|
||||
|
||||
public void MarkReady() => Volatile.Write(ref isReady, 1);
|
||||
}
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Hosting;
|
||||
|
||||
internal sealed class PolicyEngineStartupDiagnostics
|
||||
{
|
||||
private int isReady;
|
||||
|
||||
public bool IsReady => Volatile.Read(ref isReady) == 1;
|
||||
|
||||
public void MarkReady() => Volatile.Write(ref isReady, 1);
|
||||
}
|
||||
|
||||
@@ -1,355 +1,355 @@
|
||||
using System.IO;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NetEscapades.Configuration.Yaml;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Policy.Engine.Hosting;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Endpoints;
|
||||
using StellaOps.Policy.Engine.BatchEvaluation;
|
||||
using StellaOps.Policy.Engine.DependencyInjection;
|
||||
using StellaOps.PolicyDsl;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.Policy.Engine.ConsoleSurface;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
using StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
using StellaOps.Policy.Engine.Storage.InMemory;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var policyEngineConfigFiles = new[]
|
||||
{
|
||||
"../etc/policy-engine.yaml",
|
||||
"../etc/policy-engine.local.yaml",
|
||||
"policy-engine.yaml",
|
||||
"policy-engine.local.yaml"
|
||||
};
|
||||
|
||||
var policyEngineActivationConfigFiles = new[]
|
||||
{
|
||||
"../etc/policy-engine.activation.yaml",
|
||||
"../etc/policy-engine.activation.local.yaml",
|
||||
"/config/policy-engine/activation.yaml",
|
||||
"policy-engine.activation.yaml",
|
||||
"policy-engine.activation.local.yaml"
|
||||
};
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole();
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
var contentRoot = builder.Environment.ContentRootPath;
|
||||
foreach (var relative in policyEngineConfigFiles)
|
||||
{
|
||||
var path = Path.Combine(contentRoot, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
|
||||
foreach (var relative in policyEngineActivationConfigFiles)
|
||||
{
|
||||
var path = Path.Combine(contentRoot, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
|
||||
options.BindingSection = PolicyEngineOptions.SectionName;
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
foreach (var relative in policyEngineConfigFiles)
|
||||
{
|
||||
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
|
||||
foreach (var relative in policyEngineActivationConfigFiles)
|
||||
{
|
||||
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
};
|
||||
options.PostBind = static (value, _) => value.Validate();
|
||||
});
|
||||
|
||||
builder.Configuration.AddConfiguration(bootstrap.Configuration);
|
||||
|
||||
builder.ConfigurePolicyEngineTelemetry(bootstrap.Options);
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
||||
|
||||
// CVSS receipts rely on PostgreSQL storage for deterministic persistence.
|
||||
builder.Services.AddPolicyPostgresStorage(builder.Configuration, sectionName: "Postgres:Policy");
|
||||
|
||||
builder.Services.AddSingleton<ICvssV4Engine, CvssV4Engine>();
|
||||
builder.Services.AddScoped<IReceiptBuilder, ReceiptBuilder>();
|
||||
builder.Services.AddScoped<IReceiptHistoryService, ReceiptHistoryService>();
|
||||
|
||||
builder.Services.AddOptions<PolicyEngineOptions>()
|
||||
.Bind(builder.Configuration.GetSection(PolicyEngineOptions.SectionName))
|
||||
.Validate(options =>
|
||||
{
|
||||
try
|
||||
{
|
||||
options.Validate();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new OptionsValidationException(
|
||||
PolicyEngineOptions.SectionName,
|
||||
typeof(PolicyEngineOptions),
|
||||
new[] { ex.Message });
|
||||
}
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<PolicyEngineOptions>().ExceptionLifecycle);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
|
||||
builder.Services.AddSingleton<PolicyTimelineEvents>();
|
||||
builder.Services.AddSingleton<EvidenceBundleService>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationAttestationService>();
|
||||
builder.Services.AddSingleton<IncidentModeService>();
|
||||
builder.Services.AddSingleton<RiskProfileConfigurationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Lifecycle.RiskProfileLifecycleService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Scope.ScopeAttachmentService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Scope.EffectivePolicyService>();
|
||||
builder.Services.AddSingleton<IEffectivePolicyAuditor, EffectivePolicyAuditor>(); // CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.IVerificationPolicyStore, StellaOps.Policy.Engine.Attestation.InMemoryVerificationPolicyStore>(); // CONTRACT-VERIFICATION-POLICY-006
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.VerificationPolicyValidator>(); // CONTRACT-VERIFICATION-POLICY-006 validation
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.IAttestationReportStore, StellaOps.Policy.Engine.Attestation.InMemoryAttestationReportStore>(); // CONTRACT-VERIFICATION-POLICY-006 reports
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.IAttestationReportService, StellaOps.Policy.Engine.Attestation.AttestationReportService>(); // CONTRACT-VERIFICATION-POLICY-006 reports
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleSurface.ConsoleAttestationReportService>(); // CONTRACT-VERIFICATION-POLICY-006 Console integration
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Overrides.OverrideService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.IRiskScoringJobStore, StellaOps.Policy.Engine.Scoring.InMemoryRiskScoringJobStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.RiskScoringTriggerService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.RiskSimulationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Signals.Entropy.EntropyPenaltyCalculator>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Export.ProfileExportService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.ProfileEventPublisher>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.IExceptionEventPublisher>(sp =>
|
||||
new StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher(
|
||||
sp.GetService<StellaOps.Policy.Engine.ExceptionCache.IExceptionEffectiveCache>(),
|
||||
sp.GetRequiredService<ILogger<StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher>>()));
|
||||
builder.Services.AddSingleton<ExceptionLifecycleService>();
|
||||
builder.Services.AddHostedService<ExceptionLifecycleWorker>();
|
||||
builder.Services.AddHostedService<IncidentModeExpirationWorker>();
|
||||
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.SimulationAnalyticsService>();
|
||||
builder.Services.AddSingleton<ConsoleSimulationDiffService>();
|
||||
builder.Services.AddSingleton<StellaOps.PolicyDsl.PolicyCompiler>();
|
||||
builder.Services.AddSingleton<PolicyCompilationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationService>();
|
||||
builder.Services.AddPolicyEngineCore();
|
||||
builder.Services.AddSingleton<PathScopeSimulationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayProjectionService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.IOverlayEventSink, StellaOps.Policy.Engine.Overlay.LoggingOverlayEventSink>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayChangeEventPublisher>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulationBridgeService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.TrustWeighting.TrustWeightingService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AdvisoryAI.AdvisoryAiKnobsService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.BatchContext.BatchContextService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.EvidenceSummaryService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyBundleService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyRuntimeEvaluator>();
|
||||
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
builder.Services.AddSingleton<IOrchestratorJobStore, InMemoryOrchestratorJobStore>();
|
||||
builder.Services.AddSingleton<OrchestratorJobService>();
|
||||
builder.Services.AddSingleton<IWorkerResultStore, InMemoryWorkerResultStore>();
|
||||
builder.Services.AddSingleton<PolicyWorkerService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.ILedgerExportStore, StellaOps.Policy.Engine.Ledger.InMemoryLedgerExportStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.LedgerExportService>();
|
||||
|
||||
// Console export jobs per CONTRACT-EXPORT-BUNDLE-009
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportJobStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportJobStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportExecutionStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportExecutionStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportBundleStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportBundleStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.ConsoleExportJobService>();
|
||||
|
||||
// Air-gap bundle import per CONTRACT-MIRROR-BUNDLE-003
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IPolicyPackBundleStore, StellaOps.Policy.Engine.AirGap.InMemoryPolicyPackBundleStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.PolicyPackBundleImportService>();
|
||||
|
||||
// Sealed-mode services per CONTRACT-SEALED-MODE-004
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.ISealedModeStateStore, StellaOps.Policy.Engine.AirGap.InMemorySealedModeStateStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.ISealedModeService, StellaOps.Policy.Engine.AirGap.SealedModeService>();
|
||||
|
||||
// Staleness signaling services per CONTRACT-SEALED-MODE-004
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IStalenessEventSink, StellaOps.Policy.Engine.AirGap.LoggingStalenessEventSink>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IStalenessSignalingService, StellaOps.Policy.Engine.AirGap.StalenessSignalingService>();
|
||||
|
||||
// Air-gap notification services
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IAirGapNotificationChannel, StellaOps.Policy.Engine.AirGap.LoggingNotificationChannel>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IAirGapNotificationService, StellaOps.Policy.Engine.AirGap.AirGapNotificationService>();
|
||||
|
||||
// Air-gap risk profile export/import per CONTRACT-MIRROR-BUNDLE-003
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.RiskProfileAirGapExportService>();
|
||||
// Also register as IStalenessEventSink to auto-notify on staleness events
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IStalenessEventSink>(sp =>
|
||||
(StellaOps.Policy.Engine.AirGap.AirGapNotificationService)sp.GetRequiredService<StellaOps.Policy.Engine.AirGap.IAirGapNotificationService>());
|
||||
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.ISnapshotStore, StellaOps.Policy.Engine.Snapshots.InMemorySnapshotStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.SnapshotService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.IViolationEventStore, StellaOps.Policy.Engine.Violations.InMemoryViolationEventStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ViolationEventService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.SeverityFusionService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ConflictHandlingService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyDecisionService>();
|
||||
builder.Services.AddSingleton<IReachabilityFactsStore, InMemoryReachabilityFactsStore>();
|
||||
builder.Services.AddSingleton<IReachabilityFactsOverlayCache, InMemoryReachabilityFactsOverlayCache>();
|
||||
builder.Services.AddSingleton<ReachabilityFactsJoiningService>();
|
||||
builder.Services.AddSingleton<IRuntimeEvaluationExecutor, RuntimeEvaluationExecutor>();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// Rate limiting configuration for simulation endpoints
|
||||
var rateLimitOptions = builder.Configuration
|
||||
.GetSection(PolicyEngineRateLimitOptions.SectionName)
|
||||
.Get<PolicyEngineRateLimitOptions>() ?? new PolicyEngineRateLimitOptions();
|
||||
|
||||
if (rateLimitOptions.Enabled)
|
||||
{
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
options.AddTokenBucketLimiter(PolicyEngineRateLimitOptions.PolicyName, limiterOptions =>
|
||||
{
|
||||
limiterOptions.TokenLimit = rateLimitOptions.SimulationPermitLimit;
|
||||
limiterOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds);
|
||||
limiterOptions.TokensPerPeriod = rateLimitOptions.SimulationPermitLimit;
|
||||
limiterOptions.QueueLimit = rateLimitOptions.QueueLimit;
|
||||
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
});
|
||||
|
||||
options.OnRejected = async (context, cancellationToken) =>
|
||||
{
|
||||
var tenant = context.HttpContext.User.FindFirst("tenant_id")?.Value;
|
||||
var endpoint = context.HttpContext.Request.Path.Value;
|
||||
PolicyEngineTelemetry.RecordRateLimitExceeded(tenant, endpoint);
|
||||
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
context.HttpContext.Response.Headers.RetryAfter = rateLimitOptions.WindowSeconds.ToString();
|
||||
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "ERR_POL_007",
|
||||
message = "Rate limit exceeded. Please retry after the reset window.",
|
||||
retryAfterSeconds = rateLimitOptions.WindowSeconds
|
||||
}, cancellationToken);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyEngineOptions.SectionName}:ResourceServer");
|
||||
|
||||
if (bootstrap.Options.Authority.Enabled)
|
||||
{
|
||||
builder.Services.AddStellaOpsAuthClient(clientOptions =>
|
||||
{
|
||||
clientOptions.Authority = bootstrap.Options.Authority.Issuer;
|
||||
clientOptions.ClientId = bootstrap.Options.Authority.ClientId;
|
||||
clientOptions.ClientSecret = bootstrap.Options.Authority.ClientSecret;
|
||||
clientOptions.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.Authority.BackchannelTimeoutSeconds);
|
||||
|
||||
clientOptions.DefaultScopes.Clear();
|
||||
foreach (var scope in bootstrap.Options.Authority.Scopes)
|
||||
{
|
||||
clientOptions.DefaultScopes.Add(scope);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
if (rateLimitOptions.Enabled)
|
||||
{
|
||||
app.UseRateLimiter();
|
||||
}
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
|
||||
diagnostics.IsReady
|
||||
? Results.Ok(new { status = "ready" })
|
||||
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable))
|
||||
.WithName("Readiness");
|
||||
|
||||
app.MapGet("/", () => Results.Redirect("/healthz"));
|
||||
|
||||
app.MapPolicyCompilation();
|
||||
app.MapPolicyPacks();
|
||||
app.MapPathScopeSimulation();
|
||||
app.MapOverlaySimulation();
|
||||
app.MapEvidenceSummaries();
|
||||
app.MapBatchEvaluation();
|
||||
app.MapConsoleSimulationDiff();
|
||||
app.MapTrustWeighting();
|
||||
app.MapAdvisoryAiKnobs();
|
||||
app.MapBatchContext();
|
||||
app.MapOrchestratorJobs();
|
||||
app.MapPolicyWorker();
|
||||
app.MapLedgerExport();
|
||||
app.MapConsoleExportJobs(); // CONTRACT-EXPORT-BUNDLE-009
|
||||
app.MapPolicyPackBundles(); // CONTRACT-MIRROR-BUNDLE-003
|
||||
app.MapSealedMode(); // CONTRACT-SEALED-MODE-004
|
||||
app.MapStalenessSignaling(); // CONTRACT-SEALED-MODE-004 staleness
|
||||
app.MapAirGapNotifications(); // Air-gap notifications
|
||||
app.MapPolicyLint(); // POLICY-AOC-19-001 determinism linting
|
||||
app.MapVerificationPolicies(); // CONTRACT-VERIFICATION-POLICY-006 attestation policies
|
||||
app.MapVerificationPolicyEditor(); // CONTRACT-VERIFICATION-POLICY-006 editor DTOs/validation
|
||||
app.MapAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 attestation reports
|
||||
app.MapConsoleAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 Console integration
|
||||
app.MapSnapshots();
|
||||
app.MapViolations();
|
||||
app.MapPolicyDecisions();
|
||||
app.MapRiskProfiles();
|
||||
app.MapRiskProfileSchema();
|
||||
app.MapScopeAttachments();
|
||||
app.MapEffectivePolicies(); // CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
app.MapRiskSimulation();
|
||||
app.MapOverrides();
|
||||
app.MapProfileExport();
|
||||
app.MapRiskProfileAirGap(); // CONTRACT-MIRROR-BUNDLE-003 risk profile air-gap
|
||||
app.MapProfileEvents();
|
||||
app.MapCvssReceipts(); // CVSS v4 receipt CRUD & history
|
||||
|
||||
// Phase 5: Multi-tenant PostgreSQL-backed API endpoints
|
||||
app.MapPolicySnapshotsApi();
|
||||
app.MapViolationEventsApi();
|
||||
app.MapConflictsApi();
|
||||
|
||||
app.Run();
|
||||
using System.IO;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NetEscapades.Configuration.Yaml;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Policy.Engine.Hosting;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Endpoints;
|
||||
using StellaOps.Policy.Engine.BatchEvaluation;
|
||||
using StellaOps.Policy.Engine.DependencyInjection;
|
||||
using StellaOps.PolicyDsl;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.Policy.Engine.ConsoleSurface;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
using StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
using StellaOps.Policy.Engine.Storage.InMemory;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var policyEngineConfigFiles = new[]
|
||||
{
|
||||
"../etc/policy-engine.yaml",
|
||||
"../etc/policy-engine.local.yaml",
|
||||
"policy-engine.yaml",
|
||||
"policy-engine.local.yaml"
|
||||
};
|
||||
|
||||
var policyEngineActivationConfigFiles = new[]
|
||||
{
|
||||
"../etc/policy-engine.activation.yaml",
|
||||
"../etc/policy-engine.activation.local.yaml",
|
||||
"/config/policy-engine/activation.yaml",
|
||||
"policy-engine.activation.yaml",
|
||||
"policy-engine.activation.local.yaml"
|
||||
};
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole();
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
var contentRoot = builder.Environment.ContentRootPath;
|
||||
foreach (var relative in policyEngineConfigFiles)
|
||||
{
|
||||
var path = Path.Combine(contentRoot, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
|
||||
foreach (var relative in policyEngineActivationConfigFiles)
|
||||
{
|
||||
var path = Path.Combine(contentRoot, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
|
||||
options.BindingSection = PolicyEngineOptions.SectionName;
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
foreach (var relative in policyEngineConfigFiles)
|
||||
{
|
||||
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
|
||||
foreach (var relative in policyEngineActivationConfigFiles)
|
||||
{
|
||||
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
};
|
||||
options.PostBind = static (value, _) => value.Validate();
|
||||
});
|
||||
|
||||
builder.Configuration.AddConfiguration(bootstrap.Configuration);
|
||||
|
||||
builder.ConfigurePolicyEngineTelemetry(bootstrap.Options);
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
||||
|
||||
// CVSS receipts rely on PostgreSQL storage for deterministic persistence.
|
||||
builder.Services.AddPolicyPostgresStorage(builder.Configuration, sectionName: "Postgres:Policy");
|
||||
|
||||
builder.Services.AddSingleton<ICvssV4Engine, CvssV4Engine>();
|
||||
builder.Services.AddScoped<IReceiptBuilder, ReceiptBuilder>();
|
||||
builder.Services.AddScoped<IReceiptHistoryService, ReceiptHistoryService>();
|
||||
|
||||
builder.Services.AddOptions<PolicyEngineOptions>()
|
||||
.Bind(builder.Configuration.GetSection(PolicyEngineOptions.SectionName))
|
||||
.Validate(options =>
|
||||
{
|
||||
try
|
||||
{
|
||||
options.Validate();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new OptionsValidationException(
|
||||
PolicyEngineOptions.SectionName,
|
||||
typeof(PolicyEngineOptions),
|
||||
new[] { ex.Message });
|
||||
}
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<PolicyEngineOptions>().ExceptionLifecycle);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
|
||||
builder.Services.AddSingleton<PolicyTimelineEvents>();
|
||||
builder.Services.AddSingleton<EvidenceBundleService>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationAttestationService>();
|
||||
builder.Services.AddSingleton<IncidentModeService>();
|
||||
builder.Services.AddSingleton<RiskProfileConfigurationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Lifecycle.RiskProfileLifecycleService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Scope.ScopeAttachmentService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Scope.EffectivePolicyService>();
|
||||
builder.Services.AddSingleton<IEffectivePolicyAuditor, EffectivePolicyAuditor>(); // CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.IVerificationPolicyStore, StellaOps.Policy.Engine.Attestation.InMemoryVerificationPolicyStore>(); // CONTRACT-VERIFICATION-POLICY-006
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.VerificationPolicyValidator>(); // CONTRACT-VERIFICATION-POLICY-006 validation
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.IAttestationReportStore, StellaOps.Policy.Engine.Attestation.InMemoryAttestationReportStore>(); // CONTRACT-VERIFICATION-POLICY-006 reports
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.IAttestationReportService, StellaOps.Policy.Engine.Attestation.AttestationReportService>(); // CONTRACT-VERIFICATION-POLICY-006 reports
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleSurface.ConsoleAttestationReportService>(); // CONTRACT-VERIFICATION-POLICY-006 Console integration
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Overrides.OverrideService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.IRiskScoringJobStore, StellaOps.Policy.Engine.Scoring.InMemoryRiskScoringJobStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.RiskScoringTriggerService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.RiskSimulationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Signals.Entropy.EntropyPenaltyCalculator>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Export.ProfileExportService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.ProfileEventPublisher>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.IExceptionEventPublisher>(sp =>
|
||||
new StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher(
|
||||
sp.GetService<StellaOps.Policy.Engine.ExceptionCache.IExceptionEffectiveCache>(),
|
||||
sp.GetRequiredService<ILogger<StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher>>()));
|
||||
builder.Services.AddSingleton<ExceptionLifecycleService>();
|
||||
builder.Services.AddHostedService<ExceptionLifecycleWorker>();
|
||||
builder.Services.AddHostedService<IncidentModeExpirationWorker>();
|
||||
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.SimulationAnalyticsService>();
|
||||
builder.Services.AddSingleton<ConsoleSimulationDiffService>();
|
||||
builder.Services.AddSingleton<StellaOps.PolicyDsl.PolicyCompiler>();
|
||||
builder.Services.AddSingleton<PolicyCompilationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationService>();
|
||||
builder.Services.AddPolicyEngineCore();
|
||||
builder.Services.AddSingleton<PathScopeSimulationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayProjectionService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.IOverlayEventSink, StellaOps.Policy.Engine.Overlay.LoggingOverlayEventSink>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayChangeEventPublisher>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulationBridgeService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.TrustWeighting.TrustWeightingService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AdvisoryAI.AdvisoryAiKnobsService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.BatchContext.BatchContextService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.EvidenceSummaryService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyBundleService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyRuntimeEvaluator>();
|
||||
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
builder.Services.AddSingleton<IOrchestratorJobStore, InMemoryOrchestratorJobStore>();
|
||||
builder.Services.AddSingleton<OrchestratorJobService>();
|
||||
builder.Services.AddSingleton<IWorkerResultStore, InMemoryWorkerResultStore>();
|
||||
builder.Services.AddSingleton<PolicyWorkerService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.ILedgerExportStore, StellaOps.Policy.Engine.Ledger.InMemoryLedgerExportStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.LedgerExportService>();
|
||||
|
||||
// Console export jobs per CONTRACT-EXPORT-BUNDLE-009
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportJobStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportJobStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportExecutionStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportExecutionStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportBundleStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportBundleStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.ConsoleExportJobService>();
|
||||
|
||||
// Air-gap bundle import per CONTRACT-MIRROR-BUNDLE-003
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IPolicyPackBundleStore, StellaOps.Policy.Engine.AirGap.InMemoryPolicyPackBundleStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.PolicyPackBundleImportService>();
|
||||
|
||||
// Sealed-mode services per CONTRACT-SEALED-MODE-004
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.ISealedModeStateStore, StellaOps.Policy.Engine.AirGap.InMemorySealedModeStateStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.ISealedModeService, StellaOps.Policy.Engine.AirGap.SealedModeService>();
|
||||
|
||||
// Staleness signaling services per CONTRACT-SEALED-MODE-004
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IStalenessEventSink, StellaOps.Policy.Engine.AirGap.LoggingStalenessEventSink>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IStalenessSignalingService, StellaOps.Policy.Engine.AirGap.StalenessSignalingService>();
|
||||
|
||||
// Air-gap notification services
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IAirGapNotificationChannel, StellaOps.Policy.Engine.AirGap.LoggingNotificationChannel>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IAirGapNotificationService, StellaOps.Policy.Engine.AirGap.AirGapNotificationService>();
|
||||
|
||||
// Air-gap risk profile export/import per CONTRACT-MIRROR-BUNDLE-003
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.RiskProfileAirGapExportService>();
|
||||
// Also register as IStalenessEventSink to auto-notify on staleness events
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IStalenessEventSink>(sp =>
|
||||
(StellaOps.Policy.Engine.AirGap.AirGapNotificationService)sp.GetRequiredService<StellaOps.Policy.Engine.AirGap.IAirGapNotificationService>());
|
||||
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.ISnapshotStore, StellaOps.Policy.Engine.Snapshots.InMemorySnapshotStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.SnapshotService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.IViolationEventStore, StellaOps.Policy.Engine.Violations.InMemoryViolationEventStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ViolationEventService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.SeverityFusionService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ConflictHandlingService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyDecisionService>();
|
||||
builder.Services.AddSingleton<IReachabilityFactsStore, InMemoryReachabilityFactsStore>();
|
||||
builder.Services.AddSingleton<IReachabilityFactsOverlayCache, InMemoryReachabilityFactsOverlayCache>();
|
||||
builder.Services.AddSingleton<ReachabilityFactsJoiningService>();
|
||||
builder.Services.AddSingleton<IRuntimeEvaluationExecutor, RuntimeEvaluationExecutor>();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// Rate limiting configuration for simulation endpoints
|
||||
var rateLimitOptions = builder.Configuration
|
||||
.GetSection(PolicyEngineRateLimitOptions.SectionName)
|
||||
.Get<PolicyEngineRateLimitOptions>() ?? new PolicyEngineRateLimitOptions();
|
||||
|
||||
if (rateLimitOptions.Enabled)
|
||||
{
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
options.AddTokenBucketLimiter(PolicyEngineRateLimitOptions.PolicyName, limiterOptions =>
|
||||
{
|
||||
limiterOptions.TokenLimit = rateLimitOptions.SimulationPermitLimit;
|
||||
limiterOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds);
|
||||
limiterOptions.TokensPerPeriod = rateLimitOptions.SimulationPermitLimit;
|
||||
limiterOptions.QueueLimit = rateLimitOptions.QueueLimit;
|
||||
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
});
|
||||
|
||||
options.OnRejected = async (context, cancellationToken) =>
|
||||
{
|
||||
var tenant = context.HttpContext.User.FindFirst("tenant_id")?.Value;
|
||||
var endpoint = context.HttpContext.Request.Path.Value;
|
||||
PolicyEngineTelemetry.RecordRateLimitExceeded(tenant, endpoint);
|
||||
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
context.HttpContext.Response.Headers.RetryAfter = rateLimitOptions.WindowSeconds.ToString();
|
||||
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "ERR_POL_007",
|
||||
message = "Rate limit exceeded. Please retry after the reset window.",
|
||||
retryAfterSeconds = rateLimitOptions.WindowSeconds
|
||||
}, cancellationToken);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyEngineOptions.SectionName}:ResourceServer");
|
||||
|
||||
if (bootstrap.Options.Authority.Enabled)
|
||||
{
|
||||
builder.Services.AddStellaOpsAuthClient(clientOptions =>
|
||||
{
|
||||
clientOptions.Authority = bootstrap.Options.Authority.Issuer;
|
||||
clientOptions.ClientId = bootstrap.Options.Authority.ClientId;
|
||||
clientOptions.ClientSecret = bootstrap.Options.Authority.ClientSecret;
|
||||
clientOptions.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.Authority.BackchannelTimeoutSeconds);
|
||||
|
||||
clientOptions.DefaultScopes.Clear();
|
||||
foreach (var scope in bootstrap.Options.Authority.Scopes)
|
||||
{
|
||||
clientOptions.DefaultScopes.Add(scope);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
if (rateLimitOptions.Enabled)
|
||||
{
|
||||
app.UseRateLimiter();
|
||||
}
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
|
||||
diagnostics.IsReady
|
||||
? Results.Ok(new { status = "ready" })
|
||||
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable))
|
||||
.WithName("Readiness");
|
||||
|
||||
app.MapGet("/", () => Results.Redirect("/healthz"));
|
||||
|
||||
app.MapPolicyCompilation();
|
||||
app.MapPolicyPacks();
|
||||
app.MapPathScopeSimulation();
|
||||
app.MapOverlaySimulation();
|
||||
app.MapEvidenceSummaries();
|
||||
app.MapBatchEvaluation();
|
||||
app.MapConsoleSimulationDiff();
|
||||
app.MapTrustWeighting();
|
||||
app.MapAdvisoryAiKnobs();
|
||||
app.MapBatchContext();
|
||||
app.MapOrchestratorJobs();
|
||||
app.MapPolicyWorker();
|
||||
app.MapLedgerExport();
|
||||
app.MapConsoleExportJobs(); // CONTRACT-EXPORT-BUNDLE-009
|
||||
app.MapPolicyPackBundles(); // CONTRACT-MIRROR-BUNDLE-003
|
||||
app.MapSealedMode(); // CONTRACT-SEALED-MODE-004
|
||||
app.MapStalenessSignaling(); // CONTRACT-SEALED-MODE-004 staleness
|
||||
app.MapAirGapNotifications(); // Air-gap notifications
|
||||
app.MapPolicyLint(); // POLICY-AOC-19-001 determinism linting
|
||||
app.MapVerificationPolicies(); // CONTRACT-VERIFICATION-POLICY-006 attestation policies
|
||||
app.MapVerificationPolicyEditor(); // CONTRACT-VERIFICATION-POLICY-006 editor DTOs/validation
|
||||
app.MapAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 attestation reports
|
||||
app.MapConsoleAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 Console integration
|
||||
app.MapSnapshots();
|
||||
app.MapViolations();
|
||||
app.MapPolicyDecisions();
|
||||
app.MapRiskProfiles();
|
||||
app.MapRiskProfileSchema();
|
||||
app.MapScopeAttachments();
|
||||
app.MapEffectivePolicies(); // CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
app.MapRiskSimulation();
|
||||
app.MapOverrides();
|
||||
app.MapProfileExport();
|
||||
app.MapRiskProfileAirGap(); // CONTRACT-MIRROR-BUNDLE-003 risk profile air-gap
|
||||
app.MapProfileEvents();
|
||||
app.MapCvssReceipts(); // CVSS v4 receipt CRUD & history
|
||||
|
||||
// Phase 5: Multi-tenant PostgreSQL-backed API endpoints
|
||||
app.MapPolicySnapshotsApi();
|
||||
app.MapViolationEventsApi();
|
||||
app.MapConflictsApi();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Policy.Engine.Tests")]
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Policy.Engine.Tests")]
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Store for risk scoring jobs.
|
||||
/// </summary>
|
||||
public interface IRiskScoringJobStore
|
||||
{
|
||||
Task SaveAsync(RiskScoringJob job, CancellationToken cancellationToken = default);
|
||||
Task<RiskScoringJob?> GetAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RiskScoringJob>> ListByStatusAsync(RiskScoringJobStatus status, int limit = 100, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RiskScoringJob>> ListByTenantAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default);
|
||||
Task UpdateStatusAsync(string jobId, RiskScoringJobStatus status, string? errorMessage = null, CancellationToken cancellationToken = default);
|
||||
Task<RiskScoringJob?> DequeueNextAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of risk scoring job store.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRiskScoringJobStore : IRiskScoringJobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, RiskScoringJob> _jobs = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryRiskScoringJobStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task SaveAsync(RiskScoringJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs[job.JobId] = job;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<RiskScoringJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs.TryGetValue(jobId, out var job);
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RiskScoringJob>> ListByStatusAsync(RiskScoringJobStatus status, int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jobs = _jobs.Values
|
||||
.Where(j => j.Status == status)
|
||||
.OrderBy(j => j.RequestedAt)
|
||||
.Take(limit)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<RiskScoringJob>>(jobs);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RiskScoringJob>> ListByTenantAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jobs = _jobs.Values
|
||||
.Where(j => j.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(j => j.RequestedAt)
|
||||
.Take(limit)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<RiskScoringJob>>(jobs);
|
||||
}
|
||||
|
||||
public Task UpdateStatusAsync(string jobId, RiskScoringJobStatus status, string? errorMessage = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_jobs.TryGetValue(jobId, out var job))
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = job with
|
||||
{
|
||||
Status = status,
|
||||
StartedAt = status == RiskScoringJobStatus.Running ? now : job.StartedAt,
|
||||
CompletedAt = status is RiskScoringJobStatus.Completed or RiskScoringJobStatus.Failed or RiskScoringJobStatus.Cancelled ? now : job.CompletedAt,
|
||||
ErrorMessage = errorMessage ?? job.ErrorMessage
|
||||
};
|
||||
_jobs[jobId] = updated;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<RiskScoringJob?> DequeueNextAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var next = _jobs.Values
|
||||
.Where(j => j.Status == RiskScoringJobStatus.Queued)
|
||||
.OrderByDescending(j => j.Priority)
|
||||
.ThenBy(j => j.RequestedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (next != null)
|
||||
{
|
||||
var running = next with
|
||||
{
|
||||
Status = RiskScoringJobStatus.Running,
|
||||
StartedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
_jobs[next.JobId] = running;
|
||||
return Task.FromResult<RiskScoringJob?>(running);
|
||||
}
|
||||
|
||||
return Task.FromResult<RiskScoringJob?>(null);
|
||||
}
|
||||
}
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Store for risk scoring jobs.
|
||||
/// </summary>
|
||||
public interface IRiskScoringJobStore
|
||||
{
|
||||
Task SaveAsync(RiskScoringJob job, CancellationToken cancellationToken = default);
|
||||
Task<RiskScoringJob?> GetAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RiskScoringJob>> ListByStatusAsync(RiskScoringJobStatus status, int limit = 100, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RiskScoringJob>> ListByTenantAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default);
|
||||
Task UpdateStatusAsync(string jobId, RiskScoringJobStatus status, string? errorMessage = null, CancellationToken cancellationToken = default);
|
||||
Task<RiskScoringJob?> DequeueNextAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of risk scoring job store.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRiskScoringJobStore : IRiskScoringJobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, RiskScoringJob> _jobs = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryRiskScoringJobStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task SaveAsync(RiskScoringJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs[job.JobId] = job;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<RiskScoringJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs.TryGetValue(jobId, out var job);
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RiskScoringJob>> ListByStatusAsync(RiskScoringJobStatus status, int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jobs = _jobs.Values
|
||||
.Where(j => j.Status == status)
|
||||
.OrderBy(j => j.RequestedAt)
|
||||
.Take(limit)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<RiskScoringJob>>(jobs);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RiskScoringJob>> ListByTenantAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jobs = _jobs.Values
|
||||
.Where(j => j.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(j => j.RequestedAt)
|
||||
.Take(limit)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<RiskScoringJob>>(jobs);
|
||||
}
|
||||
|
||||
public Task UpdateStatusAsync(string jobId, RiskScoringJobStatus status, string? errorMessage = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_jobs.TryGetValue(jobId, out var job))
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = job with
|
||||
{
|
||||
Status = status,
|
||||
StartedAt = status == RiskScoringJobStatus.Running ? now : job.StartedAt,
|
||||
CompletedAt = status is RiskScoringJobStatus.Completed or RiskScoringJobStatus.Failed or RiskScoringJobStatus.Cancelled ? now : job.CompletedAt,
|
||||
ErrorMessage = errorMessage ?? job.ErrorMessage
|
||||
};
|
||||
_jobs[jobId] = updated;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<RiskScoringJob?> DequeueNextAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var next = _jobs.Values
|
||||
.Where(j => j.Status == RiskScoringJobStatus.Queued)
|
||||
.OrderByDescending(j => j.Priority)
|
||||
.ThenBy(j => j.RequestedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (next != null)
|
||||
{
|
||||
var running = next with
|
||||
{
|
||||
Status = RiskScoringJobStatus.Running,
|
||||
StartedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
_jobs[next.JobId] = running;
|
||||
return Task.FromResult<RiskScoringJob?>(running);
|
||||
}
|
||||
|
||||
return Task.FromResult<RiskScoringJob?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +1,145 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Event indicating a finding has been created or updated.
|
||||
/// </summary>
|
||||
public sealed record FindingChangedEvent(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("change_type")] FindingChangeType ChangeType,
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Type of finding change.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<FindingChangeType>))]
|
||||
public enum FindingChangeType
|
||||
{
|
||||
[JsonPropertyName("created")]
|
||||
Created,
|
||||
|
||||
[JsonPropertyName("updated")]
|
||||
Updated,
|
||||
|
||||
[JsonPropertyName("enriched")]
|
||||
Enriched,
|
||||
|
||||
[JsonPropertyName("vex_applied")]
|
||||
VexApplied
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a risk scoring job.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringJobRequest(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("findings")] IReadOnlyList<RiskScoringFinding> Findings,
|
||||
[property: JsonPropertyName("priority")] RiskScoringPriority Priority = RiskScoringPriority.Normal,
|
||||
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null,
|
||||
[property: JsonPropertyName("requested_at")] DateTimeOffset? RequestedAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// A finding to score.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringFinding(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("trigger")] FindingChangeType Trigger);
|
||||
|
||||
/// <summary>
|
||||
/// Priority for risk scoring jobs.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskScoringPriority>))]
|
||||
public enum RiskScoringPriority
|
||||
{
|
||||
[JsonPropertyName("low")]
|
||||
Low,
|
||||
|
||||
[JsonPropertyName("normal")]
|
||||
Normal,
|
||||
|
||||
[JsonPropertyName("high")]
|
||||
High,
|
||||
|
||||
[JsonPropertyName("emergency")]
|
||||
Emergency
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A queued or completed risk scoring job.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringJob(
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("profile_hash")] string ProfileHash,
|
||||
[property: JsonPropertyName("findings")] IReadOnlyList<RiskScoringFinding> Findings,
|
||||
[property: JsonPropertyName("priority")] RiskScoringPriority Priority,
|
||||
[property: JsonPropertyName("status")] RiskScoringJobStatus Status,
|
||||
[property: JsonPropertyName("requested_at")] DateTimeOffset RequestedAt,
|
||||
[property: JsonPropertyName("started_at")] DateTimeOffset? StartedAt = null,
|
||||
[property: JsonPropertyName("completed_at")] DateTimeOffset? CompletedAt = null,
|
||||
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null,
|
||||
[property: JsonPropertyName("error_message")] string? ErrorMessage = null);
|
||||
|
||||
/// <summary>
|
||||
/// Status of a risk scoring job.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskScoringJobStatus>))]
|
||||
public enum RiskScoringJobStatus
|
||||
{
|
||||
[JsonPropertyName("queued")]
|
||||
Queued,
|
||||
|
||||
[JsonPropertyName("running")]
|
||||
Running,
|
||||
|
||||
[JsonPropertyName("completed")]
|
||||
Completed,
|
||||
|
||||
[JsonPropertyName("failed")]
|
||||
Failed,
|
||||
|
||||
[JsonPropertyName("cancelled")]
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of scoring a single finding.
|
||||
/// </summary>
|
||||
/// <param name="FindingId">Unique identifier for the finding.</param>
|
||||
/// <param name="ProfileId">Risk profile used for scoring.</param>
|
||||
/// <param name="ProfileVersion">Version of the risk profile.</param>
|
||||
/// <param name="RawScore">Raw computed score before normalization.</param>
|
||||
/// <param name="NormalizedScore">
|
||||
/// DEPRECATED: Legacy normalized score (0-1 range). Use <see cref="Severity"/> instead.
|
||||
/// Scheduled for removal in v2.0. See DESIGN-POLICY-NORMALIZED-FIELD-REMOVAL-001.
|
||||
/// </param>
|
||||
/// <param name="Severity">Canonical severity (critical/high/medium/low/info).</param>
|
||||
/// <param name="SignalValues">Input signal values used in scoring.</param>
|
||||
/// <param name="SignalContributions">Contribution of each signal to final score.</param>
|
||||
/// <param name="OverrideApplied">Override rule that was applied, if any.</param>
|
||||
/// <param name="OverrideReason">Reason for the override, if any.</param>
|
||||
/// <param name="ScoredAt">Timestamp when scoring was performed.</param>
|
||||
public sealed record RiskScoringResult(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("profile_version")] string ProfileVersion,
|
||||
[property: JsonPropertyName("raw_score")] double RawScore,
|
||||
[property: JsonPropertyName("normalized_score")] double NormalizedScore,
|
||||
[property: JsonPropertyName("severity")] string Severity,
|
||||
[property: JsonPropertyName("signal_values")] IReadOnlyDictionary<string, object?> SignalValues,
|
||||
[property: JsonPropertyName("signal_contributions")] IReadOnlyDictionary<string, double> SignalContributions,
|
||||
[property: JsonPropertyName("override_applied")] string? OverrideApplied,
|
||||
[property: JsonPropertyName("override_reason")] string? OverrideReason,
|
||||
[property: JsonPropertyName("scored_at")] DateTimeOffset ScoredAt);
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Event indicating a finding has been created or updated.
|
||||
/// </summary>
|
||||
public sealed record FindingChangedEvent(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("change_type")] FindingChangeType ChangeType,
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Type of finding change.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<FindingChangeType>))]
|
||||
public enum FindingChangeType
|
||||
{
|
||||
[JsonPropertyName("created")]
|
||||
Created,
|
||||
|
||||
[JsonPropertyName("updated")]
|
||||
Updated,
|
||||
|
||||
[JsonPropertyName("enriched")]
|
||||
Enriched,
|
||||
|
||||
[JsonPropertyName("vex_applied")]
|
||||
VexApplied
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a risk scoring job.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringJobRequest(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("findings")] IReadOnlyList<RiskScoringFinding> Findings,
|
||||
[property: JsonPropertyName("priority")] RiskScoringPriority Priority = RiskScoringPriority.Normal,
|
||||
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null,
|
||||
[property: JsonPropertyName("requested_at")] DateTimeOffset? RequestedAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// A finding to score.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringFinding(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("trigger")] FindingChangeType Trigger);
|
||||
|
||||
/// <summary>
|
||||
/// Priority for risk scoring jobs.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskScoringPriority>))]
|
||||
public enum RiskScoringPriority
|
||||
{
|
||||
[JsonPropertyName("low")]
|
||||
Low,
|
||||
|
||||
[JsonPropertyName("normal")]
|
||||
Normal,
|
||||
|
||||
[JsonPropertyName("high")]
|
||||
High,
|
||||
|
||||
[JsonPropertyName("emergency")]
|
||||
Emergency
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A queued or completed risk scoring job.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringJob(
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("profile_hash")] string ProfileHash,
|
||||
[property: JsonPropertyName("findings")] IReadOnlyList<RiskScoringFinding> Findings,
|
||||
[property: JsonPropertyName("priority")] RiskScoringPriority Priority,
|
||||
[property: JsonPropertyName("status")] RiskScoringJobStatus Status,
|
||||
[property: JsonPropertyName("requested_at")] DateTimeOffset RequestedAt,
|
||||
[property: JsonPropertyName("started_at")] DateTimeOffset? StartedAt = null,
|
||||
[property: JsonPropertyName("completed_at")] DateTimeOffset? CompletedAt = null,
|
||||
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null,
|
||||
[property: JsonPropertyName("error_message")] string? ErrorMessage = null);
|
||||
|
||||
/// <summary>
|
||||
/// Status of a risk scoring job.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskScoringJobStatus>))]
|
||||
public enum RiskScoringJobStatus
|
||||
{
|
||||
[JsonPropertyName("queued")]
|
||||
Queued,
|
||||
|
||||
[JsonPropertyName("running")]
|
||||
Running,
|
||||
|
||||
[JsonPropertyName("completed")]
|
||||
Completed,
|
||||
|
||||
[JsonPropertyName("failed")]
|
||||
Failed,
|
||||
|
||||
[JsonPropertyName("cancelled")]
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of scoring a single finding.
|
||||
/// </summary>
|
||||
/// <param name="FindingId">Unique identifier for the finding.</param>
|
||||
/// <param name="ProfileId">Risk profile used for scoring.</param>
|
||||
/// <param name="ProfileVersion">Version of the risk profile.</param>
|
||||
/// <param name="RawScore">Raw computed score before normalization.</param>
|
||||
/// <param name="NormalizedScore">
|
||||
/// DEPRECATED: Legacy normalized score (0-1 range). Use <see cref="Severity"/> instead.
|
||||
/// Scheduled for removal in v2.0. See DESIGN-POLICY-NORMALIZED-FIELD-REMOVAL-001.
|
||||
/// </param>
|
||||
/// <param name="Severity">Canonical severity (critical/high/medium/low/info).</param>
|
||||
/// <param name="SignalValues">Input signal values used in scoring.</param>
|
||||
/// <param name="SignalContributions">Contribution of each signal to final score.</param>
|
||||
/// <param name="OverrideApplied">Override rule that was applied, if any.</param>
|
||||
/// <param name="OverrideReason">Reason for the override, if any.</param>
|
||||
/// <param name="ScoredAt">Timestamp when scoring was performed.</param>
|
||||
public sealed record RiskScoringResult(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("profile_version")] string ProfileVersion,
|
||||
[property: JsonPropertyName("raw_score")] double RawScore,
|
||||
[property: JsonPropertyName("normalized_score")] double NormalizedScore,
|
||||
[property: JsonPropertyName("severity")] string Severity,
|
||||
[property: JsonPropertyName("signal_values")] IReadOnlyDictionary<string, object?> SignalValues,
|
||||
[property: JsonPropertyName("signal_contributions")] IReadOnlyDictionary<string, double> SignalContributions,
|
||||
[property: JsonPropertyName("override_applied")] string? OverrideApplied,
|
||||
[property: JsonPropertyName("override_reason")] string? OverrideReason,
|
||||
[property: JsonPropertyName("scored_at")] DateTimeOffset ScoredAt);
|
||||
|
||||
@@ -1,268 +1,268 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Service for triggering risk scoring jobs when findings change.
|
||||
/// </summary>
|
||||
public sealed class RiskScoringTriggerService
|
||||
{
|
||||
private readonly ILogger<RiskScoringTriggerService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RiskProfileConfigurationService _profileService;
|
||||
private readonly IRiskScoringJobStore _jobStore;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _recentTriggers;
|
||||
private readonly TimeSpan _deduplicationWindow;
|
||||
|
||||
public RiskScoringTriggerService(
|
||||
ILogger<RiskScoringTriggerService> logger,
|
||||
TimeProvider timeProvider,
|
||||
RiskProfileConfigurationService profileService,
|
||||
IRiskScoringJobStore jobStore,
|
||||
ICryptoHash cryptoHash)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_profileService = profileService ?? throw new ArgumentNullException(nameof(profileService));
|
||||
_jobStore = jobStore ?? throw new ArgumentNullException(nameof(jobStore));
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_hasher = new RiskProfileHasher(cryptoHash);
|
||||
_recentTriggers = new ConcurrentDictionary<string, DateTimeOffset>();
|
||||
_deduplicationWindow = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a finding changed event and creates a scoring job if appropriate.
|
||||
/// </summary>
|
||||
/// <param name="evt">The finding changed event.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created job, or null if skipped.</returns>
|
||||
public async Task<RiskScoringJob?> HandleFindingChangedAsync(
|
||||
FindingChangedEvent evt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
|
||||
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("risk_scoring.trigger");
|
||||
activity?.SetTag("finding.id", evt.FindingId);
|
||||
activity?.SetTag("change_type", evt.ChangeType.ToString());
|
||||
|
||||
if (!_profileService.IsEnabled)
|
||||
{
|
||||
_logger.LogDebug("Risk profile integration disabled; skipping scoring for {FindingId}", evt.FindingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var triggerKey = BuildTriggerKey(evt);
|
||||
if (IsRecentlyTriggered(triggerKey))
|
||||
{
|
||||
_logger.LogDebug("Skipping duplicate trigger for {FindingId} within deduplication window", evt.FindingId);
|
||||
PolicyEngineTelemetry.RiskScoringTriggersSkipped.Add(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
var request = new RiskScoringJobRequest(
|
||||
TenantId: evt.TenantId,
|
||||
ContextId: evt.ContextId,
|
||||
ProfileId: _profileService.DefaultProfileId,
|
||||
Findings: new[]
|
||||
{
|
||||
new RiskScoringFinding(
|
||||
evt.FindingId,
|
||||
evt.ComponentPurl,
|
||||
evt.AdvisoryId,
|
||||
evt.ChangeType)
|
||||
},
|
||||
Priority: DeterminePriority(evt.ChangeType),
|
||||
CorrelationId: evt.CorrelationId,
|
||||
RequestedAt: evt.Timestamp);
|
||||
|
||||
var job = await CreateJobAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
RecordTrigger(triggerKey);
|
||||
PolicyEngineTelemetry.RiskScoringJobsCreated.Add(1);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created risk scoring job {JobId} for finding {FindingId} (trigger: {ChangeType})",
|
||||
job.JobId, evt.FindingId, evt.ChangeType);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles multiple finding changed events in batch.
|
||||
/// </summary>
|
||||
/// <param name="events">The finding changed events.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created job, or null if all events were skipped.</returns>
|
||||
public async Task<RiskScoringJob?> HandleFindingsBatchAsync(
|
||||
IReadOnlyList<FindingChangedEvent> events,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_profileService.IsEnabled)
|
||||
{
|
||||
_logger.LogDebug("Risk profile integration disabled; skipping batch scoring");
|
||||
return null;
|
||||
}
|
||||
|
||||
var uniqueEvents = events
|
||||
.Where(e => !IsRecentlyTriggered(BuildTriggerKey(e)))
|
||||
.GroupBy(e => e.FindingId)
|
||||
.Select(g => g.OrderByDescending(e => e.Timestamp).First())
|
||||
.ToList();
|
||||
|
||||
if (uniqueEvents.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("All events in batch were duplicates; skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
var firstEvent = uniqueEvents[0];
|
||||
var highestPriority = uniqueEvents.Select(e => DeterminePriority(e.ChangeType)).Max();
|
||||
|
||||
var request = new RiskScoringJobRequest(
|
||||
TenantId: firstEvent.TenantId,
|
||||
ContextId: firstEvent.ContextId,
|
||||
ProfileId: _profileService.DefaultProfileId,
|
||||
Findings: uniqueEvents.Select(e => new RiskScoringFinding(
|
||||
e.FindingId,
|
||||
e.ComponentPurl,
|
||||
e.AdvisoryId,
|
||||
e.ChangeType)).ToList(),
|
||||
Priority: highestPriority,
|
||||
CorrelationId: firstEvent.CorrelationId,
|
||||
RequestedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
var job = await CreateJobAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var evt in uniqueEvents)
|
||||
{
|
||||
RecordTrigger(BuildTriggerKey(evt));
|
||||
}
|
||||
|
||||
PolicyEngineTelemetry.RiskScoringJobsCreated.Add(1);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created batch risk scoring job {JobId} for {FindingCount} findings",
|
||||
job.JobId, uniqueEvents.Count);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a risk scoring job from a request.
|
||||
/// </summary>
|
||||
public async Task<RiskScoringJob> CreateJobAsync(
|
||||
RiskScoringJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var profile = _profileService.GetProfile(request.ProfileId);
|
||||
if (profile == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Risk profile '{request.ProfileId}' not found.");
|
||||
}
|
||||
|
||||
var profileHash = _hasher.ComputeHash(profile);
|
||||
var requestedAt = request.RequestedAt ?? _timeProvider.GetUtcNow();
|
||||
var jobId = GenerateJobId(request.TenantId, request.ContextId, requestedAt);
|
||||
|
||||
var job = new RiskScoringJob(
|
||||
JobId: jobId,
|
||||
TenantId: request.TenantId,
|
||||
ContextId: request.ContextId,
|
||||
ProfileId: request.ProfileId,
|
||||
ProfileHash: profileHash,
|
||||
Findings: request.Findings,
|
||||
Priority: request.Priority,
|
||||
Status: RiskScoringJobStatus.Queued,
|
||||
RequestedAt: requestedAt,
|
||||
CorrelationId: request.CorrelationId);
|
||||
|
||||
await _jobStore.SaveAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current queue depth.
|
||||
/// </summary>
|
||||
public async Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queued = await _jobStore.ListByStatusAsync(RiskScoringJobStatus.Queued, limit: 10000, cancellationToken).ConfigureAwait(false);
|
||||
return queued.Count;
|
||||
}
|
||||
|
||||
private static RiskScoringPriority DeterminePriority(FindingChangeType changeType)
|
||||
{
|
||||
return changeType switch
|
||||
{
|
||||
FindingChangeType.Created => RiskScoringPriority.High,
|
||||
FindingChangeType.Enriched => RiskScoringPriority.High,
|
||||
FindingChangeType.VexApplied => RiskScoringPriority.High,
|
||||
FindingChangeType.Updated => RiskScoringPriority.Normal,
|
||||
_ => RiskScoringPriority.Normal
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTriggerKey(FindingChangedEvent evt)
|
||||
{
|
||||
return $"{evt.TenantId}|{evt.ContextId}|{evt.FindingId}|{evt.ChangeType}";
|
||||
}
|
||||
|
||||
private bool IsRecentlyTriggered(string key)
|
||||
{
|
||||
if (_recentTriggers.TryGetValue(key, out var timestamp))
|
||||
{
|
||||
var elapsed = _timeProvider.GetUtcNow() - timestamp;
|
||||
return elapsed < _deduplicationWindow;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void RecordTrigger(string key)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
_recentTriggers[key] = now;
|
||||
|
||||
CleanupOldTriggers(now);
|
||||
}
|
||||
|
||||
private void CleanupOldTriggers(DateTimeOffset now)
|
||||
{
|
||||
var threshold = now - _deduplicationWindow * 2;
|
||||
var keysToRemove = _recentTriggers
|
||||
.Where(kvp => kvp.Value < threshold)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_recentTriggers.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateJobId(string tenantId, string contextId, DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{tenantId}|{contextId}|{timestamp:O}|{Guid.NewGuid()}";
|
||||
var hash = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(seed), HashPurpose.Content);
|
||||
return $"rsj-{hash[..16]}";
|
||||
}
|
||||
}
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Service for triggering risk scoring jobs when findings change.
|
||||
/// </summary>
|
||||
public sealed class RiskScoringTriggerService
|
||||
{
|
||||
private readonly ILogger<RiskScoringTriggerService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RiskProfileConfigurationService _profileService;
|
||||
private readonly IRiskScoringJobStore _jobStore;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _recentTriggers;
|
||||
private readonly TimeSpan _deduplicationWindow;
|
||||
|
||||
public RiskScoringTriggerService(
|
||||
ILogger<RiskScoringTriggerService> logger,
|
||||
TimeProvider timeProvider,
|
||||
RiskProfileConfigurationService profileService,
|
||||
IRiskScoringJobStore jobStore,
|
||||
ICryptoHash cryptoHash)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_profileService = profileService ?? throw new ArgumentNullException(nameof(profileService));
|
||||
_jobStore = jobStore ?? throw new ArgumentNullException(nameof(jobStore));
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_hasher = new RiskProfileHasher(cryptoHash);
|
||||
_recentTriggers = new ConcurrentDictionary<string, DateTimeOffset>();
|
||||
_deduplicationWindow = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a finding changed event and creates a scoring job if appropriate.
|
||||
/// </summary>
|
||||
/// <param name="evt">The finding changed event.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created job, or null if skipped.</returns>
|
||||
public async Task<RiskScoringJob?> HandleFindingChangedAsync(
|
||||
FindingChangedEvent evt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
|
||||
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("risk_scoring.trigger");
|
||||
activity?.SetTag("finding.id", evt.FindingId);
|
||||
activity?.SetTag("change_type", evt.ChangeType.ToString());
|
||||
|
||||
if (!_profileService.IsEnabled)
|
||||
{
|
||||
_logger.LogDebug("Risk profile integration disabled; skipping scoring for {FindingId}", evt.FindingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var triggerKey = BuildTriggerKey(evt);
|
||||
if (IsRecentlyTriggered(triggerKey))
|
||||
{
|
||||
_logger.LogDebug("Skipping duplicate trigger for {FindingId} within deduplication window", evt.FindingId);
|
||||
PolicyEngineTelemetry.RiskScoringTriggersSkipped.Add(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
var request = new RiskScoringJobRequest(
|
||||
TenantId: evt.TenantId,
|
||||
ContextId: evt.ContextId,
|
||||
ProfileId: _profileService.DefaultProfileId,
|
||||
Findings: new[]
|
||||
{
|
||||
new RiskScoringFinding(
|
||||
evt.FindingId,
|
||||
evt.ComponentPurl,
|
||||
evt.AdvisoryId,
|
||||
evt.ChangeType)
|
||||
},
|
||||
Priority: DeterminePriority(evt.ChangeType),
|
||||
CorrelationId: evt.CorrelationId,
|
||||
RequestedAt: evt.Timestamp);
|
||||
|
||||
var job = await CreateJobAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
RecordTrigger(triggerKey);
|
||||
PolicyEngineTelemetry.RiskScoringJobsCreated.Add(1);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created risk scoring job {JobId} for finding {FindingId} (trigger: {ChangeType})",
|
||||
job.JobId, evt.FindingId, evt.ChangeType);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles multiple finding changed events in batch.
|
||||
/// </summary>
|
||||
/// <param name="events">The finding changed events.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created job, or null if all events were skipped.</returns>
|
||||
public async Task<RiskScoringJob?> HandleFindingsBatchAsync(
|
||||
IReadOnlyList<FindingChangedEvent> events,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_profileService.IsEnabled)
|
||||
{
|
||||
_logger.LogDebug("Risk profile integration disabled; skipping batch scoring");
|
||||
return null;
|
||||
}
|
||||
|
||||
var uniqueEvents = events
|
||||
.Where(e => !IsRecentlyTriggered(BuildTriggerKey(e)))
|
||||
.GroupBy(e => e.FindingId)
|
||||
.Select(g => g.OrderByDescending(e => e.Timestamp).First())
|
||||
.ToList();
|
||||
|
||||
if (uniqueEvents.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("All events in batch were duplicates; skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
var firstEvent = uniqueEvents[0];
|
||||
var highestPriority = uniqueEvents.Select(e => DeterminePriority(e.ChangeType)).Max();
|
||||
|
||||
var request = new RiskScoringJobRequest(
|
||||
TenantId: firstEvent.TenantId,
|
||||
ContextId: firstEvent.ContextId,
|
||||
ProfileId: _profileService.DefaultProfileId,
|
||||
Findings: uniqueEvents.Select(e => new RiskScoringFinding(
|
||||
e.FindingId,
|
||||
e.ComponentPurl,
|
||||
e.AdvisoryId,
|
||||
e.ChangeType)).ToList(),
|
||||
Priority: highestPriority,
|
||||
CorrelationId: firstEvent.CorrelationId,
|
||||
RequestedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
var job = await CreateJobAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var evt in uniqueEvents)
|
||||
{
|
||||
RecordTrigger(BuildTriggerKey(evt));
|
||||
}
|
||||
|
||||
PolicyEngineTelemetry.RiskScoringJobsCreated.Add(1);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created batch risk scoring job {JobId} for {FindingCount} findings",
|
||||
job.JobId, uniqueEvents.Count);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a risk scoring job from a request.
|
||||
/// </summary>
|
||||
public async Task<RiskScoringJob> CreateJobAsync(
|
||||
RiskScoringJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var profile = _profileService.GetProfile(request.ProfileId);
|
||||
if (profile == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Risk profile '{request.ProfileId}' not found.");
|
||||
}
|
||||
|
||||
var profileHash = _hasher.ComputeHash(profile);
|
||||
var requestedAt = request.RequestedAt ?? _timeProvider.GetUtcNow();
|
||||
var jobId = GenerateJobId(request.TenantId, request.ContextId, requestedAt);
|
||||
|
||||
var job = new RiskScoringJob(
|
||||
JobId: jobId,
|
||||
TenantId: request.TenantId,
|
||||
ContextId: request.ContextId,
|
||||
ProfileId: request.ProfileId,
|
||||
ProfileHash: profileHash,
|
||||
Findings: request.Findings,
|
||||
Priority: request.Priority,
|
||||
Status: RiskScoringJobStatus.Queued,
|
||||
RequestedAt: requestedAt,
|
||||
CorrelationId: request.CorrelationId);
|
||||
|
||||
await _jobStore.SaveAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current queue depth.
|
||||
/// </summary>
|
||||
public async Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queued = await _jobStore.ListByStatusAsync(RiskScoringJobStatus.Queued, limit: 10000, cancellationToken).ConfigureAwait(false);
|
||||
return queued.Count;
|
||||
}
|
||||
|
||||
private static RiskScoringPriority DeterminePriority(FindingChangeType changeType)
|
||||
{
|
||||
return changeType switch
|
||||
{
|
||||
FindingChangeType.Created => RiskScoringPriority.High,
|
||||
FindingChangeType.Enriched => RiskScoringPriority.High,
|
||||
FindingChangeType.VexApplied => RiskScoringPriority.High,
|
||||
FindingChangeType.Updated => RiskScoringPriority.Normal,
|
||||
_ => RiskScoringPriority.Normal
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTriggerKey(FindingChangedEvent evt)
|
||||
{
|
||||
return $"{evt.TenantId}|{evt.ContextId}|{evt.FindingId}|{evt.ChangeType}";
|
||||
}
|
||||
|
||||
private bool IsRecentlyTriggered(string key)
|
||||
{
|
||||
if (_recentTriggers.TryGetValue(key, out var timestamp))
|
||||
{
|
||||
var elapsed = _timeProvider.GetUtcNow() - timestamp;
|
||||
return elapsed < _deduplicationWindow;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void RecordTrigger(string key)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
_recentTriggers[key] = now;
|
||||
|
||||
CleanupOldTriggers(now);
|
||||
}
|
||||
|
||||
private void CleanupOldTriggers(DateTimeOffset now)
|
||||
{
|
||||
var threshold = now - _deduplicationWindow * 2;
|
||||
var keysToRemove = _recentTriggers
|
||||
.Where(kvp => kvp.Value < threshold)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_recentTriggers.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateJobId(string tenantId, string contextId, DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{tenantId}|{contextId}|{timestamp:O}|{Guid.NewGuid()}";
|
||||
var hash = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(seed), HashPurpose.Content);
|
||||
return $"rsj-{hash[..16]}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal interface IPolicyPackRepository
|
||||
{
|
||||
Task<PolicyPackRecord> CreateAsync(string packId, string? displayName, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<PolicyPackRecord>> ListAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyRevisionRecord> UpsertRevisionAsync(string packId, int version, bool requiresTwoPersonApproval, PolicyRevisionStatus initialStatus, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyRevisionRecord?> GetRevisionAsync(string packId, int version, CancellationToken cancellationToken);
|
||||
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal interface IPolicyPackRepository
|
||||
{
|
||||
Task<PolicyPackRecord> CreateAsync(string packId, string? displayName, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<PolicyPackRecord>> ListAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyRevisionRecord> UpsertRevisionAsync(string packId, int version, bool requiresTwoPersonApproval, PolicyRevisionStatus initialStatus, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyRevisionRecord?> GetRevisionAsync(string packId, int version, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyActivationResult> RecordActivationAsync(string packId, int version, string actorId, DateTimeOffset timestamp, string? comment, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyBundleRecord> StoreBundleAsync(string packId, int version, PolicyBundleRecord bundle, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyBundleRecord?> GetBundleAsync(string packId, int version, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record PolicyActivationResult(PolicyActivationResultStatus Status, PolicyRevisionRecord? Revision);
|
||||
|
||||
internal enum PolicyActivationResultStatus
|
||||
{
|
||||
PackNotFound,
|
||||
RevisionNotFound,
|
||||
NotApproved,
|
||||
DuplicateApproval,
|
||||
PendingSecondApproval,
|
||||
Activated,
|
||||
AlreadyActive
|
||||
}
|
||||
|
||||
internal sealed record PolicyActivationResult(PolicyActivationResultStatus Status, PolicyRevisionRecord? Revision);
|
||||
|
||||
internal enum PolicyActivationResultStatus
|
||||
{
|
||||
PackNotFound,
|
||||
RevisionNotFound,
|
||||
NotApproved,
|
||||
DuplicateApproval,
|
||||
PendingSecondApproval,
|
||||
Activated,
|
||||
AlreadyActive
|
||||
}
|
||||
|
||||
@@ -1,88 +1,88 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PolicyPackRecord> packs = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<PolicyPackRecord> CreateAsync(string packId, string? displayName, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(packId);
|
||||
|
||||
var created = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, displayName, DateTimeOffset.UtcNow));
|
||||
return Task.FromResult(created);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PolicyPackRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<PolicyPackRecord> list = packs.Values
|
||||
.OrderBy(pack => pack.PackId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return Task.FromResult(list);
|
||||
}
|
||||
|
||||
public Task<PolicyRevisionRecord> UpsertRevisionAsync(string packId, int version, bool requiresTwoPersonApproval, PolicyRevisionStatus initialStatus, CancellationToken cancellationToken)
|
||||
{
|
||||
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, DateTimeOffset.UtcNow));
|
||||
int revisionVersion = version > 0 ? version : pack.GetNextVersion();
|
||||
var revision = pack.GetOrAddRevision(
|
||||
revisionVersion,
|
||||
v => new PolicyRevisionRecord(v, requiresTwoPersonApproval, initialStatus, DateTimeOffset.UtcNow));
|
||||
|
||||
if (revision.Status != initialStatus)
|
||||
{
|
||||
revision.SetStatus(initialStatus, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
return Task.FromResult(revision);
|
||||
}
|
||||
|
||||
public Task<PolicyRevisionRecord?> GetRevisionAsync(string packId, int version, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!packs.TryGetValue(packId, out var pack))
|
||||
{
|
||||
return Task.FromResult<PolicyRevisionRecord?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult(pack.TryGetRevision(version, out var revision) ? revision : null);
|
||||
}
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PolicyPackRecord> packs = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<PolicyPackRecord> CreateAsync(string packId, string? displayName, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(packId);
|
||||
|
||||
var created = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, displayName, DateTimeOffset.UtcNow));
|
||||
return Task.FromResult(created);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PolicyPackRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<PolicyPackRecord> list = packs.Values
|
||||
.OrderBy(pack => pack.PackId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return Task.FromResult(list);
|
||||
}
|
||||
|
||||
public Task<PolicyRevisionRecord> UpsertRevisionAsync(string packId, int version, bool requiresTwoPersonApproval, PolicyRevisionStatus initialStatus, CancellationToken cancellationToken)
|
||||
{
|
||||
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, DateTimeOffset.UtcNow));
|
||||
int revisionVersion = version > 0 ? version : pack.GetNextVersion();
|
||||
var revision = pack.GetOrAddRevision(
|
||||
revisionVersion,
|
||||
v => new PolicyRevisionRecord(v, requiresTwoPersonApproval, initialStatus, DateTimeOffset.UtcNow));
|
||||
|
||||
if (revision.Status != initialStatus)
|
||||
{
|
||||
revision.SetStatus(initialStatus, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
return Task.FromResult(revision);
|
||||
}
|
||||
|
||||
public Task<PolicyRevisionRecord?> GetRevisionAsync(string packId, int version, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!packs.TryGetValue(packId, out var pack))
|
||||
{
|
||||
return Task.FromResult<PolicyRevisionRecord?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult(pack.TryGetRevision(version, out var revision) ? revision : null);
|
||||
}
|
||||
|
||||
public Task<PolicyActivationResult> RecordActivationAsync(string packId, int version, string actorId, DateTimeOffset timestamp, string? comment, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!packs.TryGetValue(packId, out var pack))
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.PackNotFound, null));
|
||||
}
|
||||
|
||||
if (!pack.TryGetRevision(version, out var revision))
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.RevisionNotFound, null));
|
||||
}
|
||||
|
||||
if (revision.Status == PolicyRevisionStatus.Active)
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.AlreadyActive, revision));
|
||||
}
|
||||
|
||||
if (revision.Status != PolicyRevisionStatus.Approved)
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.NotApproved, revision));
|
||||
}
|
||||
|
||||
var approvalStatus = revision.AddApproval(new PolicyActivationApproval(actorId, timestamp, comment));
|
||||
return Task.FromResult(approvalStatus switch
|
||||
{
|
||||
PolicyActivationApprovalStatus.Duplicate => new PolicyActivationResult(PolicyActivationResultStatus.DuplicateApproval, revision),
|
||||
PolicyActivationApprovalStatus.Pending when revision.RequiresTwoPersonApproval
|
||||
=> new PolicyActivationResult(PolicyActivationResultStatus.PendingSecondApproval, revision),
|
||||
PolicyActivationApprovalStatus.Pending =>
|
||||
ActivateRevision(revision, timestamp),
|
||||
PolicyActivationApprovalStatus.ThresholdReached =>
|
||||
ActivateRevision(revision, timestamp),
|
||||
_ => throw new InvalidOperationException("Unknown activation approval status.")
|
||||
});
|
||||
}
|
||||
|
||||
if (!pack.TryGetRevision(version, out var revision))
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.RevisionNotFound, null));
|
||||
}
|
||||
|
||||
if (revision.Status == PolicyRevisionStatus.Active)
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.AlreadyActive, revision));
|
||||
}
|
||||
|
||||
if (revision.Status != PolicyRevisionStatus.Approved)
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.NotApproved, revision));
|
||||
}
|
||||
|
||||
var approvalStatus = revision.AddApproval(new PolicyActivationApproval(actorId, timestamp, comment));
|
||||
return Task.FromResult(approvalStatus switch
|
||||
{
|
||||
PolicyActivationApprovalStatus.Duplicate => new PolicyActivationResult(PolicyActivationResultStatus.DuplicateApproval, revision),
|
||||
PolicyActivationApprovalStatus.Pending when revision.RequiresTwoPersonApproval
|
||||
=> new PolicyActivationResult(PolicyActivationResultStatus.PendingSecondApproval, revision),
|
||||
PolicyActivationApprovalStatus.Pending =>
|
||||
ActivateRevision(revision, timestamp),
|
||||
PolicyActivationApprovalStatus.ThresholdReached =>
|
||||
ActivateRevision(revision, timestamp),
|
||||
_ => throw new InvalidOperationException("Unknown activation approval status.")
|
||||
});
|
||||
}
|
||||
|
||||
private static PolicyActivationResult ActivateRevision(PolicyRevisionRecord revision, DateTimeOffset timestamp)
|
||||
|
||||
@@ -1,100 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal interface IPolicyActivationAuditor
|
||||
{
|
||||
void RecordActivation(
|
||||
string packId,
|
||||
int version,
|
||||
string actorId,
|
||||
string? tenantId,
|
||||
PolicyActivationResult result,
|
||||
string? comment);
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationAuditor : IPolicyActivationAuditor
|
||||
{
|
||||
private const int CommentLimit = 512;
|
||||
|
||||
private readonly PolicyEngineOptions options;
|
||||
private readonly ILogger<PolicyActivationAuditor> logger;
|
||||
|
||||
public PolicyActivationAuditor(
|
||||
PolicyEngineOptions options,
|
||||
ILogger<PolicyActivationAuditor> logger)
|
||||
{
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void RecordActivation(
|
||||
string packId,
|
||||
int version,
|
||||
string actorId,
|
||||
string? tenantId,
|
||||
PolicyActivationResult result,
|
||||
string? comment)
|
||||
{
|
||||
if (!options.Activation.EmitAuditLogs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(packId);
|
||||
ArgumentNullException.ThrowIfNull(actorId);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var normalizedStatus = NormalizeStatus(result.Status);
|
||||
var scope = new Dictionary<string, object?>
|
||||
{
|
||||
["policy.pack_id"] = packId,
|
||||
["policy.revision"] = version,
|
||||
["policy.activation.status"] = normalizedStatus,
|
||||
["policy.activation.actor"] = actorId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
scope["policy.tenant"] = tenantId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(comment))
|
||||
{
|
||||
scope["policy.activation.comment"] = Truncate(comment!, CommentLimit);
|
||||
}
|
||||
|
||||
if (result.Revision is { } revision)
|
||||
{
|
||||
scope["policy.activation.requires_two_person"] = revision.RequiresTwoPersonApproval;
|
||||
scope["policy.activation.approval_count"] = revision.Approvals.Length;
|
||||
if (revision.Approvals.Length > 0)
|
||||
{
|
||||
scope["policy.activation.approvers"] = revision.Approvals
|
||||
.Select(static approval => approval.ActorId)
|
||||
.Where(static actor => !string.IsNullOrWhiteSpace(actor))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Policy activation {PackId}/{Revision} completed with status {Status}.",
|
||||
packId,
|
||||
version,
|
||||
normalizedStatus);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeStatus(PolicyActivationResultStatus status)
|
||||
=> status.ToString().ToLowerInvariant();
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
=> value.Length <= maxLength ? value : value[..maxLength];
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal interface IPolicyActivationAuditor
|
||||
{
|
||||
void RecordActivation(
|
||||
string packId,
|
||||
int version,
|
||||
string actorId,
|
||||
string? tenantId,
|
||||
PolicyActivationResult result,
|
||||
string? comment);
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationAuditor : IPolicyActivationAuditor
|
||||
{
|
||||
private const int CommentLimit = 512;
|
||||
|
||||
private readonly PolicyEngineOptions options;
|
||||
private readonly ILogger<PolicyActivationAuditor> logger;
|
||||
|
||||
public PolicyActivationAuditor(
|
||||
PolicyEngineOptions options,
|
||||
ILogger<PolicyActivationAuditor> logger)
|
||||
{
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void RecordActivation(
|
||||
string packId,
|
||||
int version,
|
||||
string actorId,
|
||||
string? tenantId,
|
||||
PolicyActivationResult result,
|
||||
string? comment)
|
||||
{
|
||||
if (!options.Activation.EmitAuditLogs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(packId);
|
||||
ArgumentNullException.ThrowIfNull(actorId);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var normalizedStatus = NormalizeStatus(result.Status);
|
||||
var scope = new Dictionary<string, object?>
|
||||
{
|
||||
["policy.pack_id"] = packId,
|
||||
["policy.revision"] = version,
|
||||
["policy.activation.status"] = normalizedStatus,
|
||||
["policy.activation.actor"] = actorId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
scope["policy.tenant"] = tenantId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(comment))
|
||||
{
|
||||
scope["policy.activation.comment"] = Truncate(comment!, CommentLimit);
|
||||
}
|
||||
|
||||
if (result.Revision is { } revision)
|
||||
{
|
||||
scope["policy.activation.requires_two_person"] = revision.RequiresTwoPersonApproval;
|
||||
scope["policy.activation.approval_count"] = revision.Approvals.Length;
|
||||
if (revision.Approvals.Length > 0)
|
||||
{
|
||||
scope["policy.activation.approvers"] = revision.Approvals
|
||||
.Select(static approval => approval.ActorId)
|
||||
.Where(static actor => !string.IsNullOrWhiteSpace(actor))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Policy activation {PackId}/{Revision} completed with status {Status}.",
|
||||
packId,
|
||||
version,
|
||||
normalizedStatus);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeStatus(PolicyActivationResultStatus status)
|
||||
=> status.ToString().ToLowerInvariant();
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
=> value.Length <= maxLength ? value : value[..maxLength];
|
||||
}
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
using System;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal interface IPolicyActivationSettings
|
||||
{
|
||||
bool ResolveRequirement(bool? requested);
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationSettings : IPolicyActivationSettings
|
||||
{
|
||||
private readonly PolicyEngineOptions options;
|
||||
|
||||
public PolicyActivationSettings(PolicyEngineOptions options)
|
||||
{
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public bool ResolveRequirement(bool? requested)
|
||||
{
|
||||
if (options.Activation.ForceTwoPersonApproval)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (requested.HasValue)
|
||||
{
|
||||
return requested.Value;
|
||||
}
|
||||
|
||||
return options.Activation.DefaultRequiresTwoPersonApproval;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal interface IPolicyActivationSettings
|
||||
{
|
||||
bool ResolveRequirement(bool? requested);
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationSettings : IPolicyActivationSettings
|
||||
{
|
||||
private readonly PolicyEngineOptions options;
|
||||
|
||||
public PolicyActivationSettings(PolicyEngineOptions options)
|
||||
{
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public bool ResolveRequirement(bool? requested)
|
||||
{
|
||||
if (options.Activation.ForceTwoPersonApproval)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (requested.HasValue)
|
||||
{
|
||||
return requested.Value;
|
||||
}
|
||||
|
||||
return options.Activation.DefaultRequiresTwoPersonApproval;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal static class PolicyEngineDiagnosticCodes
|
||||
{
|
||||
public const string CompilationComplexityExceeded = "ERR_POL_COMPLEXITY";
|
||||
}
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal static class PolicyEngineDiagnosticCodes
|
||||
{
|
||||
public const string CompilationComplexityExceeded = "ERR_POL_COMPLEXITY";
|
||||
}
|
||||
|
||||
@@ -1,344 +1,344 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
using StellaOps.Policy.RiskProfile.Merge;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
using StellaOps.Policy.RiskProfile.Validation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for loading and providing risk profiles from configuration.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileConfigurationService
|
||||
{
|
||||
private readonly ILogger<RiskProfileConfigurationService> _logger;
|
||||
private readonly PolicyEngineRiskProfileOptions _options;
|
||||
private readonly RiskProfileMergeService _mergeService;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly RiskProfileValidator _validator;
|
||||
private readonly ConcurrentDictionary<string, RiskProfileModel> _profileCache;
|
||||
private readonly ConcurrentDictionary<string, RiskProfileModel> _resolvedCache;
|
||||
private readonly object _loadLock = new();
|
||||
private bool _loaded;
|
||||
|
||||
public RiskProfileConfigurationService(
|
||||
ILogger<RiskProfileConfigurationService> logger,
|
||||
IOptions<PolicyEngineOptions> options,
|
||||
ICryptoHash cryptoHash)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value.RiskProfile ?? throw new ArgumentNullException(nameof(options));
|
||||
ArgumentNullException.ThrowIfNull(cryptoHash);
|
||||
_mergeService = new RiskProfileMergeService();
|
||||
_hasher = new RiskProfileHasher(cryptoHash);
|
||||
_validator = new RiskProfileValidator();
|
||||
_profileCache = new ConcurrentDictionary<string, RiskProfileModel>(StringComparer.OrdinalIgnoreCase);
|
||||
_resolvedCache = new ConcurrentDictionary<string, RiskProfileModel>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether risk profile integration is enabled.
|
||||
/// </summary>
|
||||
public bool IsEnabled => _options.Enabled;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default profile ID.
|
||||
/// </summary>
|
||||
public string DefaultProfileId => _options.DefaultProfileId;
|
||||
|
||||
/// <summary>
|
||||
/// Loads all profiles from configuration and file system.
|
||||
/// </summary>
|
||||
public void LoadProfiles()
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_loadLock)
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LoadInlineProfiles();
|
||||
LoadFileProfiles();
|
||||
EnsureDefaultProfile();
|
||||
|
||||
_loaded = true;
|
||||
_logger.LogInformation(
|
||||
"Loaded {Count} risk profiles (default: {DefaultId})",
|
||||
_profileCache.Count,
|
||||
_options.DefaultProfileId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a profile by ID, resolving inheritance if needed.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID to retrieve.</param>
|
||||
/// <returns>The resolved profile, or null if not found.</returns>
|
||||
public RiskProfileModel? GetProfile(string? profileId)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(profileId) ? _options.DefaultProfileId : profileId;
|
||||
|
||||
if (_options.CacheResolvedProfiles && _resolvedCache.TryGetValue(id, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (!_profileCache.TryGetValue(id, out var profile))
|
||||
{
|
||||
_logger.LogWarning("Risk profile '{ProfileId}' not found", id);
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolved = _mergeService.ResolveInheritance(
|
||||
profile,
|
||||
LookupProfile,
|
||||
_options.MaxInheritanceDepth);
|
||||
|
||||
if (_options.CacheResolvedProfiles)
|
||||
{
|
||||
_resolvedCache.TryAdd(id, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default profile.
|
||||
/// </summary>
|
||||
public RiskProfileModel? GetDefaultProfile() => GetProfile(_options.DefaultProfileId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all loaded profile IDs.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> GetProfileIds() => _profileCache.Keys.ToList().AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic hash for a profile.
|
||||
/// </summary>
|
||||
public string ComputeHash(RiskProfileModel profile) => _hasher.ComputeHash(profile);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a content hash (ignoring identity fields) for a profile.
|
||||
/// </summary>
|
||||
public string ComputeContentHash(RiskProfileModel profile) => _hasher.ComputeContentHash(profile);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a profile programmatically.
|
||||
/// </summary>
|
||||
public void RegisterProfile(RiskProfileModel profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
_profileCache[profile.Id] = profile;
|
||||
_resolvedCache.TryRemove(profile.Id, out _);
|
||||
|
||||
_logger.LogDebug("Registered risk profile '{ProfileId}' v{Version}", profile.Id, profile.Version);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the resolved profile cache.
|
||||
/// </summary>
|
||||
public void ClearResolvedCache()
|
||||
{
|
||||
_resolvedCache.Clear();
|
||||
_logger.LogDebug("Cleared resolved profile cache");
|
||||
}
|
||||
|
||||
private RiskProfileModel? LookupProfile(string id) =>
|
||||
_profileCache.TryGetValue(id, out var profile) ? profile : null;
|
||||
|
||||
private void LoadInlineProfiles()
|
||||
{
|
||||
foreach (var definition in _options.Profiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = ConvertFromDefinition(definition);
|
||||
_profileCache[profile.Id] = profile;
|
||||
_logger.LogDebug("Loaded inline profile '{ProfileId}' v{Version}", profile.Id, profile.Version);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load inline profile '{ProfileId}'", definition.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadFileProfiles()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ProfileDirectory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(_options.ProfileDirectory))
|
||||
{
|
||||
_logger.LogWarning("Risk profile directory not found: {Directory}", _options.ProfileDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(_options.ProfileDirectory, "*.json", SearchOption.AllDirectories);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(file);
|
||||
|
||||
if (_options.ValidateOnLoad)
|
||||
{
|
||||
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("; ", errorMessages.Any() ? errorMessages : new[] { "Unknown error" }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var profile = JsonSerializer.Deserialize<RiskProfileModel>(json, JsonOptions);
|
||||
if (profile != null)
|
||||
{
|
||||
_profileCache[profile.Id] = profile;
|
||||
_logger.LogDebug("Loaded profile '{ProfileId}' from {File}", profile.Id, file);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load risk profile from '{File}'", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureDefaultProfile()
|
||||
{
|
||||
if (_profileCache.ContainsKey(_options.DefaultProfileId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var defaultProfile = CreateBuiltInDefaultProfile();
|
||||
_profileCache[defaultProfile.Id] = defaultProfile;
|
||||
_logger.LogDebug("Created built-in default profile '{ProfileId}'", defaultProfile.Id);
|
||||
}
|
||||
|
||||
private static RiskProfileModel CreateBuiltInDefaultProfile()
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = "default",
|
||||
Version = "1.0.0",
|
||||
Description = "Built-in default risk profile with standard vulnerability signals.",
|
||||
Signals = new List<RiskSignal>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "cvss_score",
|
||||
Source = "vulnerability",
|
||||
Type = RiskSignalType.Numeric,
|
||||
Path = "/cvss/baseScore",
|
||||
Unit = "score"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "kev",
|
||||
Source = "cisa",
|
||||
Type = RiskSignalType.Boolean,
|
||||
Path = "/kev/inCatalog"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "epss",
|
||||
Source = "first",
|
||||
Type = RiskSignalType.Numeric,
|
||||
Path = "/epss/probability",
|
||||
Unit = "probability"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "reachability",
|
||||
Source = "analysis",
|
||||
Type = RiskSignalType.Categorical,
|
||||
Path = "/reachability/status"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "exploit_available",
|
||||
Source = "exploit-db",
|
||||
Type = RiskSignalType.Boolean,
|
||||
Path = "/exploit/available"
|
||||
}
|
||||
},
|
||||
Weights = new Dictionary<string, double>
|
||||
{
|
||||
["cvss_score"] = 0.3,
|
||||
["kev"] = 0.25,
|
||||
["epss"] = 0.2,
|
||||
["reachability"] = 0.15,
|
||||
["exploit_available"] = 0.1
|
||||
},
|
||||
Overrides = new RiskOverrides(),
|
||||
Metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["builtin"] = true,
|
||||
["created"] = DateTimeOffset.UtcNow.ToString("o")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskProfileModel ConvertFromDefinition(RiskProfileDefinition definition)
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = definition.Id,
|
||||
Version = definition.Version,
|
||||
Description = definition.Description,
|
||||
Extends = definition.Extends,
|
||||
Signals = definition.Signals.Select(s => new RiskSignal
|
||||
{
|
||||
Name = s.Name,
|
||||
Source = s.Source,
|
||||
Type = ParseSignalType(s.Type),
|
||||
Path = s.Path,
|
||||
Transform = s.Transform,
|
||||
Unit = s.Unit
|
||||
}).ToList(),
|
||||
Weights = new Dictionary<string, double>(definition.Weights),
|
||||
Overrides = new RiskOverrides(),
|
||||
Metadata = definition.Metadata != null
|
||||
? new Dictionary<string, object?>(definition.Metadata)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskSignalType ParseSignalType(string type)
|
||||
{
|
||||
return type.ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" or "bool" => RiskSignalType.Boolean,
|
||||
"numeric" or "number" => RiskSignalType.Numeric,
|
||||
"categorical" or "category" => RiskSignalType.Categorical,
|
||||
_ => throw new ArgumentException($"Unknown signal type: {type}")
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
}
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
using StellaOps.Policy.RiskProfile.Merge;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
using StellaOps.Policy.RiskProfile.Validation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for loading and providing risk profiles from configuration.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileConfigurationService
|
||||
{
|
||||
private readonly ILogger<RiskProfileConfigurationService> _logger;
|
||||
private readonly PolicyEngineRiskProfileOptions _options;
|
||||
private readonly RiskProfileMergeService _mergeService;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly RiskProfileValidator _validator;
|
||||
private readonly ConcurrentDictionary<string, RiskProfileModel> _profileCache;
|
||||
private readonly ConcurrentDictionary<string, RiskProfileModel> _resolvedCache;
|
||||
private readonly object _loadLock = new();
|
||||
private bool _loaded;
|
||||
|
||||
public RiskProfileConfigurationService(
|
||||
ILogger<RiskProfileConfigurationService> logger,
|
||||
IOptions<PolicyEngineOptions> options,
|
||||
ICryptoHash cryptoHash)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value.RiskProfile ?? throw new ArgumentNullException(nameof(options));
|
||||
ArgumentNullException.ThrowIfNull(cryptoHash);
|
||||
_mergeService = new RiskProfileMergeService();
|
||||
_hasher = new RiskProfileHasher(cryptoHash);
|
||||
_validator = new RiskProfileValidator();
|
||||
_profileCache = new ConcurrentDictionary<string, RiskProfileModel>(StringComparer.OrdinalIgnoreCase);
|
||||
_resolvedCache = new ConcurrentDictionary<string, RiskProfileModel>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether risk profile integration is enabled.
|
||||
/// </summary>
|
||||
public bool IsEnabled => _options.Enabled;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default profile ID.
|
||||
/// </summary>
|
||||
public string DefaultProfileId => _options.DefaultProfileId;
|
||||
|
||||
/// <summary>
|
||||
/// Loads all profiles from configuration and file system.
|
||||
/// </summary>
|
||||
public void LoadProfiles()
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_loadLock)
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LoadInlineProfiles();
|
||||
LoadFileProfiles();
|
||||
EnsureDefaultProfile();
|
||||
|
||||
_loaded = true;
|
||||
_logger.LogInformation(
|
||||
"Loaded {Count} risk profiles (default: {DefaultId})",
|
||||
_profileCache.Count,
|
||||
_options.DefaultProfileId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a profile by ID, resolving inheritance if needed.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID to retrieve.</param>
|
||||
/// <returns>The resolved profile, or null if not found.</returns>
|
||||
public RiskProfileModel? GetProfile(string? profileId)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(profileId) ? _options.DefaultProfileId : profileId;
|
||||
|
||||
if (_options.CacheResolvedProfiles && _resolvedCache.TryGetValue(id, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (!_profileCache.TryGetValue(id, out var profile))
|
||||
{
|
||||
_logger.LogWarning("Risk profile '{ProfileId}' not found", id);
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolved = _mergeService.ResolveInheritance(
|
||||
profile,
|
||||
LookupProfile,
|
||||
_options.MaxInheritanceDepth);
|
||||
|
||||
if (_options.CacheResolvedProfiles)
|
||||
{
|
||||
_resolvedCache.TryAdd(id, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default profile.
|
||||
/// </summary>
|
||||
public RiskProfileModel? GetDefaultProfile() => GetProfile(_options.DefaultProfileId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all loaded profile IDs.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> GetProfileIds() => _profileCache.Keys.ToList().AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic hash for a profile.
|
||||
/// </summary>
|
||||
public string ComputeHash(RiskProfileModel profile) => _hasher.ComputeHash(profile);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a content hash (ignoring identity fields) for a profile.
|
||||
/// </summary>
|
||||
public string ComputeContentHash(RiskProfileModel profile) => _hasher.ComputeContentHash(profile);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a profile programmatically.
|
||||
/// </summary>
|
||||
public void RegisterProfile(RiskProfileModel profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
_profileCache[profile.Id] = profile;
|
||||
_resolvedCache.TryRemove(profile.Id, out _);
|
||||
|
||||
_logger.LogDebug("Registered risk profile '{ProfileId}' v{Version}", profile.Id, profile.Version);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the resolved profile cache.
|
||||
/// </summary>
|
||||
public void ClearResolvedCache()
|
||||
{
|
||||
_resolvedCache.Clear();
|
||||
_logger.LogDebug("Cleared resolved profile cache");
|
||||
}
|
||||
|
||||
private RiskProfileModel? LookupProfile(string id) =>
|
||||
_profileCache.TryGetValue(id, out var profile) ? profile : null;
|
||||
|
||||
private void LoadInlineProfiles()
|
||||
{
|
||||
foreach (var definition in _options.Profiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = ConvertFromDefinition(definition);
|
||||
_profileCache[profile.Id] = profile;
|
||||
_logger.LogDebug("Loaded inline profile '{ProfileId}' v{Version}", profile.Id, profile.Version);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load inline profile '{ProfileId}'", definition.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadFileProfiles()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ProfileDirectory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(_options.ProfileDirectory))
|
||||
{
|
||||
_logger.LogWarning("Risk profile directory not found: {Directory}", _options.ProfileDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(_options.ProfileDirectory, "*.json", SearchOption.AllDirectories);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(file);
|
||||
|
||||
if (_options.ValidateOnLoad)
|
||||
{
|
||||
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("; ", errorMessages.Any() ? errorMessages : new[] { "Unknown error" }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var profile = JsonSerializer.Deserialize<RiskProfileModel>(json, JsonOptions);
|
||||
if (profile != null)
|
||||
{
|
||||
_profileCache[profile.Id] = profile;
|
||||
_logger.LogDebug("Loaded profile '{ProfileId}' from {File}", profile.Id, file);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load risk profile from '{File}'", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureDefaultProfile()
|
||||
{
|
||||
if (_profileCache.ContainsKey(_options.DefaultProfileId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var defaultProfile = CreateBuiltInDefaultProfile();
|
||||
_profileCache[defaultProfile.Id] = defaultProfile;
|
||||
_logger.LogDebug("Created built-in default profile '{ProfileId}'", defaultProfile.Id);
|
||||
}
|
||||
|
||||
private static RiskProfileModel CreateBuiltInDefaultProfile()
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = "default",
|
||||
Version = "1.0.0",
|
||||
Description = "Built-in default risk profile with standard vulnerability signals.",
|
||||
Signals = new List<RiskSignal>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "cvss_score",
|
||||
Source = "vulnerability",
|
||||
Type = RiskSignalType.Numeric,
|
||||
Path = "/cvss/baseScore",
|
||||
Unit = "score"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "kev",
|
||||
Source = "cisa",
|
||||
Type = RiskSignalType.Boolean,
|
||||
Path = "/kev/inCatalog"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "epss",
|
||||
Source = "first",
|
||||
Type = RiskSignalType.Numeric,
|
||||
Path = "/epss/probability",
|
||||
Unit = "probability"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "reachability",
|
||||
Source = "analysis",
|
||||
Type = RiskSignalType.Categorical,
|
||||
Path = "/reachability/status"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "exploit_available",
|
||||
Source = "exploit-db",
|
||||
Type = RiskSignalType.Boolean,
|
||||
Path = "/exploit/available"
|
||||
}
|
||||
},
|
||||
Weights = new Dictionary<string, double>
|
||||
{
|
||||
["cvss_score"] = 0.3,
|
||||
["kev"] = 0.25,
|
||||
["epss"] = 0.2,
|
||||
["reachability"] = 0.15,
|
||||
["exploit_available"] = 0.1
|
||||
},
|
||||
Overrides = new RiskOverrides(),
|
||||
Metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["builtin"] = true,
|
||||
["created"] = DateTimeOffset.UtcNow.ToString("o")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskProfileModel ConvertFromDefinition(RiskProfileDefinition definition)
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = definition.Id,
|
||||
Version = definition.Version,
|
||||
Description = definition.Description,
|
||||
Extends = definition.Extends,
|
||||
Signals = definition.Signals.Select(s => new RiskSignal
|
||||
{
|
||||
Name = s.Name,
|
||||
Source = s.Source,
|
||||
Type = ParseSignalType(s.Type),
|
||||
Path = s.Path,
|
||||
Transform = s.Transform,
|
||||
Unit = s.Unit
|
||||
}).ToList(),
|
||||
Weights = new Dictionary<string, double>(definition.Weights),
|
||||
Overrides = new RiskOverrides(),
|
||||
Metadata = definition.Metadata != null
|
||||
? new Dictionary<string, object?>(definition.Metadata)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskSignalType ParseSignalType(string type)
|
||||
{
|
||||
return type.ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" or "bool" => RiskSignalType.Boolean,
|
||||
"numeric" or "number" => RiskSignalType.Numeric,
|
||||
"categorical" or "category" => RiskSignalType.Categorical,
|
||||
_ => throw new ArgumentException($"Unknown signal type: {type}")
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal static class ScopeAuthorization
|
||||
{
|
||||
private static readonly StringComparer ScopeComparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public static IResult? RequireScope(HttpContext context, string requiredScope)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requiredScope))
|
||||
{
|
||||
throw new ArgumentException("Scope must be provided.", nameof(requiredScope));
|
||||
}
|
||||
|
||||
var user = context.User;
|
||||
if (user?.Identity?.IsAuthenticated is not true)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (!HasScope(user, requiredScope))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasScope(ClaimsPrincipal principal, string scope)
|
||||
{
|
||||
foreach (var claim in principal.FindAll("scope").Concat(principal.FindAll("scp")))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scopes = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (scopes.Any(value => ScopeComparer.Equals(value, scope)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal static class ScopeAuthorization
|
||||
{
|
||||
private static readonly StringComparer ScopeComparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public static IResult? RequireScope(HttpContext context, string requiredScope)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requiredScope))
|
||||
{
|
||||
throw new ArgumentException("Scope must be provided.", nameof(requiredScope));
|
||||
}
|
||||
|
||||
var user = context.User;
|
||||
if (user?.Identity?.IsAuthenticated is not true)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (!HasScope(user, requiredScope))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasScope(ClaimsPrincipal principal, string scope)
|
||||
{
|
||||
foreach (var claim in principal.FindAll("scope").Concat(principal.FindAll("scp")))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scopes = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (scopes.Any(value => ScopeComparer.Equals(value, scope)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,379 +1,379 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an evaluation evidence bundle containing all inputs, outputs,
|
||||
/// and metadata for a policy evaluation run.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this evidence bundle.
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Run identifier this bundle is associated with.
|
||||
/// </summary>
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the bundle was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the bundle contents for integrity verification.
|
||||
/// </summary>
|
||||
public string? ContentHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinism hash from the evaluation run.
|
||||
/// </summary>
|
||||
public string? DeterminismHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input references for the evaluation.
|
||||
/// </summary>
|
||||
public required EvidenceInputs Inputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output summary from the evaluation.
|
||||
/// </summary>
|
||||
public required EvidenceOutputs Outputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment and configuration metadata.
|
||||
/// </summary>
|
||||
public required EvidenceEnvironment Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manifest listing all artifacts in the bundle.
|
||||
/// </summary>
|
||||
public required EvidenceManifest Manifest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// References to inputs used in the policy evaluation.
|
||||
/// </summary>
|
||||
public sealed class EvidenceInputs
|
||||
{
|
||||
/// <summary>
|
||||
/// SBOM document references with content hashes.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifactRef> SbomRefs { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Advisory document references from Concelier.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifactRef> AdvisoryRefs { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// VEX document references from Excititor.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifactRef> VexRefs { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Reachability evidence references.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifactRef> ReachabilityRefs { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack IR digest.
|
||||
/// </summary>
|
||||
public string? PolicyIrDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cursor positions for incremental evaluation.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Cursors { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of evaluation outputs.
|
||||
/// </summary>
|
||||
public sealed class EvidenceOutputs
|
||||
{
|
||||
/// <summary>
|
||||
/// Total findings evaluated.
|
||||
/// </summary>
|
||||
public int TotalFindings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Findings by verdict status.
|
||||
/// </summary>
|
||||
public Dictionary<string, int> FindingsByVerdict { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Findings by severity.
|
||||
/// </summary>
|
||||
public Dictionary<string, int> FindingsBySeverity { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Total rules evaluated.
|
||||
/// </summary>
|
||||
public int RulesEvaluated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total rules that fired.
|
||||
/// </summary>
|
||||
public int RulesFired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX overrides applied.
|
||||
/// </summary>
|
||||
public int VexOverridesApplied { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the evaluation in seconds.
|
||||
/// </summary>
|
||||
public double DurationSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the evaluation (success, failure, canceled).
|
||||
/// </summary>
|
||||
public required string Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error details if outcome is failure.
|
||||
/// </summary>
|
||||
public string? ErrorDetails { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Environment and configuration metadata for the evaluation.
|
||||
/// </summary>
|
||||
public sealed class EvidenceEnvironment
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy Engine service version.
|
||||
/// </summary>
|
||||
public required string ServiceVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation mode (full, incremental, simulate).
|
||||
/// </summary>
|
||||
public required string Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether sealed/air-gapped mode was active.
|
||||
/// </summary>
|
||||
public bool SealedMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Host machine identifier.
|
||||
/// </summary>
|
||||
public string? HostId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace ID for correlation.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Configuration snapshot relevant to the evaluation.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> ConfigSnapshot { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest listing all artifacts in the evidence bundle.
|
||||
/// </summary>
|
||||
public sealed class EvidenceManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Version of the manifest schema.
|
||||
/// </summary>
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// List of artifacts in the bundle.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifact> Artifacts { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds an artifact to the manifest.
|
||||
/// </summary>
|
||||
public void AddArtifact(string name, string mediaType, long sizeBytes, string contentHash)
|
||||
{
|
||||
Artifacts.Add(new EvidenceArtifact
|
||||
{
|
||||
Name = name,
|
||||
MediaType = mediaType,
|
||||
SizeBytes = sizeBytes,
|
||||
ContentHash = contentHash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an external artifact used as input.
|
||||
/// </summary>
|
||||
public sealed class EvidenceArtifactRef
|
||||
{
|
||||
/// <summary>
|
||||
/// URI or identifier for the artifact.
|
||||
/// </summary>
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash (SHA-256) of the artifact.
|
||||
/// </summary>
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the artifact.
|
||||
/// </summary>
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the artifact was fetched.
|
||||
/// </summary>
|
||||
public DateTimeOffset? FetchedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An artifact included in the evidence bundle.
|
||||
/// </summary>
|
||||
public sealed class EvidenceArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// Name/path of the artifact within the bundle.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the artifact.
|
||||
/// </summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 content hash.
|
||||
/// </summary>
|
||||
public required string ContentHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing evaluation evidence bundles.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public EvidenceBundleService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new evidence bundle for a policy evaluation run.
|
||||
/// </summary>
|
||||
public EvidenceBundle CreateBundle(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string policyVersion,
|
||||
string mode,
|
||||
string serviceVersion,
|
||||
bool sealedMode = false,
|
||||
string? traceId = null)
|
||||
{
|
||||
var bundleId = GenerateBundleId(runId);
|
||||
|
||||
return new EvidenceBundle
|
||||
{
|
||||
BundleId = bundleId,
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = policyVersion,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Inputs = new EvidenceInputs(),
|
||||
Outputs = new EvidenceOutputs { Outcome = "pending" },
|
||||
Environment = new EvidenceEnvironment
|
||||
{
|
||||
ServiceVersion = serviceVersion,
|
||||
Mode = mode,
|
||||
SealedMode = sealedMode,
|
||||
TraceId = traceId,
|
||||
HostId = Environment.MachineName,
|
||||
},
|
||||
Manifest = new EvidenceManifest(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finalizes the bundle by computing the content hash.
|
||||
/// </summary>
|
||||
public void FinalizeBundle(EvidenceBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var json = JsonSerializer.Serialize(bundle, EvidenceBundleJsonContext.Default.EvidenceBundle);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
bundle.ContentHash = Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the bundle to JSON.
|
||||
/// </summary>
|
||||
public string SerializeBundle(EvidenceBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
return JsonSerializer.Serialize(bundle, EvidenceBundleJsonContext.Default.EvidenceBundle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a bundle from JSON.
|
||||
/// </summary>
|
||||
public EvidenceBundle? DeserializeBundle(string json)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(json);
|
||||
return JsonSerializer.Deserialize(json, EvidenceBundleJsonContext.Default.EvidenceBundle);
|
||||
}
|
||||
|
||||
private static string GenerateBundleId(string runId)
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
return $"bundle-{runId}-{timestamp:x}";
|
||||
}
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(EvidenceBundle))]
|
||||
[JsonSerializable(typeof(EvidenceInputs))]
|
||||
[JsonSerializable(typeof(EvidenceOutputs))]
|
||||
[JsonSerializable(typeof(EvidenceEnvironment))]
|
||||
[JsonSerializable(typeof(EvidenceManifest))]
|
||||
[JsonSerializable(typeof(EvidenceArtifact))]
|
||||
[JsonSerializable(typeof(EvidenceArtifactRef))]
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
internal partial class EvidenceBundleJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an evaluation evidence bundle containing all inputs, outputs,
|
||||
/// and metadata for a policy evaluation run.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this evidence bundle.
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Run identifier this bundle is associated with.
|
||||
/// </summary>
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the bundle was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the bundle contents for integrity verification.
|
||||
/// </summary>
|
||||
public string? ContentHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinism hash from the evaluation run.
|
||||
/// </summary>
|
||||
public string? DeterminismHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input references for the evaluation.
|
||||
/// </summary>
|
||||
public required EvidenceInputs Inputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output summary from the evaluation.
|
||||
/// </summary>
|
||||
public required EvidenceOutputs Outputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment and configuration metadata.
|
||||
/// </summary>
|
||||
public required EvidenceEnvironment Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manifest listing all artifacts in the bundle.
|
||||
/// </summary>
|
||||
public required EvidenceManifest Manifest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// References to inputs used in the policy evaluation.
|
||||
/// </summary>
|
||||
public sealed class EvidenceInputs
|
||||
{
|
||||
/// <summary>
|
||||
/// SBOM document references with content hashes.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifactRef> SbomRefs { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Advisory document references from Concelier.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifactRef> AdvisoryRefs { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// VEX document references from Excititor.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifactRef> VexRefs { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Reachability evidence references.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifactRef> ReachabilityRefs { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack IR digest.
|
||||
/// </summary>
|
||||
public string? PolicyIrDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cursor positions for incremental evaluation.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Cursors { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of evaluation outputs.
|
||||
/// </summary>
|
||||
public sealed class EvidenceOutputs
|
||||
{
|
||||
/// <summary>
|
||||
/// Total findings evaluated.
|
||||
/// </summary>
|
||||
public int TotalFindings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Findings by verdict status.
|
||||
/// </summary>
|
||||
public Dictionary<string, int> FindingsByVerdict { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Findings by severity.
|
||||
/// </summary>
|
||||
public Dictionary<string, int> FindingsBySeverity { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Total rules evaluated.
|
||||
/// </summary>
|
||||
public int RulesEvaluated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total rules that fired.
|
||||
/// </summary>
|
||||
public int RulesFired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX overrides applied.
|
||||
/// </summary>
|
||||
public int VexOverridesApplied { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the evaluation in seconds.
|
||||
/// </summary>
|
||||
public double DurationSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the evaluation (success, failure, canceled).
|
||||
/// </summary>
|
||||
public required string Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error details if outcome is failure.
|
||||
/// </summary>
|
||||
public string? ErrorDetails { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Environment and configuration metadata for the evaluation.
|
||||
/// </summary>
|
||||
public sealed class EvidenceEnvironment
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy Engine service version.
|
||||
/// </summary>
|
||||
public required string ServiceVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation mode (full, incremental, simulate).
|
||||
/// </summary>
|
||||
public required string Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether sealed/air-gapped mode was active.
|
||||
/// </summary>
|
||||
public bool SealedMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Host machine identifier.
|
||||
/// </summary>
|
||||
public string? HostId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace ID for correlation.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Configuration snapshot relevant to the evaluation.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> ConfigSnapshot { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest listing all artifacts in the evidence bundle.
|
||||
/// </summary>
|
||||
public sealed class EvidenceManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Version of the manifest schema.
|
||||
/// </summary>
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// List of artifacts in the bundle.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifact> Artifacts { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds an artifact to the manifest.
|
||||
/// </summary>
|
||||
public void AddArtifact(string name, string mediaType, long sizeBytes, string contentHash)
|
||||
{
|
||||
Artifacts.Add(new EvidenceArtifact
|
||||
{
|
||||
Name = name,
|
||||
MediaType = mediaType,
|
||||
SizeBytes = sizeBytes,
|
||||
ContentHash = contentHash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an external artifact used as input.
|
||||
/// </summary>
|
||||
public sealed class EvidenceArtifactRef
|
||||
{
|
||||
/// <summary>
|
||||
/// URI or identifier for the artifact.
|
||||
/// </summary>
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash (SHA-256) of the artifact.
|
||||
/// </summary>
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the artifact.
|
||||
/// </summary>
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the artifact was fetched.
|
||||
/// </summary>
|
||||
public DateTimeOffset? FetchedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An artifact included in the evidence bundle.
|
||||
/// </summary>
|
||||
public sealed class EvidenceArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// Name/path of the artifact within the bundle.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the artifact.
|
||||
/// </summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 content hash.
|
||||
/// </summary>
|
||||
public required string ContentHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing evaluation evidence bundles.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public EvidenceBundleService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new evidence bundle for a policy evaluation run.
|
||||
/// </summary>
|
||||
public EvidenceBundle CreateBundle(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string policyVersion,
|
||||
string mode,
|
||||
string serviceVersion,
|
||||
bool sealedMode = false,
|
||||
string? traceId = null)
|
||||
{
|
||||
var bundleId = GenerateBundleId(runId);
|
||||
|
||||
return new EvidenceBundle
|
||||
{
|
||||
BundleId = bundleId,
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = policyVersion,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Inputs = new EvidenceInputs(),
|
||||
Outputs = new EvidenceOutputs { Outcome = "pending" },
|
||||
Environment = new EvidenceEnvironment
|
||||
{
|
||||
ServiceVersion = serviceVersion,
|
||||
Mode = mode,
|
||||
SealedMode = sealedMode,
|
||||
TraceId = traceId,
|
||||
HostId = Environment.MachineName,
|
||||
},
|
||||
Manifest = new EvidenceManifest(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finalizes the bundle by computing the content hash.
|
||||
/// </summary>
|
||||
public void FinalizeBundle(EvidenceBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var json = JsonSerializer.Serialize(bundle, EvidenceBundleJsonContext.Default.EvidenceBundle);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
bundle.ContentHash = Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the bundle to JSON.
|
||||
/// </summary>
|
||||
public string SerializeBundle(EvidenceBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
return JsonSerializer.Serialize(bundle, EvidenceBundleJsonContext.Default.EvidenceBundle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a bundle from JSON.
|
||||
/// </summary>
|
||||
public EvidenceBundle? DeserializeBundle(string json)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(json);
|
||||
return JsonSerializer.Deserialize(json, EvidenceBundleJsonContext.Default.EvidenceBundle);
|
||||
}
|
||||
|
||||
private static string GenerateBundleId(string runId)
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
return $"bundle-{runId}-{timestamp:x}";
|
||||
}
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(EvidenceBundle))]
|
||||
[JsonSerializable(typeof(EvidenceInputs))]
|
||||
[JsonSerializable(typeof(EvidenceOutputs))]
|
||||
[JsonSerializable(typeof(EvidenceEnvironment))]
|
||||
[JsonSerializable(typeof(EvidenceManifest))]
|
||||
[JsonSerializable(typeof(EvidenceArtifact))]
|
||||
[JsonSerializable(typeof(EvidenceArtifactRef))]
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
internal partial class EvidenceBundleJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,211 +1,211 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing incident mode, which enables 100% trace sampling
|
||||
/// and extended retention during critical periods.
|
||||
/// </summary>
|
||||
public sealed class IncidentModeService
|
||||
{
|
||||
private readonly ILogger<IncidentModeService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<PolicyEngineTelemetryOptions> _optionsMonitor;
|
||||
|
||||
private volatile IncidentModeState _state = new(false, null, null, null);
|
||||
|
||||
public IncidentModeService(
|
||||
ILogger<IncidentModeService> logger,
|
||||
TimeProvider timeProvider,
|
||||
IOptionsMonitor<PolicyEngineTelemetryOptions> optionsMonitor)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
|
||||
// Initialize from configuration
|
||||
if (_optionsMonitor.CurrentValue.IncidentMode)
|
||||
{
|
||||
_state = new IncidentModeState(
|
||||
true,
|
||||
_timeProvider.GetUtcNow(),
|
||||
null,
|
||||
"configuration");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current incident mode state.
|
||||
/// </summary>
|
||||
public IncidentModeState State => _state;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether incident mode is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive => _state.IsActive;
|
||||
|
||||
/// <summary>
|
||||
/// Enables incident mode.
|
||||
/// </summary>
|
||||
/// <param name="reason">Reason for enabling incident mode.</param>
|
||||
/// <param name="duration">Optional duration after which incident mode auto-disables.</param>
|
||||
public void Enable(string reason, TimeSpan? duration = null)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = duration.HasValue ? now.Add(duration.Value) : (DateTimeOffset?)null;
|
||||
|
||||
_state = new IncidentModeState(true, now, expiresAt, reason);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Incident mode ENABLED. Reason: {Reason}, ExpiresAt: {ExpiresAt}",
|
||||
reason,
|
||||
expiresAt?.ToString("O") ?? "never");
|
||||
|
||||
PolicyEngineTelemetry.RecordError("incident_mode_enabled", null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables incident mode.
|
||||
/// </summary>
|
||||
/// <param name="reason">Reason for disabling incident mode.</param>
|
||||
public void Disable(string reason)
|
||||
{
|
||||
var wasActive = _state.IsActive;
|
||||
_state = new IncidentModeState(false, null, null, null);
|
||||
|
||||
if (wasActive)
|
||||
{
|
||||
_logger.LogInformation("Incident mode DISABLED. Reason: {Reason}", reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if incident mode should be auto-disabled due to expiration.
|
||||
/// </summary>
|
||||
public void CheckExpiration()
|
||||
{
|
||||
var state = _state;
|
||||
if (state.IsActive && state.ExpiresAt.HasValue)
|
||||
{
|
||||
if (_timeProvider.GetUtcNow() >= state.ExpiresAt.Value)
|
||||
{
|
||||
Disable("auto-expired");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective sampling ratio, considering incident mode.
|
||||
/// </summary>
|
||||
public double GetEffectiveSamplingRatio()
|
||||
{
|
||||
if (_state.IsActive)
|
||||
{
|
||||
return 1.0; // 100% sampling during incident mode
|
||||
}
|
||||
|
||||
return _optionsMonitor.CurrentValue.TraceSamplingRatio;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the current state of incident mode.
|
||||
/// </summary>
|
||||
public sealed record IncidentModeState(
|
||||
bool IsActive,
|
||||
DateTimeOffset? ActivatedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? Reason);
|
||||
|
||||
/// <summary>
|
||||
/// A trace sampler that respects incident mode settings.
|
||||
/// </summary>
|
||||
public sealed class IncidentModeSampler : Sampler
|
||||
{
|
||||
private readonly IncidentModeService _incidentModeService;
|
||||
private readonly Sampler _baseSampler;
|
||||
|
||||
public IncidentModeSampler(IncidentModeService incidentModeService, double baseSamplingRatio)
|
||||
{
|
||||
_incidentModeService = incidentModeService ?? throw new ArgumentNullException(nameof(incidentModeService));
|
||||
_baseSampler = new TraceIdRatioBasedSampler(baseSamplingRatio);
|
||||
}
|
||||
|
||||
public override SamplingResult ShouldSample(in SamplingParameters samplingParameters)
|
||||
{
|
||||
// During incident mode, always sample
|
||||
if (_incidentModeService.IsActive)
|
||||
{
|
||||
return new SamplingResult(SamplingDecision.RecordAndSample);
|
||||
}
|
||||
|
||||
// Otherwise, use the base sampler
|
||||
return _baseSampler.ShouldSample(samplingParameters);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring incident mode.
|
||||
/// </summary>
|
||||
public static class IncidentModeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the incident mode sampler to the tracer provider.
|
||||
/// </summary>
|
||||
public static TracerProviderBuilder SetIncidentModeSampler(
|
||||
this TracerProviderBuilder builder,
|
||||
IncidentModeService incidentModeService,
|
||||
double baseSamplingRatio)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(incidentModeService);
|
||||
|
||||
return builder.SetSampler(new IncidentModeSampler(incidentModeService, baseSamplingRatio));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background service that periodically checks incident mode expiration.
|
||||
/// </summary>
|
||||
public sealed class IncidentModeExpirationWorker : BackgroundService
|
||||
{
|
||||
private readonly IncidentModeService _incidentModeService;
|
||||
private readonly ILogger<IncidentModeExpirationWorker> _logger;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
public IncidentModeExpirationWorker(
|
||||
IncidentModeService incidentModeService,
|
||||
ILogger<IncidentModeExpirationWorker> logger)
|
||||
{
|
||||
_incidentModeService = incidentModeService ?? throw new ArgumentNullException(nameof(incidentModeService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogDebug("Incident mode expiration worker started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_incidentModeService.CheckExpiration();
|
||||
await Task.Delay(_checkInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking incident mode expiration.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Incident mode expiration worker stopped.");
|
||||
}
|
||||
}
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing incident mode, which enables 100% trace sampling
|
||||
/// and extended retention during critical periods.
|
||||
/// </summary>
|
||||
public sealed class IncidentModeService
|
||||
{
|
||||
private readonly ILogger<IncidentModeService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<PolicyEngineTelemetryOptions> _optionsMonitor;
|
||||
|
||||
private volatile IncidentModeState _state = new(false, null, null, null);
|
||||
|
||||
public IncidentModeService(
|
||||
ILogger<IncidentModeService> logger,
|
||||
TimeProvider timeProvider,
|
||||
IOptionsMonitor<PolicyEngineTelemetryOptions> optionsMonitor)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
|
||||
// Initialize from configuration
|
||||
if (_optionsMonitor.CurrentValue.IncidentMode)
|
||||
{
|
||||
_state = new IncidentModeState(
|
||||
true,
|
||||
_timeProvider.GetUtcNow(),
|
||||
null,
|
||||
"configuration");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current incident mode state.
|
||||
/// </summary>
|
||||
public IncidentModeState State => _state;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether incident mode is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive => _state.IsActive;
|
||||
|
||||
/// <summary>
|
||||
/// Enables incident mode.
|
||||
/// </summary>
|
||||
/// <param name="reason">Reason for enabling incident mode.</param>
|
||||
/// <param name="duration">Optional duration after which incident mode auto-disables.</param>
|
||||
public void Enable(string reason, TimeSpan? duration = null)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = duration.HasValue ? now.Add(duration.Value) : (DateTimeOffset?)null;
|
||||
|
||||
_state = new IncidentModeState(true, now, expiresAt, reason);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Incident mode ENABLED. Reason: {Reason}, ExpiresAt: {ExpiresAt}",
|
||||
reason,
|
||||
expiresAt?.ToString("O") ?? "never");
|
||||
|
||||
PolicyEngineTelemetry.RecordError("incident_mode_enabled", null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables incident mode.
|
||||
/// </summary>
|
||||
/// <param name="reason">Reason for disabling incident mode.</param>
|
||||
public void Disable(string reason)
|
||||
{
|
||||
var wasActive = _state.IsActive;
|
||||
_state = new IncidentModeState(false, null, null, null);
|
||||
|
||||
if (wasActive)
|
||||
{
|
||||
_logger.LogInformation("Incident mode DISABLED. Reason: {Reason}", reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if incident mode should be auto-disabled due to expiration.
|
||||
/// </summary>
|
||||
public void CheckExpiration()
|
||||
{
|
||||
var state = _state;
|
||||
if (state.IsActive && state.ExpiresAt.HasValue)
|
||||
{
|
||||
if (_timeProvider.GetUtcNow() >= state.ExpiresAt.Value)
|
||||
{
|
||||
Disable("auto-expired");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective sampling ratio, considering incident mode.
|
||||
/// </summary>
|
||||
public double GetEffectiveSamplingRatio()
|
||||
{
|
||||
if (_state.IsActive)
|
||||
{
|
||||
return 1.0; // 100% sampling during incident mode
|
||||
}
|
||||
|
||||
return _optionsMonitor.CurrentValue.TraceSamplingRatio;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the current state of incident mode.
|
||||
/// </summary>
|
||||
public sealed record IncidentModeState(
|
||||
bool IsActive,
|
||||
DateTimeOffset? ActivatedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? Reason);
|
||||
|
||||
/// <summary>
|
||||
/// A trace sampler that respects incident mode settings.
|
||||
/// </summary>
|
||||
public sealed class IncidentModeSampler : Sampler
|
||||
{
|
||||
private readonly IncidentModeService _incidentModeService;
|
||||
private readonly Sampler _baseSampler;
|
||||
|
||||
public IncidentModeSampler(IncidentModeService incidentModeService, double baseSamplingRatio)
|
||||
{
|
||||
_incidentModeService = incidentModeService ?? throw new ArgumentNullException(nameof(incidentModeService));
|
||||
_baseSampler = new TraceIdRatioBasedSampler(baseSamplingRatio);
|
||||
}
|
||||
|
||||
public override SamplingResult ShouldSample(in SamplingParameters samplingParameters)
|
||||
{
|
||||
// During incident mode, always sample
|
||||
if (_incidentModeService.IsActive)
|
||||
{
|
||||
return new SamplingResult(SamplingDecision.RecordAndSample);
|
||||
}
|
||||
|
||||
// Otherwise, use the base sampler
|
||||
return _baseSampler.ShouldSample(samplingParameters);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring incident mode.
|
||||
/// </summary>
|
||||
public static class IncidentModeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the incident mode sampler to the tracer provider.
|
||||
/// </summary>
|
||||
public static TracerProviderBuilder SetIncidentModeSampler(
|
||||
this TracerProviderBuilder builder,
|
||||
IncidentModeService incidentModeService,
|
||||
double baseSamplingRatio)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(incidentModeService);
|
||||
|
||||
return builder.SetSampler(new IncidentModeSampler(incidentModeService, baseSamplingRatio));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background service that periodically checks incident mode expiration.
|
||||
/// </summary>
|
||||
public sealed class IncidentModeExpirationWorker : BackgroundService
|
||||
{
|
||||
private readonly IncidentModeService _incidentModeService;
|
||||
private readonly ILogger<IncidentModeExpirationWorker> _logger;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
public IncidentModeExpirationWorker(
|
||||
IncidentModeService incidentModeService,
|
||||
ILogger<IncidentModeExpirationWorker> logger)
|
||||
{
|
||||
_incidentModeService = incidentModeService ?? throw new ArgumentNullException(nameof(incidentModeService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogDebug("Incident mode expiration worker started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_incidentModeService.CheckExpiration();
|
||||
await Task.Delay(_checkInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking incident mode expiration.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Incident mode expiration worker stopped.");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,85 +1,85 @@
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for Policy Engine telemetry.
|
||||
/// </summary>
|
||||
public sealed class PolicyEngineTelemetryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether telemetry is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether tracing is enabled.
|
||||
/// </summary>
|
||||
public bool EnableTracing { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether metrics collection is enabled.
|
||||
/// </summary>
|
||||
public bool EnableMetrics { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether structured logging is enabled.
|
||||
/// </summary>
|
||||
public bool EnableLogging { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the service name used in telemetry data.
|
||||
/// </summary>
|
||||
public string? ServiceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OTLP exporter endpoint.
|
||||
/// </summary>
|
||||
public string? OtlpEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OTLP exporter headers.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> OtlpHeaders { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional resource attributes for OpenTelemetry.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> ResourceAttributes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to export telemetry to console.
|
||||
/// </summary>
|
||||
public bool ExportConsole { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum log level for structured logging.
|
||||
/// </summary>
|
||||
public string MinimumLogLevel { get; set; } = "Information";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether incident mode is enabled.
|
||||
/// When enabled, 100% sampling is applied and extended retention windows are used.
|
||||
/// </summary>
|
||||
public bool IncidentMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the sampling ratio for traces (0.0 to 1.0).
|
||||
/// Ignored when <see cref="IncidentMode"/> is enabled.
|
||||
/// </summary>
|
||||
public double TraceSamplingRatio { get; set; } = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the telemetry options.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(OtlpEndpoint) && !Uri.TryCreate(OtlpEndpoint, UriKind.Absolute, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Telemetry OTLP endpoint must be a valid absolute URI.");
|
||||
}
|
||||
|
||||
if (TraceSamplingRatio is < 0 or > 1)
|
||||
{
|
||||
throw new InvalidOperationException("Telemetry trace sampling ratio must be between 0.0 and 1.0.");
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for Policy Engine telemetry.
|
||||
/// </summary>
|
||||
public sealed class PolicyEngineTelemetryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether telemetry is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether tracing is enabled.
|
||||
/// </summary>
|
||||
public bool EnableTracing { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether metrics collection is enabled.
|
||||
/// </summary>
|
||||
public bool EnableMetrics { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether structured logging is enabled.
|
||||
/// </summary>
|
||||
public bool EnableLogging { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the service name used in telemetry data.
|
||||
/// </summary>
|
||||
public string? ServiceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OTLP exporter endpoint.
|
||||
/// </summary>
|
||||
public string? OtlpEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OTLP exporter headers.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> OtlpHeaders { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional resource attributes for OpenTelemetry.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> ResourceAttributes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to export telemetry to console.
|
||||
/// </summary>
|
||||
public bool ExportConsole { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum log level for structured logging.
|
||||
/// </summary>
|
||||
public string MinimumLogLevel { get; set; } = "Information";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether incident mode is enabled.
|
||||
/// When enabled, 100% sampling is applied and extended retention windows are used.
|
||||
/// </summary>
|
||||
public bool IncidentMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the sampling ratio for traces (0.0 to 1.0).
|
||||
/// Ignored when <see cref="IncidentMode"/> is enabled.
|
||||
/// </summary>
|
||||
public double TraceSamplingRatio { get; set; } = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the telemetry options.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(OtlpEndpoint) && !Uri.TryCreate(OtlpEndpoint, UriKind.Absolute, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Telemetry OTLP endpoint must be a valid absolute URI.");
|
||||
}
|
||||
|
||||
if (TraceSamplingRatio is < 0 or > 1)
|
||||
{
|
||||
throw new InvalidOperationException("Telemetry trace sampling ratio must be between 0.0 and 1.0.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,347 +1,347 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// in-toto statement types for policy evaluation attestations.
|
||||
/// </summary>
|
||||
public static class PolicyAttestationTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Attestation type for policy evaluation results.
|
||||
/// </summary>
|
||||
public const string PolicyEvaluationV1 = "https://stella-ops.org/attestation/policy-evaluation/v1";
|
||||
|
||||
/// <summary>
|
||||
/// DSSE payload type for in-toto statements.
|
||||
/// </summary>
|
||||
public const string InTotoPayloadType = "application/vnd.in-toto+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// in-toto Statement structure for policy evaluation attestations.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationStatement
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; init; } = "https://in-toto.io/Statement/v1";
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public List<InTotoSubject> Subject { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType { get; init; } = PolicyAttestationTypes.PolicyEvaluationV1;
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public required PolicyEvaluationPredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject reference in an in-toto statement.
|
||||
/// </summary>
|
||||
public sealed class InTotoSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required Dictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate containing policy evaluation details.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Run identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("runId")]
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant")]
|
||||
public required string Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyId")]
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation mode (full, incremental, simulate).
|
||||
/// </summary>
|
||||
[JsonPropertyName("mode")]
|
||||
public required string Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when evaluation started.
|
||||
/// </summary>
|
||||
[JsonPropertyName("startedAt")]
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when evaluation completed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("completedAt")]
|
||||
public required DateTimeOffset CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outcome")]
|
||||
public required string Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinism hash for reproducibility verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("determinismHash")]
|
||||
public string? DeterminismHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the evidence bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceBundle")]
|
||||
public EvidenceBundleRef? EvidenceBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary metrics from the evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metrics")]
|
||||
public required PolicyEvaluationMetrics Metrics { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("environment")]
|
||||
public required AttestationEnvironment Environment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an evidence bundle.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleRef
|
||||
{
|
||||
[JsonPropertyName("bundleId")]
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
[JsonPropertyName("contentHash")]
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string? Uri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metrics from the policy evaluation.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationMetrics
|
||||
{
|
||||
[JsonPropertyName("totalFindings")]
|
||||
public int TotalFindings { get; init; }
|
||||
|
||||
[JsonPropertyName("rulesEvaluated")]
|
||||
public int RulesEvaluated { get; init; }
|
||||
|
||||
[JsonPropertyName("rulesFired")]
|
||||
public int RulesFired { get; init; }
|
||||
|
||||
[JsonPropertyName("vexOverridesApplied")]
|
||||
public int VexOverridesApplied { get; init; }
|
||||
|
||||
[JsonPropertyName("durationSeconds")]
|
||||
public double DurationSeconds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Environment information for the attestation.
|
||||
/// </summary>
|
||||
public sealed class AttestationEnvironment
|
||||
{
|
||||
[JsonPropertyName("serviceVersion")]
|
||||
public required string ServiceVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("hostId")]
|
||||
public string? HostId { get; init; }
|
||||
|
||||
[JsonPropertyName("sealedMode")]
|
||||
public bool SealedMode { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating DSSE attestations for policy evaluations.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationAttestationService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PolicyEvaluationAttestationService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an in-toto statement for a policy evaluation.
|
||||
/// </summary>
|
||||
public PolicyEvaluationStatement CreateStatement(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string policyVersion,
|
||||
string mode,
|
||||
DateTimeOffset startedAt,
|
||||
string outcome,
|
||||
string serviceVersion,
|
||||
int totalFindings,
|
||||
int rulesEvaluated,
|
||||
int rulesFired,
|
||||
int vexOverridesApplied,
|
||||
double durationSeconds,
|
||||
string? determinismHash = null,
|
||||
EvidenceBundle? evidenceBundle = null,
|
||||
bool sealedMode = false,
|
||||
IEnumerable<(string name, string digestAlgorithm, string digestValue)>? subjects = null)
|
||||
{
|
||||
var statement = new PolicyEvaluationStatement
|
||||
{
|
||||
Predicate = new PolicyEvaluationPredicate
|
||||
{
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = policyVersion,
|
||||
Mode = mode,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
Outcome = outcome,
|
||||
DeterminismHash = determinismHash,
|
||||
EvidenceBundle = evidenceBundle != null
|
||||
? new EvidenceBundleRef
|
||||
{
|
||||
BundleId = evidenceBundle.BundleId,
|
||||
ContentHash = evidenceBundle.ContentHash ?? "unknown",
|
||||
}
|
||||
: null,
|
||||
Metrics = new PolicyEvaluationMetrics
|
||||
{
|
||||
TotalFindings = totalFindings,
|
||||
RulesEvaluated = rulesEvaluated,
|
||||
RulesFired = rulesFired,
|
||||
VexOverridesApplied = vexOverridesApplied,
|
||||
DurationSeconds = durationSeconds,
|
||||
},
|
||||
Environment = new AttestationEnvironment
|
||||
{
|
||||
ServiceVersion = serviceVersion,
|
||||
HostId = Environment.MachineName,
|
||||
SealedMode = sealedMode,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Add subjects if provided
|
||||
if (subjects != null)
|
||||
{
|
||||
foreach (var (name, algorithm, value) in subjects)
|
||||
{
|
||||
statement.Subject.Add(new InTotoSubject
|
||||
{
|
||||
Name = name,
|
||||
Digest = new Dictionary<string, string> { [algorithm] = value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the policy as a subject
|
||||
statement.Subject.Add(new InTotoSubject
|
||||
{
|
||||
Name = $"policy://{tenant}/{policyId}@{policyVersion}",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ComputePolicyDigest(policyId, policyVersion),
|
||||
},
|
||||
});
|
||||
|
||||
return statement;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes an in-toto statement to JSON bytes for signing.
|
||||
/// </summary>
|
||||
public byte[] SerializeStatement(PolicyEvaluationStatement statement)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statement);
|
||||
var json = JsonSerializer.Serialize(statement, PolicyAttestationJsonContext.Default.PolicyEvaluationStatement);
|
||||
return Encoding.UTF8.GetBytes(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unsigned DSSE envelope for the statement.
|
||||
/// This envelope can be sent to the Attestor service for signing.
|
||||
/// </summary>
|
||||
public DsseEnvelopeRequest CreateEnvelopeRequest(PolicyEvaluationStatement statement)
|
||||
{
|
||||
var payload = SerializeStatement(statement);
|
||||
|
||||
return new DsseEnvelopeRequest
|
||||
{
|
||||
PayloadType = PolicyAttestationTypes.InTotoPayloadType,
|
||||
Payload = payload,
|
||||
PayloadBase64 = Convert.ToBase64String(payload),
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputePolicyDigest(string policyId, string policyVersion)
|
||||
{
|
||||
var input = $"{policyId}@{policyVersion}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a DSSE envelope (to be sent to Attestor service).
|
||||
/// </summary>
|
||||
public sealed class DsseEnvelopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// DSSE payload type.
|
||||
/// </summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw payload bytes.
|
||||
/// </summary>
|
||||
public required byte[] Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded payload for transmission.
|
||||
/// </summary>
|
||||
public required string PayloadBase64 { get; init; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(PolicyEvaluationStatement))]
|
||||
[JsonSerializable(typeof(PolicyEvaluationPredicate))]
|
||||
[JsonSerializable(typeof(InTotoSubject))]
|
||||
[JsonSerializable(typeof(EvidenceBundleRef))]
|
||||
[JsonSerializable(typeof(PolicyEvaluationMetrics))]
|
||||
[JsonSerializable(typeof(AttestationEnvironment))]
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
internal partial class PolicyAttestationJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// in-toto statement types for policy evaluation attestations.
|
||||
/// </summary>
|
||||
public static class PolicyAttestationTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Attestation type for policy evaluation results.
|
||||
/// </summary>
|
||||
public const string PolicyEvaluationV1 = "https://stella-ops.org/attestation/policy-evaluation/v1";
|
||||
|
||||
/// <summary>
|
||||
/// DSSE payload type for in-toto statements.
|
||||
/// </summary>
|
||||
public const string InTotoPayloadType = "application/vnd.in-toto+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// in-toto Statement structure for policy evaluation attestations.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationStatement
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; init; } = "https://in-toto.io/Statement/v1";
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public List<InTotoSubject> Subject { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType { get; init; } = PolicyAttestationTypes.PolicyEvaluationV1;
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public required PolicyEvaluationPredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject reference in an in-toto statement.
|
||||
/// </summary>
|
||||
public sealed class InTotoSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required Dictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate containing policy evaluation details.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Run identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("runId")]
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant")]
|
||||
public required string Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyId")]
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation mode (full, incremental, simulate).
|
||||
/// </summary>
|
||||
[JsonPropertyName("mode")]
|
||||
public required string Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when evaluation started.
|
||||
/// </summary>
|
||||
[JsonPropertyName("startedAt")]
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when evaluation completed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("completedAt")]
|
||||
public required DateTimeOffset CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outcome")]
|
||||
public required string Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinism hash for reproducibility verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("determinismHash")]
|
||||
public string? DeterminismHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the evidence bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceBundle")]
|
||||
public EvidenceBundleRef? EvidenceBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary metrics from the evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metrics")]
|
||||
public required PolicyEvaluationMetrics Metrics { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("environment")]
|
||||
public required AttestationEnvironment Environment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an evidence bundle.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleRef
|
||||
{
|
||||
[JsonPropertyName("bundleId")]
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
[JsonPropertyName("contentHash")]
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string? Uri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metrics from the policy evaluation.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationMetrics
|
||||
{
|
||||
[JsonPropertyName("totalFindings")]
|
||||
public int TotalFindings { get; init; }
|
||||
|
||||
[JsonPropertyName("rulesEvaluated")]
|
||||
public int RulesEvaluated { get; init; }
|
||||
|
||||
[JsonPropertyName("rulesFired")]
|
||||
public int RulesFired { get; init; }
|
||||
|
||||
[JsonPropertyName("vexOverridesApplied")]
|
||||
public int VexOverridesApplied { get; init; }
|
||||
|
||||
[JsonPropertyName("durationSeconds")]
|
||||
public double DurationSeconds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Environment information for the attestation.
|
||||
/// </summary>
|
||||
public sealed class AttestationEnvironment
|
||||
{
|
||||
[JsonPropertyName("serviceVersion")]
|
||||
public required string ServiceVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("hostId")]
|
||||
public string? HostId { get; init; }
|
||||
|
||||
[JsonPropertyName("sealedMode")]
|
||||
public bool SealedMode { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating DSSE attestations for policy evaluations.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationAttestationService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PolicyEvaluationAttestationService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an in-toto statement for a policy evaluation.
|
||||
/// </summary>
|
||||
public PolicyEvaluationStatement CreateStatement(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string policyVersion,
|
||||
string mode,
|
||||
DateTimeOffset startedAt,
|
||||
string outcome,
|
||||
string serviceVersion,
|
||||
int totalFindings,
|
||||
int rulesEvaluated,
|
||||
int rulesFired,
|
||||
int vexOverridesApplied,
|
||||
double durationSeconds,
|
||||
string? determinismHash = null,
|
||||
EvidenceBundle? evidenceBundle = null,
|
||||
bool sealedMode = false,
|
||||
IEnumerable<(string name, string digestAlgorithm, string digestValue)>? subjects = null)
|
||||
{
|
||||
var statement = new PolicyEvaluationStatement
|
||||
{
|
||||
Predicate = new PolicyEvaluationPredicate
|
||||
{
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = policyVersion,
|
||||
Mode = mode,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
Outcome = outcome,
|
||||
DeterminismHash = determinismHash,
|
||||
EvidenceBundle = evidenceBundle != null
|
||||
? new EvidenceBundleRef
|
||||
{
|
||||
BundleId = evidenceBundle.BundleId,
|
||||
ContentHash = evidenceBundle.ContentHash ?? "unknown",
|
||||
}
|
||||
: null,
|
||||
Metrics = new PolicyEvaluationMetrics
|
||||
{
|
||||
TotalFindings = totalFindings,
|
||||
RulesEvaluated = rulesEvaluated,
|
||||
RulesFired = rulesFired,
|
||||
VexOverridesApplied = vexOverridesApplied,
|
||||
DurationSeconds = durationSeconds,
|
||||
},
|
||||
Environment = new AttestationEnvironment
|
||||
{
|
||||
ServiceVersion = serviceVersion,
|
||||
HostId = Environment.MachineName,
|
||||
SealedMode = sealedMode,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Add subjects if provided
|
||||
if (subjects != null)
|
||||
{
|
||||
foreach (var (name, algorithm, value) in subjects)
|
||||
{
|
||||
statement.Subject.Add(new InTotoSubject
|
||||
{
|
||||
Name = name,
|
||||
Digest = new Dictionary<string, string> { [algorithm] = value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the policy as a subject
|
||||
statement.Subject.Add(new InTotoSubject
|
||||
{
|
||||
Name = $"policy://{tenant}/{policyId}@{policyVersion}",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ComputePolicyDigest(policyId, policyVersion),
|
||||
},
|
||||
});
|
||||
|
||||
return statement;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes an in-toto statement to JSON bytes for signing.
|
||||
/// </summary>
|
||||
public byte[] SerializeStatement(PolicyEvaluationStatement statement)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statement);
|
||||
var json = JsonSerializer.Serialize(statement, PolicyAttestationJsonContext.Default.PolicyEvaluationStatement);
|
||||
return Encoding.UTF8.GetBytes(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unsigned DSSE envelope for the statement.
|
||||
/// This envelope can be sent to the Attestor service for signing.
|
||||
/// </summary>
|
||||
public DsseEnvelopeRequest CreateEnvelopeRequest(PolicyEvaluationStatement statement)
|
||||
{
|
||||
var payload = SerializeStatement(statement);
|
||||
|
||||
return new DsseEnvelopeRequest
|
||||
{
|
||||
PayloadType = PolicyAttestationTypes.InTotoPayloadType,
|
||||
Payload = payload,
|
||||
PayloadBase64 = Convert.ToBase64String(payload),
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputePolicyDigest(string policyId, string policyVersion)
|
||||
{
|
||||
var input = $"{policyId}@{policyVersion}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a DSSE envelope (to be sent to Attestor service).
|
||||
/// </summary>
|
||||
public sealed class DsseEnvelopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// DSSE payload type.
|
||||
/// </summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw payload bytes.
|
||||
/// </summary>
|
||||
public required byte[] Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded payload for transmission.
|
||||
/// </summary>
|
||||
public required string PayloadBase64 { get; init; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(PolicyEvaluationStatement))]
|
||||
[JsonSerializable(typeof(PolicyEvaluationPredicate))]
|
||||
[JsonSerializable(typeof(InTotoSubject))]
|
||||
[JsonSerializable(typeof(EvidenceBundleRef))]
|
||||
[JsonSerializable(typeof(PolicyEvaluationMetrics))]
|
||||
[JsonSerializable(typeof(AttestationEnvironment))]
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
internal partial class PolicyAttestationJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,471 +1,471 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Provides structured timeline events for policy evaluation and decision flows.
|
||||
/// Events are emitted as structured logs with correlation to traces.
|
||||
/// </summary>
|
||||
public sealed class PolicyTimelineEvents
|
||||
{
|
||||
private readonly ILogger<PolicyTimelineEvents> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PolicyTimelineEvents(ILogger<PolicyTimelineEvents> logger, TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
#region Evaluation Flow Events
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a policy evaluation run starts.
|
||||
/// </summary>
|
||||
public void EmitRunStarted(string runId, string tenant, string policyId, string policyVersion, string mode)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.RunStarted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = policyVersion,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["mode"] = mode,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a policy evaluation run completes.
|
||||
/// </summary>
|
||||
public void EmitRunCompleted(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string outcome,
|
||||
double durationSeconds,
|
||||
int findingsCount,
|
||||
string? determinismHash = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.RunCompleted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["outcome"] = outcome,
|
||||
["duration_seconds"] = durationSeconds,
|
||||
["findings_count"] = findingsCount,
|
||||
["determinism_hash"] = determinismHash,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a batch selection phase starts.
|
||||
/// </summary>
|
||||
public void EmitSelectionStarted(string runId, string tenant, string policyId, int batchNumber)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.SelectionStarted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["batch_number"] = batchNumber,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a batch selection phase completes.
|
||||
/// </summary>
|
||||
public void EmitSelectionCompleted(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
int batchNumber,
|
||||
int tupleCount,
|
||||
double durationSeconds)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.SelectionCompleted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["batch_number"] = batchNumber,
|
||||
["tuple_count"] = tupleCount,
|
||||
["duration_seconds"] = durationSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when batch evaluation starts.
|
||||
/// </summary>
|
||||
public void EmitEvaluationStarted(string runId, string tenant, string policyId, int batchNumber, int tupleCount)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.EvaluationStarted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["batch_number"] = batchNumber,
|
||||
["tuple_count"] = tupleCount,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when batch evaluation completes.
|
||||
/// </summary>
|
||||
public void EmitEvaluationCompleted(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
int batchNumber,
|
||||
int rulesEvaluated,
|
||||
int rulesFired,
|
||||
double durationSeconds)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.EvaluationCompleted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["batch_number"] = batchNumber,
|
||||
["rules_evaluated"] = rulesEvaluated,
|
||||
["rules_fired"] = rulesFired,
|
||||
["duration_seconds"] = durationSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Decision Flow Events
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a rule matches during evaluation.
|
||||
/// </summary>
|
||||
public void EmitRuleMatched(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string ruleId,
|
||||
string findingKey,
|
||||
string? severity = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.RuleMatched,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["rule_id"] = ruleId,
|
||||
["finding_key"] = findingKey,
|
||||
["severity"] = severity,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a VEX override is applied.
|
||||
/// </summary>
|
||||
public void EmitVexOverrideApplied(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string findingKey,
|
||||
string vendor,
|
||||
string status,
|
||||
string? justification = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.VexOverrideApplied,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["finding_key"] = findingKey,
|
||||
["vendor"] = vendor,
|
||||
["status"] = status,
|
||||
["justification"] = justification,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a final verdict is determined for a finding.
|
||||
/// </summary>
|
||||
public void EmitVerdictDetermined(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string findingKey,
|
||||
string verdict,
|
||||
string severity,
|
||||
string? reachabilityState = null,
|
||||
IReadOnlyList<string>? contributingRules = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.VerdictDetermined,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["finding_key"] = findingKey,
|
||||
["verdict"] = verdict,
|
||||
["severity"] = severity,
|
||||
["reachability_state"] = reachabilityState,
|
||||
["contributing_rules"] = contributingRules,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when materialization of findings starts.
|
||||
/// </summary>
|
||||
public void EmitMaterializationStarted(string runId, string tenant, string policyId, int findingsCount)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.MaterializationStarted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["findings_count"] = findingsCount,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when materialization of findings completes.
|
||||
/// </summary>
|
||||
public void EmitMaterializationCompleted(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
int findingsWritten,
|
||||
int findingsUpdated,
|
||||
double durationSeconds)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.MaterializationCompleted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["findings_written"] = findingsWritten,
|
||||
["findings_updated"] = findingsUpdated,
|
||||
["duration_seconds"] = durationSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Events
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when an error occurs during evaluation.
|
||||
/// </summary>
|
||||
public void EmitError(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string errorCode,
|
||||
string errorMessage,
|
||||
string? phase = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.Error,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["error_code"] = errorCode,
|
||||
["error_message"] = errorMessage,
|
||||
["phase"] = phase,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt, LogLevel.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a determinism violation is detected.
|
||||
/// </summary>
|
||||
public void EmitDeterminismViolation(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string violationType,
|
||||
string details)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.DeterminismViolation,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["violation_type"] = violationType,
|
||||
["details"] = details,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt, LogLevel.Warning);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void LogTimelineEvent(TimelineEvent evt, LogLevel level = LogLevel.Information)
|
||||
{
|
||||
_logger.Log(
|
||||
level,
|
||||
"PolicyTimeline: {EventType} | run={RunId} tenant={Tenant} policy={PolicyId} trace={TraceId} span={SpanId} data={Data}",
|
||||
evt.EventType,
|
||||
evt.RunId,
|
||||
evt.Tenant,
|
||||
evt.PolicyId,
|
||||
evt.TraceId,
|
||||
evt.SpanId,
|
||||
JsonSerializer.Serialize(evt.Data, TimelineEventJsonContext.Default.DictionaryStringObject));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of timeline events emitted during policy evaluation.
|
||||
/// </summary>
|
||||
public enum TimelineEventType
|
||||
{
|
||||
RunStarted,
|
||||
RunCompleted,
|
||||
SelectionStarted,
|
||||
SelectionCompleted,
|
||||
EvaluationStarted,
|
||||
EvaluationCompleted,
|
||||
RuleMatched,
|
||||
VexOverrideApplied,
|
||||
VerdictDetermined,
|
||||
MaterializationStarted,
|
||||
MaterializationCompleted,
|
||||
Error,
|
||||
DeterminismViolation,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a timeline event for policy evaluation flows.
|
||||
/// </summary>
|
||||
public sealed record TimelineEvent
|
||||
{
|
||||
public required TimelineEventType EventType { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required string RunId { get; init; }
|
||||
public required string Tenant { get; init; }
|
||||
public required string PolicyId { get; init; }
|
||||
public string? PolicyVersion { get; init; }
|
||||
public string? TraceId { get; init; }
|
||||
public string? SpanId { get; init; }
|
||||
public Dictionary<string, object?>? Data { get; init; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
[JsonSourceGenerationOptions(WriteIndented = false)]
|
||||
internal partial class TimelineEventJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Provides structured timeline events for policy evaluation and decision flows.
|
||||
/// Events are emitted as structured logs with correlation to traces.
|
||||
/// </summary>
|
||||
public sealed class PolicyTimelineEvents
|
||||
{
|
||||
private readonly ILogger<PolicyTimelineEvents> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PolicyTimelineEvents(ILogger<PolicyTimelineEvents> logger, TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
#region Evaluation Flow Events
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a policy evaluation run starts.
|
||||
/// </summary>
|
||||
public void EmitRunStarted(string runId, string tenant, string policyId, string policyVersion, string mode)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.RunStarted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = policyVersion,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["mode"] = mode,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a policy evaluation run completes.
|
||||
/// </summary>
|
||||
public void EmitRunCompleted(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string outcome,
|
||||
double durationSeconds,
|
||||
int findingsCount,
|
||||
string? determinismHash = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.RunCompleted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["outcome"] = outcome,
|
||||
["duration_seconds"] = durationSeconds,
|
||||
["findings_count"] = findingsCount,
|
||||
["determinism_hash"] = determinismHash,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a batch selection phase starts.
|
||||
/// </summary>
|
||||
public void EmitSelectionStarted(string runId, string tenant, string policyId, int batchNumber)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.SelectionStarted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["batch_number"] = batchNumber,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a batch selection phase completes.
|
||||
/// </summary>
|
||||
public void EmitSelectionCompleted(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
int batchNumber,
|
||||
int tupleCount,
|
||||
double durationSeconds)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.SelectionCompleted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["batch_number"] = batchNumber,
|
||||
["tuple_count"] = tupleCount,
|
||||
["duration_seconds"] = durationSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when batch evaluation starts.
|
||||
/// </summary>
|
||||
public void EmitEvaluationStarted(string runId, string tenant, string policyId, int batchNumber, int tupleCount)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.EvaluationStarted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["batch_number"] = batchNumber,
|
||||
["tuple_count"] = tupleCount,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when batch evaluation completes.
|
||||
/// </summary>
|
||||
public void EmitEvaluationCompleted(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
int batchNumber,
|
||||
int rulesEvaluated,
|
||||
int rulesFired,
|
||||
double durationSeconds)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.EvaluationCompleted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["batch_number"] = batchNumber,
|
||||
["rules_evaluated"] = rulesEvaluated,
|
||||
["rules_fired"] = rulesFired,
|
||||
["duration_seconds"] = durationSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Decision Flow Events
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a rule matches during evaluation.
|
||||
/// </summary>
|
||||
public void EmitRuleMatched(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string ruleId,
|
||||
string findingKey,
|
||||
string? severity = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.RuleMatched,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["rule_id"] = ruleId,
|
||||
["finding_key"] = findingKey,
|
||||
["severity"] = severity,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a VEX override is applied.
|
||||
/// </summary>
|
||||
public void EmitVexOverrideApplied(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string findingKey,
|
||||
string vendor,
|
||||
string status,
|
||||
string? justification = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.VexOverrideApplied,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["finding_key"] = findingKey,
|
||||
["vendor"] = vendor,
|
||||
["status"] = status,
|
||||
["justification"] = justification,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a final verdict is determined for a finding.
|
||||
/// </summary>
|
||||
public void EmitVerdictDetermined(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string findingKey,
|
||||
string verdict,
|
||||
string severity,
|
||||
string? reachabilityState = null,
|
||||
IReadOnlyList<string>? contributingRules = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.VerdictDetermined,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["finding_key"] = findingKey,
|
||||
["verdict"] = verdict,
|
||||
["severity"] = severity,
|
||||
["reachability_state"] = reachabilityState,
|
||||
["contributing_rules"] = contributingRules,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when materialization of findings starts.
|
||||
/// </summary>
|
||||
public void EmitMaterializationStarted(string runId, string tenant, string policyId, int findingsCount)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.MaterializationStarted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["findings_count"] = findingsCount,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when materialization of findings completes.
|
||||
/// </summary>
|
||||
public void EmitMaterializationCompleted(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
int findingsWritten,
|
||||
int findingsUpdated,
|
||||
double durationSeconds)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.MaterializationCompleted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["findings_written"] = findingsWritten,
|
||||
["findings_updated"] = findingsUpdated,
|
||||
["duration_seconds"] = durationSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Events
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when an error occurs during evaluation.
|
||||
/// </summary>
|
||||
public void EmitError(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string errorCode,
|
||||
string errorMessage,
|
||||
string? phase = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.Error,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["error_code"] = errorCode,
|
||||
["error_message"] = errorMessage,
|
||||
["phase"] = phase,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt, LogLevel.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a determinism violation is detected.
|
||||
/// </summary>
|
||||
public void EmitDeterminismViolation(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string violationType,
|
||||
string details)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.DeterminismViolation,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["violation_type"] = violationType,
|
||||
["details"] = details,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt, LogLevel.Warning);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void LogTimelineEvent(TimelineEvent evt, LogLevel level = LogLevel.Information)
|
||||
{
|
||||
_logger.Log(
|
||||
level,
|
||||
"PolicyTimeline: {EventType} | run={RunId} tenant={Tenant} policy={PolicyId} trace={TraceId} span={SpanId} data={Data}",
|
||||
evt.EventType,
|
||||
evt.RunId,
|
||||
evt.Tenant,
|
||||
evt.PolicyId,
|
||||
evt.TraceId,
|
||||
evt.SpanId,
|
||||
JsonSerializer.Serialize(evt.Data, TimelineEventJsonContext.Default.DictionaryStringObject));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of timeline events emitted during policy evaluation.
|
||||
/// </summary>
|
||||
public enum TimelineEventType
|
||||
{
|
||||
RunStarted,
|
||||
RunCompleted,
|
||||
SelectionStarted,
|
||||
SelectionCompleted,
|
||||
EvaluationStarted,
|
||||
EvaluationCompleted,
|
||||
RuleMatched,
|
||||
VexOverrideApplied,
|
||||
VerdictDetermined,
|
||||
MaterializationStarted,
|
||||
MaterializationCompleted,
|
||||
Error,
|
||||
DeterminismViolation,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a timeline event for policy evaluation flows.
|
||||
/// </summary>
|
||||
public sealed record TimelineEvent
|
||||
{
|
||||
public required TimelineEventType EventType { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required string RunId { get; init; }
|
||||
public required string Tenant { get; init; }
|
||||
public required string PolicyId { get; init; }
|
||||
public string? PolicyVersion { get; init; }
|
||||
public string? TraceId { get; init; }
|
||||
public string? SpanId { get; init; }
|
||||
public Dictionary<string, object?>? Data { get; init; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
[JsonSourceGenerationOptions(WriteIndented = false)]
|
||||
internal partial class TimelineEventJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,239 +1,239 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring Policy Engine telemetry.
|
||||
/// </summary>
|
||||
public static class TelemetryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures Policy Engine telemetry including metrics, traces, and structured logging.
|
||||
/// </summary>
|
||||
/// <param name="builder">The web application builder.</param>
|
||||
/// <param name="options">Policy engine options containing telemetry configuration.</param>
|
||||
public static void ConfigurePolicyEngineTelemetry(this WebApplicationBuilder builder, PolicyEngineOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var telemetry = options.Telemetry ?? new PolicyEngineTelemetryOptions();
|
||||
|
||||
if (telemetry.EnableLogging)
|
||||
{
|
||||
builder.Host.UseSerilog((context, services, configuration) =>
|
||||
{
|
||||
ConfigureSerilog(configuration, telemetry, builder.Environment.EnvironmentName, builder.Environment.ApplicationName);
|
||||
});
|
||||
}
|
||||
|
||||
if (!telemetry.Enabled || (!telemetry.EnableTracing && !telemetry.EnableMetrics))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var openTelemetry = builder.Services.AddOpenTelemetry();
|
||||
|
||||
openTelemetry.ConfigureResource(resource =>
|
||||
{
|
||||
var serviceName = telemetry.ServiceName ?? builder.Environment.ApplicationName;
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
||||
|
||||
resource.AddService(serviceName, serviceVersion: version, serviceInstanceId: Environment.MachineName);
|
||||
resource.AddAttributes(new[]
|
||||
{
|
||||
new KeyValuePair<string, object>("deployment.environment", builder.Environment.EnvironmentName),
|
||||
});
|
||||
|
||||
foreach (var attribute in telemetry.ResourceAttributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attribute.Key) || attribute.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
resource.AddAttributes(new[] { new KeyValuePair<string, object>(attribute.Key, attribute.Value) });
|
||||
}
|
||||
});
|
||||
|
||||
if (telemetry.EnableTracing)
|
||||
{
|
||||
openTelemetry.WithTracing(tracing =>
|
||||
{
|
||||
tracing
|
||||
.AddSource(PolicyEngineTelemetry.ActivitySourceName)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation();
|
||||
|
||||
ConfigureTracingExporter(telemetry, tracing);
|
||||
});
|
||||
}
|
||||
|
||||
if (telemetry.EnableMetrics)
|
||||
{
|
||||
openTelemetry.WithMetrics(metrics =>
|
||||
{
|
||||
metrics
|
||||
.AddMeter(PolicyEngineTelemetry.MeterName)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
|
||||
ConfigureMetricsExporter(telemetry, metrics);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureSerilog(
|
||||
LoggerConfiguration configuration,
|
||||
PolicyEngineTelemetryOptions telemetry,
|
||||
string environmentName,
|
||||
string applicationName)
|
||||
{
|
||||
if (!Enum.TryParse(telemetry.MinimumLogLevel, ignoreCase: true, out LogEventLevel level))
|
||||
{
|
||||
level = LogEventLevel.Information;
|
||||
}
|
||||
|
||||
configuration
|
||||
.MinimumLevel.Is(level)
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.With<PolicyEngineActivityEnricher>()
|
||||
.Enrich.WithProperty("service.name", telemetry.ServiceName ?? applicationName)
|
||||
.Enrich.WithProperty("deployment.environment", environmentName)
|
||||
.WriteTo.Console(outputTemplate: "[{Timestamp:O}] [{Level:u3}] {Message:lj} {Properties}{NewLine}{Exception}");
|
||||
}
|
||||
|
||||
private static void ConfigureTracingExporter(PolicyEngineTelemetryOptions telemetry, TracerProviderBuilder tracing)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
if (telemetry.ExportConsole)
|
||||
{
|
||||
tracing.AddConsoleExporter();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
tracing.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
|
||||
var headers = BuildHeaders(telemetry);
|
||||
if (!string.IsNullOrEmpty(headers))
|
||||
{
|
||||
options.Headers = headers;
|
||||
}
|
||||
});
|
||||
|
||||
if (telemetry.ExportConsole)
|
||||
{
|
||||
tracing.AddConsoleExporter();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureMetricsExporter(PolicyEngineTelemetryOptions telemetry, MeterProviderBuilder metrics)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
if (telemetry.ExportConsole)
|
||||
{
|
||||
metrics.AddConsoleExporter();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
|
||||
var headers = BuildHeaders(telemetry);
|
||||
if (!string.IsNullOrEmpty(headers))
|
||||
{
|
||||
options.Headers = headers;
|
||||
}
|
||||
});
|
||||
|
||||
if (telemetry.ExportConsole)
|
||||
{
|
||||
metrics.AddConsoleExporter();
|
||||
}
|
||||
}
|
||||
|
||||
private static string? BuildHeaders(PolicyEngineTelemetryOptions telemetry)
|
||||
{
|
||||
if (telemetry.OtlpHeaders.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.Join(",", telemetry.OtlpHeaders
|
||||
.Where(static kvp => !string.IsNullOrWhiteSpace(kvp.Key) && !string.IsNullOrWhiteSpace(kvp.Value))
|
||||
.Select(static kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serilog enricher that adds activity context (trace_id, span_id) to log events.
|
||||
/// </summary>
|
||||
internal sealed class PolicyEngineActivityEnricher : ILogEventEnricher
|
||||
{
|
||||
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (activity.TraceId != default)
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("trace_id", activity.TraceId.ToString()));
|
||||
}
|
||||
|
||||
if (activity.SpanId != default)
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("span_id", activity.SpanId.ToString()));
|
||||
}
|
||||
|
||||
if (activity.ParentSpanId != default)
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("parent_span_id", activity.ParentSpanId.ToString()));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(activity.TraceStateString))
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("trace_state", activity.TraceStateString));
|
||||
}
|
||||
|
||||
// Add Policy Engine specific context if available
|
||||
var policyId = activity.GetTagItem("policy.id")?.ToString();
|
||||
if (!string.IsNullOrEmpty(policyId))
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("policy_id", policyId));
|
||||
}
|
||||
|
||||
var runId = activity.GetTagItem("run.id")?.ToString();
|
||||
if (!string.IsNullOrEmpty(runId))
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("run_id", runId));
|
||||
}
|
||||
|
||||
var tenant = activity.GetTagItem("tenant")?.ToString();
|
||||
if (!string.IsNullOrEmpty(tenant))
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("tenant", tenant));
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring Policy Engine telemetry.
|
||||
/// </summary>
|
||||
public static class TelemetryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures Policy Engine telemetry including metrics, traces, and structured logging.
|
||||
/// </summary>
|
||||
/// <param name="builder">The web application builder.</param>
|
||||
/// <param name="options">Policy engine options containing telemetry configuration.</param>
|
||||
public static void ConfigurePolicyEngineTelemetry(this WebApplicationBuilder builder, PolicyEngineOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var telemetry = options.Telemetry ?? new PolicyEngineTelemetryOptions();
|
||||
|
||||
if (telemetry.EnableLogging)
|
||||
{
|
||||
builder.Host.UseSerilog((context, services, configuration) =>
|
||||
{
|
||||
ConfigureSerilog(configuration, telemetry, builder.Environment.EnvironmentName, builder.Environment.ApplicationName);
|
||||
});
|
||||
}
|
||||
|
||||
if (!telemetry.Enabled || (!telemetry.EnableTracing && !telemetry.EnableMetrics))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var openTelemetry = builder.Services.AddOpenTelemetry();
|
||||
|
||||
openTelemetry.ConfigureResource(resource =>
|
||||
{
|
||||
var serviceName = telemetry.ServiceName ?? builder.Environment.ApplicationName;
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
||||
|
||||
resource.AddService(serviceName, serviceVersion: version, serviceInstanceId: Environment.MachineName);
|
||||
resource.AddAttributes(new[]
|
||||
{
|
||||
new KeyValuePair<string, object>("deployment.environment", builder.Environment.EnvironmentName),
|
||||
});
|
||||
|
||||
foreach (var attribute in telemetry.ResourceAttributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attribute.Key) || attribute.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
resource.AddAttributes(new[] { new KeyValuePair<string, object>(attribute.Key, attribute.Value) });
|
||||
}
|
||||
});
|
||||
|
||||
if (telemetry.EnableTracing)
|
||||
{
|
||||
openTelemetry.WithTracing(tracing =>
|
||||
{
|
||||
tracing
|
||||
.AddSource(PolicyEngineTelemetry.ActivitySourceName)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation();
|
||||
|
||||
ConfigureTracingExporter(telemetry, tracing);
|
||||
});
|
||||
}
|
||||
|
||||
if (telemetry.EnableMetrics)
|
||||
{
|
||||
openTelemetry.WithMetrics(metrics =>
|
||||
{
|
||||
metrics
|
||||
.AddMeter(PolicyEngineTelemetry.MeterName)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
|
||||
ConfigureMetricsExporter(telemetry, metrics);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureSerilog(
|
||||
LoggerConfiguration configuration,
|
||||
PolicyEngineTelemetryOptions telemetry,
|
||||
string environmentName,
|
||||
string applicationName)
|
||||
{
|
||||
if (!Enum.TryParse(telemetry.MinimumLogLevel, ignoreCase: true, out LogEventLevel level))
|
||||
{
|
||||
level = LogEventLevel.Information;
|
||||
}
|
||||
|
||||
configuration
|
||||
.MinimumLevel.Is(level)
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.With<PolicyEngineActivityEnricher>()
|
||||
.Enrich.WithProperty("service.name", telemetry.ServiceName ?? applicationName)
|
||||
.Enrich.WithProperty("deployment.environment", environmentName)
|
||||
.WriteTo.Console(outputTemplate: "[{Timestamp:O}] [{Level:u3}] {Message:lj} {Properties}{NewLine}{Exception}");
|
||||
}
|
||||
|
||||
private static void ConfigureTracingExporter(PolicyEngineTelemetryOptions telemetry, TracerProviderBuilder tracing)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
if (telemetry.ExportConsole)
|
||||
{
|
||||
tracing.AddConsoleExporter();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
tracing.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
|
||||
var headers = BuildHeaders(telemetry);
|
||||
if (!string.IsNullOrEmpty(headers))
|
||||
{
|
||||
options.Headers = headers;
|
||||
}
|
||||
});
|
||||
|
||||
if (telemetry.ExportConsole)
|
||||
{
|
||||
tracing.AddConsoleExporter();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureMetricsExporter(PolicyEngineTelemetryOptions telemetry, MeterProviderBuilder metrics)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
if (telemetry.ExportConsole)
|
||||
{
|
||||
metrics.AddConsoleExporter();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
|
||||
var headers = BuildHeaders(telemetry);
|
||||
if (!string.IsNullOrEmpty(headers))
|
||||
{
|
||||
options.Headers = headers;
|
||||
}
|
||||
});
|
||||
|
||||
if (telemetry.ExportConsole)
|
||||
{
|
||||
metrics.AddConsoleExporter();
|
||||
}
|
||||
}
|
||||
|
||||
private static string? BuildHeaders(PolicyEngineTelemetryOptions telemetry)
|
||||
{
|
||||
if (telemetry.OtlpHeaders.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.Join(",", telemetry.OtlpHeaders
|
||||
.Where(static kvp => !string.IsNullOrWhiteSpace(kvp.Key) && !string.IsNullOrWhiteSpace(kvp.Value))
|
||||
.Select(static kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serilog enricher that adds activity context (trace_id, span_id) to log events.
|
||||
/// </summary>
|
||||
internal sealed class PolicyEngineActivityEnricher : ILogEventEnricher
|
||||
{
|
||||
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (activity.TraceId != default)
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("trace_id", activity.TraceId.ToString()));
|
||||
}
|
||||
|
||||
if (activity.SpanId != default)
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("span_id", activity.SpanId.ToString()));
|
||||
}
|
||||
|
||||
if (activity.ParentSpanId != default)
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("parent_span_id", activity.ParentSpanId.ToString()));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(activity.TraceStateString))
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("trace_state", activity.TraceStateString));
|
||||
}
|
||||
|
||||
// Add Policy Engine specific context if available
|
||||
var policyId = activity.GetTagItem("policy.id")?.ToString();
|
||||
if (!string.IsNullOrEmpty(policyId))
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("policy_id", policyId));
|
||||
}
|
||||
|
||||
var runId = activity.GetTagItem("run.id")?.ToString();
|
||||
if (!string.IsNullOrEmpty(runId))
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("run_id", runId));
|
||||
}
|
||||
|
||||
var tenant = activity.GetTagItem("tenant")?.ToString();
|
||||
if (!string.IsNullOrEmpty(tenant))
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("tenant", tenant));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Hosting;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Workers;
|
||||
|
||||
internal sealed class PolicyEngineBootstrapWorker : BackgroundService
|
||||
{
|
||||
private readonly ILogger<PolicyEngineBootstrapWorker> logger;
|
||||
private readonly PolicyEngineStartupDiagnostics diagnostics;
|
||||
private readonly PolicyEngineOptions options;
|
||||
private readonly RiskProfileConfigurationService riskProfileService;
|
||||
|
||||
public PolicyEngineBootstrapWorker(
|
||||
ILogger<PolicyEngineBootstrapWorker> logger,
|
||||
PolicyEngineStartupDiagnostics diagnostics,
|
||||
PolicyEngineOptions options,
|
||||
RiskProfileConfigurationService riskProfileService)
|
||||
{
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.riskProfileService = riskProfileService ?? throw new ArgumentNullException(nameof(riskProfileService));
|
||||
}
|
||||
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Policy Engine bootstrap worker started. Authority issuer: {AuthorityIssuer}. Storage: PostgreSQL (configured via Postgres:Policy).",
|
||||
options.Authority.Issuer);
|
||||
|
||||
if (options.RiskProfile.Enabled)
|
||||
{
|
||||
riskProfileService.LoadProfiles();
|
||||
logger.LogInformation(
|
||||
"Risk profile integration enabled. Default profile: {DefaultProfileId}. Loaded profiles: {ProfileCount}.",
|
||||
riskProfileService.DefaultProfileId,
|
||||
riskProfileService.GetProfileIds().Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Risk profile integration is disabled.");
|
||||
}
|
||||
|
||||
diagnostics.MarkReady();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Hosting;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Workers;
|
||||
|
||||
internal sealed class PolicyEngineBootstrapWorker : BackgroundService
|
||||
{
|
||||
private readonly ILogger<PolicyEngineBootstrapWorker> logger;
|
||||
private readonly PolicyEngineStartupDiagnostics diagnostics;
|
||||
private readonly PolicyEngineOptions options;
|
||||
private readonly RiskProfileConfigurationService riskProfileService;
|
||||
|
||||
public PolicyEngineBootstrapWorker(
|
||||
ILogger<PolicyEngineBootstrapWorker> logger,
|
||||
PolicyEngineStartupDiagnostics diagnostics,
|
||||
PolicyEngineOptions options,
|
||||
RiskProfileConfigurationService riskProfileService)
|
||||
{
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.riskProfileService = riskProfileService ?? throw new ArgumentNullException(nameof(riskProfileService));
|
||||
}
|
||||
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Policy Engine bootstrap worker started. Authority issuer: {AuthorityIssuer}. Storage: PostgreSQL (configured via Postgres:Policy).",
|
||||
options.Authority.Issuer);
|
||||
|
||||
if (options.RiskProfile.Enabled)
|
||||
{
|
||||
riskProfileService.LoadProfiles();
|
||||
logger.LogInformation(
|
||||
"Risk profile integration enabled. Default profile: {DefaultProfileId}. Loaded profiles: {ProfileCount}.",
|
||||
riskProfileService.DefaultProfileId,
|
||||
riskProfileService.GetProfileIds().Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Risk profile integration is disabled.");
|
||||
}
|
||||
|
||||
diagnostics.MarkReady();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user