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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ namespace StellaOps.Policy.Gateway.Clients;
|
||||
|
||||
internal interface IPolicyEngineClient
|
||||
{
|
||||
Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(GatewayForwardingContext? forwardingContext, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(GatewayForwardingContext? forwardingContext, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(GatewayForwardingContext? forwardingContext, CreatePolicyPackRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(GatewayForwardingContext? forwardingContext, string packId, CreatePolicyRevisionRequest request, CancellationToken cancellationToken);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -14,79 +14,79 @@ using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Clients;
|
||||
|
||||
internal sealed class PolicyEngineClient : IPolicyEngineClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly PolicyEngineTokenProvider tokenProvider;
|
||||
private readonly ILogger<PolicyEngineClient> logger;
|
||||
private readonly PolicyGatewayOptions options;
|
||||
|
||||
public PolicyEngineClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<PolicyGatewayOptions> options,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
ILogger<PolicyEngineClient> logger)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
this.options = options.Value ?? throw new InvalidOperationException("Policy Gateway options must be configured.");
|
||||
if (httpClient.BaseAddress is null)
|
||||
{
|
||||
httpClient.BaseAddress = this.options.PolicyEngine.BaseUri;
|
||||
}
|
||||
|
||||
httpClient.DefaultRequestHeaders.Accept.Clear();
|
||||
httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
}
|
||||
|
||||
public Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
CancellationToken cancellationToken)
|
||||
=> SendAsync<IReadOnlyList<PolicyPackSummaryDto>>(
|
||||
HttpMethod.Get,
|
||||
"api/policy/packs",
|
||||
forwardingContext,
|
||||
content: null,
|
||||
cancellationToken);
|
||||
|
||||
public Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
CreatePolicyPackRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
=> SendAsync<PolicyPackDto>(
|
||||
HttpMethod.Post,
|
||||
"api/policy/packs",
|
||||
forwardingContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
public Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
string packId,
|
||||
CreatePolicyRevisionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
=> SendAsync<PolicyRevisionDto>(
|
||||
HttpMethod.Post,
|
||||
$"api/policy/packs/{Uri.EscapeDataString(packId)}/revisions",
|
||||
forwardingContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Clients;
|
||||
|
||||
internal sealed class PolicyEngineClient : IPolicyEngineClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly PolicyEngineTokenProvider tokenProvider;
|
||||
private readonly ILogger<PolicyEngineClient> logger;
|
||||
private readonly PolicyGatewayOptions options;
|
||||
|
||||
public PolicyEngineClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<PolicyGatewayOptions> options,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
ILogger<PolicyEngineClient> logger)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
this.options = options.Value ?? throw new InvalidOperationException("Policy Gateway options must be configured.");
|
||||
if (httpClient.BaseAddress is null)
|
||||
{
|
||||
httpClient.BaseAddress = this.options.PolicyEngine.BaseUri;
|
||||
}
|
||||
|
||||
httpClient.DefaultRequestHeaders.Accept.Clear();
|
||||
httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
}
|
||||
|
||||
public Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
CancellationToken cancellationToken)
|
||||
=> SendAsync<IReadOnlyList<PolicyPackSummaryDto>>(
|
||||
HttpMethod.Get,
|
||||
"api/policy/packs",
|
||||
forwardingContext,
|
||||
content: null,
|
||||
cancellationToken);
|
||||
|
||||
public Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
CreatePolicyPackRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
=> SendAsync<PolicyPackDto>(
|
||||
HttpMethod.Post,
|
||||
"api/policy/packs",
|
||||
forwardingContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
public Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
string packId,
|
||||
CreatePolicyRevisionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
=> SendAsync<PolicyRevisionDto>(
|
||||
HttpMethod.Post,
|
||||
$"api/policy/packs/{Uri.EscapeDataString(packId)}/revisions",
|
||||
forwardingContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
public Task<PolicyEngineResponse<PolicyRevisionActivationDto>> ActivatePolicyRevisionAsync(
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
string packId,
|
||||
@@ -154,103 +154,103 @@ internal sealed class PolicyEngineClient : IPolicyEngineClient
|
||||
forwardingContext,
|
||||
content: null,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<PolicyEngineResponse<TSuccess>> SendAsync<TSuccess>(
|
||||
HttpMethod method,
|
||||
string relativeUri,
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
object? content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var absoluteUri = httpClient.BaseAddress is not null
|
||||
? new Uri(httpClient.BaseAddress, relativeUri)
|
||||
: new Uri(relativeUri, UriKind.Absolute);
|
||||
|
||||
using var request = new HttpRequestMessage(method, absoluteUri);
|
||||
|
||||
if (forwardingContext is not null)
|
||||
{
|
||||
forwardingContext.Apply(request);
|
||||
}
|
||||
else
|
||||
{
|
||||
var serviceAuthorization = await tokenProvider.GetAuthorizationAsync(method, absoluteUri, cancellationToken).ConfigureAwait(false);
|
||||
if (serviceAuthorization is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Policy Engine request {Method} {Uri} lacks caller credentials and client credentials flow is disabled.",
|
||||
method,
|
||||
absoluteUri);
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = "Upstream authorization missing",
|
||||
Detail = "Caller did not present credentials and client credentials flow is disabled.",
|
||||
Status = StatusCodes.Status401Unauthorized
|
||||
};
|
||||
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.Unauthorized, problem);
|
||||
}
|
||||
|
||||
var authorization = serviceAuthorization.Value;
|
||||
authorization.Apply(request);
|
||||
}
|
||||
|
||||
if (content is not null)
|
||||
{
|
||||
request.Content = JsonContent.Create(content, options: SerializerOptions);
|
||||
}
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
if (response.Content is null || response.Content.Headers.ContentLength == 0)
|
||||
{
|
||||
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, value: default, location);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var successValue = await response.Content.ReadFromJsonAsync<TSuccess>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, successValue, location);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to deserialize Policy Engine response for {Path}.", relativeUri);
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = "Invalid upstream response",
|
||||
Detail = "Policy Engine returned an unexpected payload.",
|
||||
Status = StatusCodes.Status502BadGateway
|
||||
};
|
||||
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.BadGateway, problem);
|
||||
}
|
||||
}
|
||||
|
||||
var problemDetails = await ReadProblemDetailsAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
return PolicyEngineResponse<TSuccess>.Failure(response.StatusCode, problemDetails);
|
||||
}
|
||||
|
||||
private async Task<ProblemDetails?> ReadProblemDetailsAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.Content is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Policy Engine returned non-ProblemDetails error response for {StatusCode}.", (int)response.StatusCode);
|
||||
return new ProblemDetails
|
||||
{
|
||||
Title = "Upstream error",
|
||||
Detail = $"Policy Engine responded with {(int)response.StatusCode} {response.ReasonPhrase}.",
|
||||
Status = (int)response.StatusCode
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PolicyEngineResponse<TSuccess>> SendAsync<TSuccess>(
|
||||
HttpMethod method,
|
||||
string relativeUri,
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
object? content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var absoluteUri = httpClient.BaseAddress is not null
|
||||
? new Uri(httpClient.BaseAddress, relativeUri)
|
||||
: new Uri(relativeUri, UriKind.Absolute);
|
||||
|
||||
using var request = new HttpRequestMessage(method, absoluteUri);
|
||||
|
||||
if (forwardingContext is not null)
|
||||
{
|
||||
forwardingContext.Apply(request);
|
||||
}
|
||||
else
|
||||
{
|
||||
var serviceAuthorization = await tokenProvider.GetAuthorizationAsync(method, absoluteUri, cancellationToken).ConfigureAwait(false);
|
||||
if (serviceAuthorization is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Policy Engine request {Method} {Uri} lacks caller credentials and client credentials flow is disabled.",
|
||||
method,
|
||||
absoluteUri);
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = "Upstream authorization missing",
|
||||
Detail = "Caller did not present credentials and client credentials flow is disabled.",
|
||||
Status = StatusCodes.Status401Unauthorized
|
||||
};
|
||||
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.Unauthorized, problem);
|
||||
}
|
||||
|
||||
var authorization = serviceAuthorization.Value;
|
||||
authorization.Apply(request);
|
||||
}
|
||||
|
||||
if (content is not null)
|
||||
{
|
||||
request.Content = JsonContent.Create(content, options: SerializerOptions);
|
||||
}
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
if (response.Content is null || response.Content.Headers.ContentLength == 0)
|
||||
{
|
||||
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, value: default, location);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var successValue = await response.Content.ReadFromJsonAsync<TSuccess>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, successValue, location);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to deserialize Policy Engine response for {Path}.", relativeUri);
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = "Invalid upstream response",
|
||||
Detail = "Policy Engine returned an unexpected payload.",
|
||||
Status = StatusCodes.Status502BadGateway
|
||||
};
|
||||
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.BadGateway, problem);
|
||||
}
|
||||
}
|
||||
|
||||
var problemDetails = await ReadProblemDetailsAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
return PolicyEngineResponse<TSuccess>.Failure(response.StatusCode, problemDetails);
|
||||
}
|
||||
|
||||
private async Task<ProblemDetails?> ReadProblemDetailsAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.Content is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Policy Engine returned non-ProblemDetails error response for {StatusCode}.", (int)response.StatusCode);
|
||||
return new ProblemDetails
|
||||
{
|
||||
Title = "Upstream error",
|
||||
Detail = $"Policy Engine responded with {(int)response.StatusCode} {response.ReasonPhrase}.",
|
||||
Status = (int)response.StatusCode
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Clients;
|
||||
|
||||
internal sealed class PolicyEngineResponse<TSuccess>
|
||||
{
|
||||
private PolicyEngineResponse(HttpStatusCode statusCode, TSuccess? value, ProblemDetails? problem, string? location)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
Value = value;
|
||||
Problem = problem;
|
||||
Location = location;
|
||||
}
|
||||
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
|
||||
public TSuccess? Value { get; }
|
||||
|
||||
public ProblemDetails? Problem { get; }
|
||||
|
||||
public string? Location { get; }
|
||||
|
||||
public bool IsSuccess => Problem is null && StatusCode is >= HttpStatusCode.OK and < HttpStatusCode.MultipleChoices;
|
||||
|
||||
public static PolicyEngineResponse<TSuccess> Success(HttpStatusCode statusCode, TSuccess? value, string? location)
|
||||
=> new(statusCode, value, problem: null, location);
|
||||
|
||||
public static PolicyEngineResponse<TSuccess> Failure(HttpStatusCode statusCode, ProblemDetails? problem)
|
||||
=> new(statusCode, value: default, problem, location: null);
|
||||
}
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Clients;
|
||||
|
||||
internal sealed class PolicyEngineResponse<TSuccess>
|
||||
{
|
||||
private PolicyEngineResponse(HttpStatusCode statusCode, TSuccess? value, ProblemDetails? problem, string? location)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
Value = value;
|
||||
Problem = problem;
|
||||
Location = location;
|
||||
}
|
||||
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
|
||||
public TSuccess? Value { get; }
|
||||
|
||||
public ProblemDetails? Problem { get; }
|
||||
|
||||
public string? Location { get; }
|
||||
|
||||
public bool IsSuccess => Problem is null && StatusCode is >= HttpStatusCode.OK and < HttpStatusCode.MultipleChoices;
|
||||
|
||||
public static PolicyEngineResponse<TSuccess> Success(HttpStatusCode statusCode, TSuccess? value, string? location)
|
||||
=> new(statusCode, value, problem: null, location);
|
||||
|
||||
public static PolicyEngineResponse<TSuccess> Failure(HttpStatusCode statusCode, ProblemDetails? problem)
|
||||
=> new(statusCode, value: default, problem, location: null);
|
||||
}
|
||||
|
||||
@@ -1,71 +1,71 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Clients;
|
||||
|
||||
internal static class PolicyEngineResponseExtensions
|
||||
{
|
||||
public static IResult ToMinimalResult<T>(this PolicyEngineResponse<T> response)
|
||||
{
|
||||
if (response is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(response));
|
||||
}
|
||||
|
||||
if (response.IsSuccess)
|
||||
{
|
||||
return CreateSuccessResult(response);
|
||||
}
|
||||
|
||||
return CreateErrorResult(response);
|
||||
}
|
||||
|
||||
private static IResult CreateSuccessResult<T>(PolicyEngineResponse<T> response)
|
||||
{
|
||||
var value = response.Value;
|
||||
switch (response.StatusCode)
|
||||
{
|
||||
case HttpStatusCode.Created:
|
||||
if (!string.IsNullOrWhiteSpace(response.Location))
|
||||
{
|
||||
return Results.Created(response.Location, value);
|
||||
}
|
||||
|
||||
return Results.Json(value, statusCode: StatusCodes.Status201Created);
|
||||
|
||||
case HttpStatusCode.Accepted:
|
||||
if (!string.IsNullOrWhiteSpace(response.Location))
|
||||
{
|
||||
return Results.Accepted(response.Location, value);
|
||||
}
|
||||
|
||||
return Results.Json(value, statusCode: StatusCodes.Status202Accepted);
|
||||
|
||||
case HttpStatusCode.NoContent:
|
||||
return Results.NoContent();
|
||||
|
||||
default:
|
||||
return Results.Json(value, statusCode: (int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CreateErrorResult<T>(PolicyEngineResponse<T> response)
|
||||
{
|
||||
var problem = response.Problem;
|
||||
if (problem is null)
|
||||
{
|
||||
return Results.StatusCode((int)response.StatusCode);
|
||||
}
|
||||
|
||||
var statusCode = problem.Status ?? (int)response.StatusCode;
|
||||
return Results.Problem(
|
||||
title: problem.Title,
|
||||
detail: problem.Detail,
|
||||
type: problem.Type,
|
||||
instance: problem.Instance,
|
||||
statusCode: statusCode,
|
||||
extensions: problem.Extensions);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Clients;
|
||||
|
||||
internal static class PolicyEngineResponseExtensions
|
||||
{
|
||||
public static IResult ToMinimalResult<T>(this PolicyEngineResponse<T> response)
|
||||
{
|
||||
if (response is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(response));
|
||||
}
|
||||
|
||||
if (response.IsSuccess)
|
||||
{
|
||||
return CreateSuccessResult(response);
|
||||
}
|
||||
|
||||
return CreateErrorResult(response);
|
||||
}
|
||||
|
||||
private static IResult CreateSuccessResult<T>(PolicyEngineResponse<T> response)
|
||||
{
|
||||
var value = response.Value;
|
||||
switch (response.StatusCode)
|
||||
{
|
||||
case HttpStatusCode.Created:
|
||||
if (!string.IsNullOrWhiteSpace(response.Location))
|
||||
{
|
||||
return Results.Created(response.Location, value);
|
||||
}
|
||||
|
||||
return Results.Json(value, statusCode: StatusCodes.Status201Created);
|
||||
|
||||
case HttpStatusCode.Accepted:
|
||||
if (!string.IsNullOrWhiteSpace(response.Location))
|
||||
{
|
||||
return Results.Accepted(response.Location, value);
|
||||
}
|
||||
|
||||
return Results.Json(value, statusCode: StatusCodes.Status202Accepted);
|
||||
|
||||
case HttpStatusCode.NoContent:
|
||||
return Results.NoContent();
|
||||
|
||||
default:
|
||||
return Results.Json(value, statusCode: (int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CreateErrorResult<T>(PolicyEngineResponse<T> response)
|
||||
{
|
||||
var problem = response.Problem;
|
||||
if (problem is null)
|
||||
{
|
||||
return Results.StatusCode((int)response.StatusCode);
|
||||
}
|
||||
|
||||
var statusCode = problem.Status ?? (int)response.StatusCode;
|
||||
return Results.Problem(
|
||||
title: problem.Title,
|
||||
detail: problem.Detail,
|
||||
type: problem.Type,
|
||||
instance: problem.Instance,
|
||||
statusCode: statusCode,
|
||||
extensions: problem.Extensions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Infrastructure;
|
||||
|
||||
internal sealed record GatewayForwardingContext(string Authorization, string? Dpop, string? Tenant)
|
||||
{
|
||||
private static readonly string[] ForwardedHeaders =
|
||||
{
|
||||
"Authorization",
|
||||
"DPoP",
|
||||
"X-Stella-Tenant"
|
||||
};
|
||||
|
||||
public void Apply(HttpRequestMessage request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
request.Headers.TryAddWithoutValidation(ForwardedHeaders[0], Authorization);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Dpop))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(ForwardedHeaders[1], Dpop);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Tenant))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(ForwardedHeaders[2], Tenant);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryCreate(HttpContext context, out GatewayForwardingContext forwardingContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var authorization = context.Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrWhiteSpace(authorization))
|
||||
{
|
||||
forwardingContext = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
var dpop = context.Request.Headers["DPoP"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(dpop))
|
||||
{
|
||||
dpop = null;
|
||||
}
|
||||
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
tenant = null;
|
||||
}
|
||||
|
||||
forwardingContext = new GatewayForwardingContext(authorization.Trim(), dpop, tenant);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Infrastructure;
|
||||
|
||||
internal sealed record GatewayForwardingContext(string Authorization, string? Dpop, string? Tenant)
|
||||
{
|
||||
private static readonly string[] ForwardedHeaders =
|
||||
{
|
||||
"Authorization",
|
||||
"DPoP",
|
||||
"X-Stella-Tenant"
|
||||
};
|
||||
|
||||
public void Apply(HttpRequestMessage request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
request.Headers.TryAddWithoutValidation(ForwardedHeaders[0], Authorization);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Dpop))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(ForwardedHeaders[1], Dpop);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Tenant))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(ForwardedHeaders[2], Tenant);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryCreate(HttpContext context, out GatewayForwardingContext forwardingContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var authorization = context.Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrWhiteSpace(authorization))
|
||||
{
|
||||
forwardingContext = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
var dpop = context.Request.Headers["DPoP"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(dpop))
|
||||
{
|
||||
dpop = null;
|
||||
}
|
||||
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
tenant = null;
|
||||
}
|
||||
|
||||
forwardingContext = new GatewayForwardingContext(authorization.Trim(), dpop, tenant);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,323 +1,323 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration for the Policy Gateway host.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayOptions
|
||||
{
|
||||
public const string SectionName = "PolicyGateway";
|
||||
|
||||
public PolicyGatewayTelemetryOptions Telemetry { get; } = new();
|
||||
|
||||
public PolicyGatewayResourceServerOptions ResourceServer { get; } = new();
|
||||
|
||||
public PolicyGatewayPolicyEngineOptions PolicyEngine { get; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
Telemetry.Validate();
|
||||
ResourceServer.Validate();
|
||||
PolicyEngine.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logging and telemetry configuration for the gateway.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayTelemetryOptions
|
||||
{
|
||||
public LogLevel MinimumLogLevel { get; set; } = LogLevel.Information;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enum.IsDefined(typeof(LogLevel), MinimumLogLevel))
|
||||
{
|
||||
throw new InvalidOperationException("Unsupported log level configured for Policy Gateway telemetry.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JWT resource server configuration for incoming requests handled by the gateway.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayResourceServerOptions
|
||||
{
|
||||
public string Authority { get; set; } = "https://authority.stella-ops.local";
|
||||
|
||||
public string? MetadataAddress { get; set; }
|
||||
= "https://authority.stella-ops.local/.well-known/openid-configuration";
|
||||
|
||||
public IList<string> Audiences { get; } = new List<string> { "api://policy-gateway" };
|
||||
|
||||
public IList<string> RequiredScopes { get; } = new List<string>
|
||||
{
|
||||
StellaOpsScopes.PolicyRead,
|
||||
StellaOpsScopes.PolicyAuthor,
|
||||
StellaOpsScopes.PolicyReview,
|
||||
StellaOpsScopes.PolicyApprove,
|
||||
StellaOpsScopes.PolicyOperate,
|
||||
StellaOpsScopes.PolicySimulate,
|
||||
StellaOpsScopes.PolicyRun,
|
||||
StellaOpsScopes.PolicyActivate
|
||||
};
|
||||
|
||||
public IList<string> RequiredTenants { get; } = new List<string>();
|
||||
|
||||
public IList<string> BypassNetworks { get; } = new List<string> { "127.0.0.1/32", "::1/128" };
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public int TokenClockSkewSeconds { get; set; } = 60;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Authority))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server configuration requires an Authority URL.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server Authority URL must be absolute.");
|
||||
}
|
||||
|
||||
if (RequireHttpsMetadata &&
|
||||
!authorityUri.IsLoopback &&
|
||||
!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server Authority URL must use HTTPS when metadata requires HTTPS.");
|
||||
}
|
||||
|
||||
if (BackchannelTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server back-channel timeout must be greater than zero seconds.");
|
||||
}
|
||||
|
||||
if (TokenClockSkewSeconds < 0 || TokenClockSkewSeconds > 300)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server token clock skew must be between 0 and 300 seconds.");
|
||||
}
|
||||
|
||||
NormalizeList(Audiences, toLower: false);
|
||||
NormalizeList(RequiredScopes, toLower: true);
|
||||
NormalizeList(RequiredTenants, toLower: true);
|
||||
NormalizeList(BypassNetworks, toLower: false);
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values, bool toLower)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var unique = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = values.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var value = values[index];
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
if (toLower)
|
||||
{
|
||||
normalized = normalized.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!unique.Add(normalized))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
values[index] = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outbound Policy Engine configuration used by the gateway to forward requests.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayPolicyEngineOptions
|
||||
{
|
||||
public string BaseAddress { get; set; } = "https://policy-engine.stella-ops.local";
|
||||
|
||||
public string Audience { get; set; } = "api://policy-engine";
|
||||
|
||||
public PolicyGatewayClientCredentialsOptions ClientCredentials { get; } = new();
|
||||
|
||||
public PolicyGatewayDpopOptions Dpop { get; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(BaseAddress))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway requires a Policy Engine base address.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(BaseAddress.Trim(), UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway Policy Engine base address must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (!string.Equals(baseUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && !baseUri.IsLoopback)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway Policy Engine base address must use HTTPS unless targeting loopback.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Audience))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway requires a Policy Engine audience value for client credential flows.");
|
||||
}
|
||||
|
||||
ClientCredentials.Validate();
|
||||
Dpop.Validate();
|
||||
}
|
||||
|
||||
public Uri BaseUri => new(BaseAddress, UriKind.Absolute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client credential configuration for the gateway when calling the Policy Engine.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayClientCredentialsOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string ClientId { get; set; } = "policy-gateway";
|
||||
|
||||
public string? ClientSecret { get; set; }
|
||||
= "change-me";
|
||||
|
||||
public IList<string> Scopes { get; } = new List<string>
|
||||
{
|
||||
StellaOpsScopes.PolicyRead,
|
||||
StellaOpsScopes.PolicyAuthor,
|
||||
StellaOpsScopes.PolicyReview,
|
||||
StellaOpsScopes.PolicyApprove,
|
||||
StellaOpsScopes.PolicyOperate,
|
||||
StellaOpsScopes.PolicySimulate,
|
||||
StellaOpsScopes.PolicyRun,
|
||||
StellaOpsScopes.PolicyActivate
|
||||
};
|
||||
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway client credential configuration requires a client identifier when enabled.");
|
||||
}
|
||||
|
||||
if (Scopes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one scope when enabled.");
|
||||
}
|
||||
|
||||
var normalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = Scopes.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var scope = Scopes[index];
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
Scopes.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = scope.Trim().ToLowerInvariant();
|
||||
if (!normalized.Add(trimmed))
|
||||
{
|
||||
Scopes.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
Scopes[index] = trimmed;
|
||||
}
|
||||
|
||||
if (Scopes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one non-empty scope when enabled.");
|
||||
}
|
||||
|
||||
if (BackchannelTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway client credential back-channel timeout must be greater than zero seconds.");
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> NormalizedScopes => new ReadOnlyCollection<string>(Scopes);
|
||||
|
||||
public TimeSpan BackchannelTimeout => TimeSpan.FromSeconds(BackchannelTimeoutSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DPoP sender-constrained credential configuration for outbound Policy Engine calls.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayDpopOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
public string KeyPath { get; set; } = string.Empty;
|
||||
|
||||
public string? KeyPassphrase { get; set; }
|
||||
= null;
|
||||
|
||||
public string Algorithm { get; set; } = "ES256";
|
||||
|
||||
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
public TimeSpan ClockSkew { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(KeyPath))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP configuration requires a key path when enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Algorithm))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP configuration requires an algorithm when enabled.");
|
||||
}
|
||||
|
||||
var normalizedAlgorithm = Algorithm.Trim().ToUpperInvariant();
|
||||
if (normalizedAlgorithm is not ("ES256" or "ES384"))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP configuration supports only ES256 or ES384 algorithms.");
|
||||
}
|
||||
|
||||
if (ProofLifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP proof lifetime must be greater than zero.");
|
||||
}
|
||||
|
||||
if (ClockSkew < TimeSpan.Zero || ClockSkew > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP clock skew must be between 0 seconds and 5 minutes.");
|
||||
}
|
||||
|
||||
Algorithm = normalizedAlgorithm;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration for the Policy Gateway host.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayOptions
|
||||
{
|
||||
public const string SectionName = "PolicyGateway";
|
||||
|
||||
public PolicyGatewayTelemetryOptions Telemetry { get; } = new();
|
||||
|
||||
public PolicyGatewayResourceServerOptions ResourceServer { get; } = new();
|
||||
|
||||
public PolicyGatewayPolicyEngineOptions PolicyEngine { get; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
Telemetry.Validate();
|
||||
ResourceServer.Validate();
|
||||
PolicyEngine.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logging and telemetry configuration for the gateway.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayTelemetryOptions
|
||||
{
|
||||
public LogLevel MinimumLogLevel { get; set; } = LogLevel.Information;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enum.IsDefined(typeof(LogLevel), MinimumLogLevel))
|
||||
{
|
||||
throw new InvalidOperationException("Unsupported log level configured for Policy Gateway telemetry.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JWT resource server configuration for incoming requests handled by the gateway.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayResourceServerOptions
|
||||
{
|
||||
public string Authority { get; set; } = "https://authority.stella-ops.local";
|
||||
|
||||
public string? MetadataAddress { get; set; }
|
||||
= "https://authority.stella-ops.local/.well-known/openid-configuration";
|
||||
|
||||
public IList<string> Audiences { get; } = new List<string> { "api://policy-gateway" };
|
||||
|
||||
public IList<string> RequiredScopes { get; } = new List<string>
|
||||
{
|
||||
StellaOpsScopes.PolicyRead,
|
||||
StellaOpsScopes.PolicyAuthor,
|
||||
StellaOpsScopes.PolicyReview,
|
||||
StellaOpsScopes.PolicyApprove,
|
||||
StellaOpsScopes.PolicyOperate,
|
||||
StellaOpsScopes.PolicySimulate,
|
||||
StellaOpsScopes.PolicyRun,
|
||||
StellaOpsScopes.PolicyActivate
|
||||
};
|
||||
|
||||
public IList<string> RequiredTenants { get; } = new List<string>();
|
||||
|
||||
public IList<string> BypassNetworks { get; } = new List<string> { "127.0.0.1/32", "::1/128" };
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public int TokenClockSkewSeconds { get; set; } = 60;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Authority))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server configuration requires an Authority URL.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server Authority URL must be absolute.");
|
||||
}
|
||||
|
||||
if (RequireHttpsMetadata &&
|
||||
!authorityUri.IsLoopback &&
|
||||
!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server Authority URL must use HTTPS when metadata requires HTTPS.");
|
||||
}
|
||||
|
||||
if (BackchannelTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server back-channel timeout must be greater than zero seconds.");
|
||||
}
|
||||
|
||||
if (TokenClockSkewSeconds < 0 || TokenClockSkewSeconds > 300)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server token clock skew must be between 0 and 300 seconds.");
|
||||
}
|
||||
|
||||
NormalizeList(Audiences, toLower: false);
|
||||
NormalizeList(RequiredScopes, toLower: true);
|
||||
NormalizeList(RequiredTenants, toLower: true);
|
||||
NormalizeList(BypassNetworks, toLower: false);
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values, bool toLower)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var unique = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = values.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var value = values[index];
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
if (toLower)
|
||||
{
|
||||
normalized = normalized.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!unique.Add(normalized))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
values[index] = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outbound Policy Engine configuration used by the gateway to forward requests.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayPolicyEngineOptions
|
||||
{
|
||||
public string BaseAddress { get; set; } = "https://policy-engine.stella-ops.local";
|
||||
|
||||
public string Audience { get; set; } = "api://policy-engine";
|
||||
|
||||
public PolicyGatewayClientCredentialsOptions ClientCredentials { get; } = new();
|
||||
|
||||
public PolicyGatewayDpopOptions Dpop { get; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(BaseAddress))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway requires a Policy Engine base address.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(BaseAddress.Trim(), UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway Policy Engine base address must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (!string.Equals(baseUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && !baseUri.IsLoopback)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway Policy Engine base address must use HTTPS unless targeting loopback.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Audience))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway requires a Policy Engine audience value for client credential flows.");
|
||||
}
|
||||
|
||||
ClientCredentials.Validate();
|
||||
Dpop.Validate();
|
||||
}
|
||||
|
||||
public Uri BaseUri => new(BaseAddress, UriKind.Absolute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client credential configuration for the gateway when calling the Policy Engine.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayClientCredentialsOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string ClientId { get; set; } = "policy-gateway";
|
||||
|
||||
public string? ClientSecret { get; set; }
|
||||
= "change-me";
|
||||
|
||||
public IList<string> Scopes { get; } = new List<string>
|
||||
{
|
||||
StellaOpsScopes.PolicyRead,
|
||||
StellaOpsScopes.PolicyAuthor,
|
||||
StellaOpsScopes.PolicyReview,
|
||||
StellaOpsScopes.PolicyApprove,
|
||||
StellaOpsScopes.PolicyOperate,
|
||||
StellaOpsScopes.PolicySimulate,
|
||||
StellaOpsScopes.PolicyRun,
|
||||
StellaOpsScopes.PolicyActivate
|
||||
};
|
||||
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway client credential configuration requires a client identifier when enabled.");
|
||||
}
|
||||
|
||||
if (Scopes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one scope when enabled.");
|
||||
}
|
||||
|
||||
var normalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = Scopes.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var scope = Scopes[index];
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
Scopes.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = scope.Trim().ToLowerInvariant();
|
||||
if (!normalized.Add(trimmed))
|
||||
{
|
||||
Scopes.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
Scopes[index] = trimmed;
|
||||
}
|
||||
|
||||
if (Scopes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one non-empty scope when enabled.");
|
||||
}
|
||||
|
||||
if (BackchannelTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway client credential back-channel timeout must be greater than zero seconds.");
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> NormalizedScopes => new ReadOnlyCollection<string>(Scopes);
|
||||
|
||||
public TimeSpan BackchannelTimeout => TimeSpan.FromSeconds(BackchannelTimeoutSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DPoP sender-constrained credential configuration for outbound Policy Engine calls.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayDpopOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
public string KeyPath { get; set; } = string.Empty;
|
||||
|
||||
public string? KeyPassphrase { get; set; }
|
||||
= null;
|
||||
|
||||
public string Algorithm { get; set; } = "ES256";
|
||||
|
||||
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
public TimeSpan ClockSkew { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(KeyPath))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP configuration requires a key path when enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Algorithm))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP configuration requires an algorithm when enabled.");
|
||||
}
|
||||
|
||||
var normalizedAlgorithm = Algorithm.Trim().ToUpperInvariant();
|
||||
if (normalizedAlgorithm is not ("ES256" or "ES384"))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP configuration supports only ES256 or ES384 algorithms.");
|
||||
}
|
||||
|
||||
if (ProofLifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP proof lifetime must be greater than zero.");
|
||||
}
|
||||
|
||||
if (ClockSkew < TimeSpan.Zero || ClockSkew > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP clock skew must be between 0 seconds and 5 minutes.");
|
||||
}
|
||||
|
||||
Algorithm = normalizedAlgorithm;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,155 +1,155 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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.Gateway.Clients;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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.Gateway.Clients;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.Policy.Gateway.Infrastructure;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddJsonConsole();
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
var contentRoot = builder.Environment.ContentRootPath;
|
||||
foreach (var relative in new[]
|
||||
{
|
||||
"../etc/policy-gateway.yaml",
|
||||
"../etc/policy-gateway.local.yaml",
|
||||
"policy-gateway.yaml",
|
||||
"policy-gateway.local.yaml"
|
||||
})
|
||||
{
|
||||
var path = Path.Combine(contentRoot, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyGatewayOptions>(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
|
||||
options.BindingSection = PolicyGatewayOptions.SectionName;
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
foreach (var relative in new[]
|
||||
{
|
||||
"../etc/policy-gateway.yaml",
|
||||
"../etc/policy-gateway.local.yaml",
|
||||
"policy-gateway.yaml",
|
||||
"policy-gateway.local.yaml"
|
||||
})
|
||||
{
|
||||
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
};
|
||||
options.PostBind = static (value, _) => value.Validate();
|
||||
});
|
||||
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddJsonConsole();
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
var contentRoot = builder.Environment.ContentRootPath;
|
||||
foreach (var relative in new[]
|
||||
{
|
||||
"../etc/policy-gateway.yaml",
|
||||
"../etc/policy-gateway.local.yaml",
|
||||
"policy-gateway.yaml",
|
||||
"policy-gateway.local.yaml"
|
||||
})
|
||||
{
|
||||
var path = Path.Combine(contentRoot, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyGatewayOptions>(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
|
||||
options.BindingSection = PolicyGatewayOptions.SectionName;
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
foreach (var relative in new[]
|
||||
{
|
||||
"../etc/policy-gateway.yaml",
|
||||
"../etc/policy-gateway.local.yaml",
|
||||
"policy-gateway.yaml",
|
||||
"policy-gateway.local.yaml"
|
||||
})
|
||||
{
|
||||
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.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
||||
|
||||
builder.Logging.SetMinimumLevel(bootstrap.Options.Telemetry.MinimumLogLevel);
|
||||
|
||||
builder.Services.AddOptions<PolicyGatewayOptions>()
|
||||
.Bind(builder.Configuration.GetSection(PolicyGatewayOptions.SectionName))
|
||||
.Validate(options =>
|
||||
{
|
||||
try
|
||||
{
|
||||
options.Validate();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new OptionsValidationException(
|
||||
PolicyGatewayOptions.SectionName,
|
||||
typeof(PolicyGatewayOptions),
|
||||
new[] { ex.Message });
|
||||
}
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
|
||||
builder.Services.AddSingleton<PolicyGatewayMetrics>();
|
||||
builder.Services.AddSingleton<PolicyGatewayDpopProofGenerator>();
|
||||
builder.Services.AddSingleton<PolicyEngineTokenProvider>();
|
||||
builder.Services.AddTransient<PolicyGatewayDpopHandler>();
|
||||
|
||||
if (bootstrap.Options.PolicyEngine.ClientCredentials.Enabled)
|
||||
{
|
||||
builder.Services.AddOptions<StellaOpsAuthClientOptions>()
|
||||
.Configure(options =>
|
||||
{
|
||||
options.Authority = bootstrap.Options.ResourceServer.Authority;
|
||||
options.ClientId = bootstrap.Options.PolicyEngine.ClientCredentials.ClientId;
|
||||
options.ClientSecret = bootstrap.Options.PolicyEngine.ClientCredentials.ClientSecret;
|
||||
options.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
|
||||
foreach (var scope in bootstrap.Options.PolicyEngine.ClientCredentials.Scopes)
|
||||
{
|
||||
options.DefaultScopes.Add(scope);
|
||||
}
|
||||
})
|
||||
.PostConfigure(static opt => opt.Validate());
|
||||
|
||||
builder.Services.TryAddSingleton<IStellaOpsTokenCache, InMemoryTokenCache>();
|
||||
|
||||
builder.Services.AddHttpClient<StellaOpsDiscoveryCache>((provider, client) =>
|
||||
{
|
||||
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
client.Timeout = authOptions.HttpTimeout;
|
||||
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
|
||||
|
||||
builder.Services.AddHttpClient<StellaOpsJwksCache>((provider, client) =>
|
||||
{
|
||||
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
client.Timeout = authOptions.HttpTimeout;
|
||||
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
|
||||
|
||||
builder.Services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) =>
|
||||
{
|
||||
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
client.Timeout = authOptions.HttpTimeout;
|
||||
})
|
||||
.AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider))
|
||||
.AddHttpMessageHandler<PolicyGatewayDpopHandler>();
|
||||
}
|
||||
|
||||
|
||||
builder.Services.AddOptions<PolicyGatewayOptions>()
|
||||
.Bind(builder.Configuration.GetSection(PolicyGatewayOptions.SectionName))
|
||||
.Validate(options =>
|
||||
{
|
||||
try
|
||||
{
|
||||
options.Validate();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new OptionsValidationException(
|
||||
PolicyGatewayOptions.SectionName,
|
||||
typeof(PolicyGatewayOptions),
|
||||
new[] { ex.Message });
|
||||
}
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
|
||||
builder.Services.AddSingleton<PolicyGatewayMetrics>();
|
||||
builder.Services.AddSingleton<PolicyGatewayDpopProofGenerator>();
|
||||
builder.Services.AddSingleton<PolicyEngineTokenProvider>();
|
||||
builder.Services.AddTransient<PolicyGatewayDpopHandler>();
|
||||
|
||||
if (bootstrap.Options.PolicyEngine.ClientCredentials.Enabled)
|
||||
{
|
||||
builder.Services.AddOptions<StellaOpsAuthClientOptions>()
|
||||
.Configure(options =>
|
||||
{
|
||||
options.Authority = bootstrap.Options.ResourceServer.Authority;
|
||||
options.ClientId = bootstrap.Options.PolicyEngine.ClientCredentials.ClientId;
|
||||
options.ClientSecret = bootstrap.Options.PolicyEngine.ClientCredentials.ClientSecret;
|
||||
options.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
|
||||
foreach (var scope in bootstrap.Options.PolicyEngine.ClientCredentials.Scopes)
|
||||
{
|
||||
options.DefaultScopes.Add(scope);
|
||||
}
|
||||
})
|
||||
.PostConfigure(static opt => opt.Validate());
|
||||
|
||||
builder.Services.TryAddSingleton<IStellaOpsTokenCache, InMemoryTokenCache>();
|
||||
|
||||
builder.Services.AddHttpClient<StellaOpsDiscoveryCache>((provider, client) =>
|
||||
{
|
||||
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
client.Timeout = authOptions.HttpTimeout;
|
||||
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
|
||||
|
||||
builder.Services.AddHttpClient<StellaOpsJwksCache>((provider, client) =>
|
||||
{
|
||||
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
client.Timeout = authOptions.HttpTimeout;
|
||||
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
|
||||
|
||||
builder.Services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) =>
|
||||
{
|
||||
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
client.Timeout = authOptions.HttpTimeout;
|
||||
})
|
||||
.AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider))
|
||||
.AddHttpMessageHandler<PolicyGatewayDpopHandler>();
|
||||
}
|
||||
|
||||
builder.Services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>((serviceProvider, client) =>
|
||||
{
|
||||
var gatewayOptions = serviceProvider.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value;
|
||||
@@ -161,175 +161,175 @@ builder.Services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>((service
|
||||
client.BaseAddress = gatewayOptions.PolicyEngine.BaseUri;
|
||||
client.Timeout = TimeSpan.FromSeconds(gatewayOptions.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
|
||||
})
|
||||
.AddPolicyHandler(static (provider, _) => CreatePolicyEngineRetryPolicy(provider));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseExceptionHandler(static appBuilder => appBuilder.Run(async context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Unexpected gateway error." });
|
||||
}));
|
||||
|
||||
app.UseStatusCodePages();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
|
||||
.WithName("Readiness");
|
||||
|
||||
app.MapGet("/", () => Results.Redirect("/healthz"));
|
||||
|
||||
var policyPacks = app.MapGroup("/api/policy/packs")
|
||||
.WithTags("Policy Packs");
|
||||
|
||||
policyPacks.MapGet(string.Empty, async Task<IResult> (
|
||||
HttpContext context,
|
||||
IPolicyEngineClient client,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
GatewayForwardingContext? forwardingContext = null;
|
||||
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
|
||||
{
|
||||
forwardingContext = callerContext;
|
||||
}
|
||||
else if (!tokenProvider.IsEnabled)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var response = await client.ListPolicyPacksAsync(forwardingContext, cancellationToken).ConfigureAwait(false);
|
||||
return response.ToMinimalResult();
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
||||
|
||||
policyPacks.MapPost(string.Empty, async Task<IResult> (
|
||||
HttpContext context,
|
||||
CreatePolicyPackRequest request,
|
||||
IPolicyEngineClient client,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
GatewayForwardingContext? forwardingContext = null;
|
||||
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
|
||||
{
|
||||
forwardingContext = callerContext;
|
||||
}
|
||||
else if (!tokenProvider.IsEnabled)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var response = await client.CreatePolicyPackAsync(forwardingContext, request, cancellationToken).ConfigureAwait(false);
|
||||
return response.ToMinimalResult();
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
|
||||
|
||||
policyPacks.MapPost("/{packId}/revisions", async Task<IResult> (
|
||||
HttpContext context,
|
||||
string packId,
|
||||
CreatePolicyRevisionRequest request,
|
||||
IPolicyEngineClient client,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "packId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
GatewayForwardingContext? forwardingContext = null;
|
||||
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
|
||||
{
|
||||
forwardingContext = callerContext;
|
||||
}
|
||||
else if (!tokenProvider.IsEnabled)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var response = await client.CreatePolicyRevisionAsync(forwardingContext, packId, request, cancellationToken).ConfigureAwait(false);
|
||||
return response.ToMinimalResult();
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
|
||||
|
||||
.AddPolicyHandler(static (provider, _) => CreatePolicyEngineRetryPolicy(provider));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseExceptionHandler(static appBuilder => appBuilder.Run(async context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Unexpected gateway error." });
|
||||
}));
|
||||
|
||||
app.UseStatusCodePages();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
|
||||
.WithName("Readiness");
|
||||
|
||||
app.MapGet("/", () => Results.Redirect("/healthz"));
|
||||
|
||||
var policyPacks = app.MapGroup("/api/policy/packs")
|
||||
.WithTags("Policy Packs");
|
||||
|
||||
policyPacks.MapGet(string.Empty, async Task<IResult> (
|
||||
HttpContext context,
|
||||
IPolicyEngineClient client,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
GatewayForwardingContext? forwardingContext = null;
|
||||
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
|
||||
{
|
||||
forwardingContext = callerContext;
|
||||
}
|
||||
else if (!tokenProvider.IsEnabled)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var response = await client.ListPolicyPacksAsync(forwardingContext, cancellationToken).ConfigureAwait(false);
|
||||
return response.ToMinimalResult();
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
||||
|
||||
policyPacks.MapPost(string.Empty, async Task<IResult> (
|
||||
HttpContext context,
|
||||
CreatePolicyPackRequest request,
|
||||
IPolicyEngineClient client,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
GatewayForwardingContext? forwardingContext = null;
|
||||
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
|
||||
{
|
||||
forwardingContext = callerContext;
|
||||
}
|
||||
else if (!tokenProvider.IsEnabled)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var response = await client.CreatePolicyPackAsync(forwardingContext, request, cancellationToken).ConfigureAwait(false);
|
||||
return response.ToMinimalResult();
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
|
||||
|
||||
policyPacks.MapPost("/{packId}/revisions", async Task<IResult> (
|
||||
HttpContext context,
|
||||
string packId,
|
||||
CreatePolicyRevisionRequest request,
|
||||
IPolicyEngineClient client,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "packId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
GatewayForwardingContext? forwardingContext = null;
|
||||
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
|
||||
{
|
||||
forwardingContext = callerContext;
|
||||
}
|
||||
else if (!tokenProvider.IsEnabled)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var response = await client.CreatePolicyRevisionAsync(forwardingContext, packId, request, cancellationToken).ConfigureAwait(false);
|
||||
return response.ToMinimalResult();
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
|
||||
|
||||
policyPacks.MapPost("/{packId}/revisions/{version:int}:activate", async Task<IResult> (
|
||||
HttpContext context,
|
||||
string packId,
|
||||
int version,
|
||||
ActivatePolicyRevisionRequest request,
|
||||
IPolicyEngineClient client,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
PolicyGatewayMetrics metrics,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "packId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
GatewayForwardingContext? forwardingContext = null;
|
||||
var source = "service";
|
||||
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
|
||||
{
|
||||
forwardingContext = callerContext;
|
||||
source = "caller";
|
||||
}
|
||||
else if (!tokenProvider.IsEnabled)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var response = await client.ActivatePolicyRevisionAsync(forwardingContext, packId, version, request, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
var outcome = DetermineActivationOutcome(response);
|
||||
metrics.RecordActivation(outcome, source, stopwatch.Elapsed.TotalMilliseconds);
|
||||
|
||||
var logger = loggerFactory.CreateLogger("StellaOps.Policy.Gateway.Activation");
|
||||
LogActivation(logger, packId, version, outcome, source, response.StatusCode);
|
||||
|
||||
IPolicyEngineClient client,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
PolicyGatewayMetrics metrics,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "packId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
GatewayForwardingContext? forwardingContext = null;
|
||||
var source = "service";
|
||||
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
|
||||
{
|
||||
forwardingContext = callerContext;
|
||||
source = "caller";
|
||||
}
|
||||
else if (!tokenProvider.IsEnabled)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var response = await client.ActivatePolicyRevisionAsync(forwardingContext, packId, version, request, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
var outcome = DetermineActivationOutcome(response);
|
||||
metrics.RecordActivation(outcome, source, stopwatch.Elapsed.TotalMilliseconds);
|
||||
|
||||
var logger = loggerFactory.CreateLogger("StellaOps.Policy.Gateway.Activation");
|
||||
LogActivation(logger, packId, version, outcome, source, response.StatusCode);
|
||||
|
||||
return response.ToMinimalResult();
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
|
||||
@@ -468,78 +468,78 @@ cvss.MapGet("/policies", async Task<IResult>(
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
|
||||
|
||||
app.Run();
|
||||
|
||||
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)
|
||||
{
|
||||
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
var delays = authOptions.NormalizedRetryDelays;
|
||||
if (delays.Count == 0)
|
||||
{
|
||||
return Policy.NoOpAsync<HttpResponseMessage>();
|
||||
}
|
||||
|
||||
var loggerFactory = provider.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger("PolicyGateway.AuthorityHttp");
|
||||
|
||||
return HttpPolicyExtensions
|
||||
.HandleTransientHttpError()
|
||||
.OrResult(static message => message.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
.WaitAndRetryAsync(
|
||||
delays.Count,
|
||||
attempt => delays[attempt - 1],
|
||||
(outcome, delay, attempt, _) =>
|
||||
{
|
||||
logger?.LogWarning(
|
||||
outcome.Exception,
|
||||
"Retrying Authority HTTP call ({Attempt}/{Total}) after {Reason}; waiting {Delay}.",
|
||||
attempt,
|
||||
delays.Count,
|
||||
outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString(),
|
||||
delay);
|
||||
});
|
||||
}
|
||||
|
||||
static IAsyncPolicy<HttpResponseMessage> CreatePolicyEngineRetryPolicy(IServiceProvider provider)
|
||||
=> HttpPolicyExtensions
|
||||
.HandleTransientHttpError()
|
||||
.OrResult(static response => response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout)
|
||||
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
|
||||
|
||||
static string DetermineActivationOutcome(PolicyEngineResponse<PolicyRevisionActivationDto> response)
|
||||
{
|
||||
if (response.IsSuccess)
|
||||
{
|
||||
return response.Value?.Status switch
|
||||
{
|
||||
"activated" => "activated",
|
||||
"already_active" => "already_active",
|
||||
"pending_second_approval" => "pending_second_approval",
|
||||
_ => "success"
|
||||
};
|
||||
}
|
||||
|
||||
return response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.BadRequest => "bad_request",
|
||||
HttpStatusCode.NotFound => "not_found",
|
||||
HttpStatusCode.Unauthorized => "unauthorized",
|
||||
HttpStatusCode.Forbidden => "forbidden",
|
||||
_ => "error"
|
||||
};
|
||||
}
|
||||
|
||||
static void LogActivation(ILogger logger, string packId, int version, string outcome, string source, HttpStatusCode statusCode)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = "Policy activation forwarded.";
|
||||
var logLevel = outcome is "activated" or "already_active" or "pending_second_approval" ? LogLevel.Information : LogLevel.Warning;
|
||||
logger.Log(logLevel, message + " Outcome={Outcome}; Source={Source}; PackId={PackId}; Version={Version}; StatusCode={StatusCode}.", outcome, source, packId, version, (int)statusCode);
|
||||
}
|
||||
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
|
||||
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)
|
||||
{
|
||||
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
var delays = authOptions.NormalizedRetryDelays;
|
||||
if (delays.Count == 0)
|
||||
{
|
||||
return Policy.NoOpAsync<HttpResponseMessage>();
|
||||
}
|
||||
|
||||
var loggerFactory = provider.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger("PolicyGateway.AuthorityHttp");
|
||||
|
||||
return HttpPolicyExtensions
|
||||
.HandleTransientHttpError()
|
||||
.OrResult(static message => message.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
.WaitAndRetryAsync(
|
||||
delays.Count,
|
||||
attempt => delays[attempt - 1],
|
||||
(outcome, delay, attempt, _) =>
|
||||
{
|
||||
logger?.LogWarning(
|
||||
outcome.Exception,
|
||||
"Retrying Authority HTTP call ({Attempt}/{Total}) after {Reason}; waiting {Delay}.",
|
||||
attempt,
|
||||
delays.Count,
|
||||
outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString(),
|
||||
delay);
|
||||
});
|
||||
}
|
||||
|
||||
static IAsyncPolicy<HttpResponseMessage> CreatePolicyEngineRetryPolicy(IServiceProvider provider)
|
||||
=> HttpPolicyExtensions
|
||||
.HandleTransientHttpError()
|
||||
.OrResult(static response => response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout)
|
||||
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
|
||||
|
||||
static string DetermineActivationOutcome(PolicyEngineResponse<PolicyRevisionActivationDto> response)
|
||||
{
|
||||
if (response.IsSuccess)
|
||||
{
|
||||
return response.Value?.Status switch
|
||||
{
|
||||
"activated" => "activated",
|
||||
"already_active" => "already_active",
|
||||
"pending_second_approval" => "pending_second_approval",
|
||||
_ => "success"
|
||||
};
|
||||
}
|
||||
|
||||
return response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.BadRequest => "bad_request",
|
||||
HttpStatusCode.NotFound => "not_found",
|
||||
HttpStatusCode.Unauthorized => "unauthorized",
|
||||
HttpStatusCode.Forbidden => "forbidden",
|
||||
_ => "error"
|
||||
};
|
||||
}
|
||||
|
||||
static void LogActivation(ILogger logger, string packId, int version, string outcome, string source, HttpStatusCode statusCode)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = "Policy activation forwarded.";
|
||||
var logLevel = outcome is "activated" or "already_active" or "pending_second_approval" ? LogLevel.Information : LogLevel.Warning;
|
||||
logger.Log(logLevel, message + " Outcome={Outcome}; Source={Source}; PackId={PackId}; Version={Version}; StatusCode={StatusCode}.", outcome, source, packId, version, (int)statusCode);
|
||||
}
|
||||
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Policy.Gateway.Tests")]
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Policy.Gateway.Tests")]
|
||||
|
||||
@@ -1,123 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal sealed class PolicyEngineTokenProvider
|
||||
{
|
||||
private readonly IStellaOpsTokenClient tokenClient;
|
||||
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
|
||||
private readonly PolicyGatewayDpopProofGenerator dpopGenerator;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<PolicyEngineTokenProvider> logger;
|
||||
private readonly SemaphoreSlim mutex = new(1, 1);
|
||||
private CachedToken? cachedToken;
|
||||
|
||||
public PolicyEngineTokenProvider(
|
||||
IStellaOpsTokenClient tokenClient,
|
||||
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
|
||||
PolicyGatewayDpopProofGenerator dpopGenerator,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PolicyEngineTokenProvider> logger)
|
||||
{
|
||||
this.tokenClient = tokenClient ?? throw new ArgumentNullException(nameof(tokenClient));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.dpopGenerator = dpopGenerator ?? throw new ArgumentNullException(nameof(dpopGenerator));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public bool IsEnabled => optionsMonitor.CurrentValue.PolicyEngine.ClientCredentials.Enabled;
|
||||
|
||||
public async ValueTask<PolicyGatewayAuthorization?> GetAuthorizationAsync(HttpMethod method, Uri targetUri, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tokenResult = await GetTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (tokenResult is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var token = tokenResult.Value;
|
||||
string? proof = null;
|
||||
if (dpopGenerator.Enabled)
|
||||
{
|
||||
proof = dpopGenerator.CreateProof(method, targetUri, token.AccessToken);
|
||||
}
|
||||
|
||||
var scheme = string.Equals(token.TokenType, "dpop", StringComparison.OrdinalIgnoreCase)
|
||||
? "DPoP"
|
||||
: token.TokenType;
|
||||
|
||||
var authorization = $"{scheme} {token.AccessToken}";
|
||||
return new PolicyGatewayAuthorization(authorization, proof, "service");
|
||||
}
|
||||
|
||||
private async ValueTask<CachedToken?> GetTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue.PolicyEngine;
|
||||
if (!options.ClientCredentials.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (cachedToken is { } existing && existing.ExpiresAt > now + TimeSpan.FromSeconds(30))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
await mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (cachedToken is { } cached && cached.ExpiresAt > now + TimeSpan.FromSeconds(30))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var scopeString = BuildScopeClaim(options);
|
||||
var result = await tokenClient.RequestClientCredentialsTokenAsync(scopeString, null, cancellationToken).ConfigureAwait(false);
|
||||
var expiresAt = result.ExpiresAtUtc;
|
||||
cachedToken = new CachedToken(result.AccessToken, string.IsNullOrWhiteSpace(result.TokenType) ? "Bearer" : result.TokenType, expiresAt);
|
||||
logger.LogInformation("Issued Policy Engine client credentials token; expires at {ExpiresAt:o}.", expiresAt);
|
||||
return cachedToken;
|
||||
}
|
||||
finally
|
||||
{
|
||||
mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildScopeClaim(PolicyGatewayPolicyEngineOptions options)
|
||||
{
|
||||
var scopeSet = new SortedSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
$"aud:{options.Audience.Trim().ToLowerInvariant()}"
|
||||
};
|
||||
|
||||
foreach (var scope in options.ClientCredentials.Scopes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
scopeSet.Add(scope.Trim());
|
||||
}
|
||||
|
||||
return string.Join(' ', scopeSet);
|
||||
}
|
||||
|
||||
private readonly record struct CachedToken(string AccessToken, string TokenType, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal sealed class PolicyEngineTokenProvider
|
||||
{
|
||||
private readonly IStellaOpsTokenClient tokenClient;
|
||||
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
|
||||
private readonly PolicyGatewayDpopProofGenerator dpopGenerator;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<PolicyEngineTokenProvider> logger;
|
||||
private readonly SemaphoreSlim mutex = new(1, 1);
|
||||
private CachedToken? cachedToken;
|
||||
|
||||
public PolicyEngineTokenProvider(
|
||||
IStellaOpsTokenClient tokenClient,
|
||||
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
|
||||
PolicyGatewayDpopProofGenerator dpopGenerator,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PolicyEngineTokenProvider> logger)
|
||||
{
|
||||
this.tokenClient = tokenClient ?? throw new ArgumentNullException(nameof(tokenClient));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.dpopGenerator = dpopGenerator ?? throw new ArgumentNullException(nameof(dpopGenerator));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public bool IsEnabled => optionsMonitor.CurrentValue.PolicyEngine.ClientCredentials.Enabled;
|
||||
|
||||
public async ValueTask<PolicyGatewayAuthorization?> GetAuthorizationAsync(HttpMethod method, Uri targetUri, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tokenResult = await GetTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (tokenResult is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var token = tokenResult.Value;
|
||||
string? proof = null;
|
||||
if (dpopGenerator.Enabled)
|
||||
{
|
||||
proof = dpopGenerator.CreateProof(method, targetUri, token.AccessToken);
|
||||
}
|
||||
|
||||
var scheme = string.Equals(token.TokenType, "dpop", StringComparison.OrdinalIgnoreCase)
|
||||
? "DPoP"
|
||||
: token.TokenType;
|
||||
|
||||
var authorization = $"{scheme} {token.AccessToken}";
|
||||
return new PolicyGatewayAuthorization(authorization, proof, "service");
|
||||
}
|
||||
|
||||
private async ValueTask<CachedToken?> GetTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue.PolicyEngine;
|
||||
if (!options.ClientCredentials.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (cachedToken is { } existing && existing.ExpiresAt > now + TimeSpan.FromSeconds(30))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
await mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (cachedToken is { } cached && cached.ExpiresAt > now + TimeSpan.FromSeconds(30))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var scopeString = BuildScopeClaim(options);
|
||||
var result = await tokenClient.RequestClientCredentialsTokenAsync(scopeString, null, cancellationToken).ConfigureAwait(false);
|
||||
var expiresAt = result.ExpiresAtUtc;
|
||||
cachedToken = new CachedToken(result.AccessToken, string.IsNullOrWhiteSpace(result.TokenType) ? "Bearer" : result.TokenType, expiresAt);
|
||||
logger.LogInformation("Issued Policy Engine client credentials token; expires at {ExpiresAt:o}.", expiresAt);
|
||||
return cachedToken;
|
||||
}
|
||||
finally
|
||||
{
|
||||
mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildScopeClaim(PolicyGatewayPolicyEngineOptions options)
|
||||
{
|
||||
var scopeSet = new SortedSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
$"aud:{options.Audience.Trim().ToLowerInvariant()}"
|
||||
};
|
||||
|
||||
foreach (var scope in options.ClientCredentials.Scopes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
scopeSet.Add(scope.Trim());
|
||||
}
|
||||
|
||||
return string.Join(' ', scopeSet);
|
||||
}
|
||||
|
||||
private readonly record struct CachedToken(string AccessToken, string TokenType, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal readonly record struct PolicyGatewayAuthorization(string AuthorizationHeader, string? DpopProof, string Source)
|
||||
{
|
||||
public void Apply(HttpRequestMessage request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AuthorizationHeader))
|
||||
{
|
||||
request.Headers.Remove("Authorization");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", AuthorizationHeader);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(DpopProof))
|
||||
{
|
||||
request.Headers.Remove("DPoP");
|
||||
request.Headers.TryAddWithoutValidation("DPoP", DpopProof);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal readonly record struct PolicyGatewayAuthorization(string AuthorizationHeader, string? DpopProof, string Source)
|
||||
{
|
||||
public void Apply(HttpRequestMessage request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AuthorizationHeader))
|
||||
{
|
||||
request.Headers.Remove("Authorization");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", AuthorizationHeader);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(DpopProof))
|
||||
{
|
||||
request.Headers.Remove("DPoP");
|
||||
request.Headers.TryAddWithoutValidation("DPoP", DpopProof);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal sealed class PolicyGatewayDpopHandler : DelegatingHandler
|
||||
{
|
||||
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
|
||||
private readonly PolicyGatewayDpopProofGenerator proofGenerator;
|
||||
|
||||
public PolicyGatewayDpopHandler(
|
||||
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
|
||||
PolicyGatewayDpopProofGenerator proofGenerator)
|
||||
{
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.proofGenerator = proofGenerator ?? throw new ArgumentNullException(nameof(proofGenerator));
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
|
||||
if (options.Enabled &&
|
||||
proofGenerator.Enabled &&
|
||||
request.Method == HttpMethod.Post &&
|
||||
request.RequestUri is { } uri &&
|
||||
uri.AbsolutePath.Contains("/token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var proof = proofGenerator.CreateProof(request.Method, uri, accessToken: null);
|
||||
request.Headers.Remove("DPoP");
|
||||
request.Headers.TryAddWithoutValidation("DPoP", proof);
|
||||
}
|
||||
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal sealed class PolicyGatewayDpopHandler : DelegatingHandler
|
||||
{
|
||||
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
|
||||
private readonly PolicyGatewayDpopProofGenerator proofGenerator;
|
||||
|
||||
public PolicyGatewayDpopHandler(
|
||||
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
|
||||
PolicyGatewayDpopProofGenerator proofGenerator)
|
||||
{
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.proofGenerator = proofGenerator ?? throw new ArgumentNullException(nameof(proofGenerator));
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
|
||||
if (options.Enabled &&
|
||||
proofGenerator.Enabled &&
|
||||
request.Method == HttpMethod.Post &&
|
||||
request.RequestUri is { } uri &&
|
||||
uri.AbsolutePath.Contains("/token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var proof = proofGenerator.CreateProof(request.Method, uri, accessToken: null);
|
||||
request.Headers.Remove("DPoP");
|
||||
request.Headers.TryAddWithoutValidation("DPoP", proof);
|
||||
}
|
||||
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,235 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal sealed class PolicyGatewayDpopProofGenerator : IDisposable
|
||||
{
|
||||
private readonly IHostEnvironment hostEnvironment;
|
||||
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<PolicyGatewayDpopProofGenerator> logger;
|
||||
private DpopKeyMaterial? keyMaterial;
|
||||
private readonly object sync = new();
|
||||
|
||||
public PolicyGatewayDpopProofGenerator(
|
||||
IHostEnvironment hostEnvironment,
|
||||
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PolicyGatewayDpopProofGenerator> logger)
|
||||
{
|
||||
this.hostEnvironment = hostEnvironment ?? throw new ArgumentNullException(nameof(hostEnvironment));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
|
||||
return options.Enabled;
|
||||
}
|
||||
}
|
||||
|
||||
public string CreateProof(HttpMethod method, Uri targetUri, string? accessToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(method);
|
||||
ArgumentNullException.ThrowIfNull(targetUri);
|
||||
|
||||
if (!Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("DPoP proof requested while DPoP is disabled.");
|
||||
}
|
||||
|
||||
var material = GetOrLoadKeyMaterial();
|
||||
var header = CreateHeader(material);
|
||||
var payload = CreatePayload(method, targetUri, accessToken);
|
||||
|
||||
var jwt = new JwtSecurityToken(header, payload);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
return handler.WriteToken(jwt);
|
||||
}
|
||||
|
||||
private JwtHeader CreateHeader(DpopKeyMaterial material)
|
||||
{
|
||||
var header = new JwtHeader(new SigningCredentials(material.SecurityKey, material.SigningAlgorithm));
|
||||
header["typ"] = "dpop+jwt";
|
||||
header["jwk"] = new Dictionary<string, object>
|
||||
{
|
||||
["kty"] = material.Jwk.Kty,
|
||||
["crv"] = material.Jwk.Crv,
|
||||
["x"] = material.Jwk.X,
|
||||
["y"] = material.Jwk.Y,
|
||||
["kid"] = material.Jwk.Kid
|
||||
};
|
||||
return header;
|
||||
}
|
||||
|
||||
private JwtPayload CreatePayload(HttpMethod method, Uri targetUri, string? accessToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var epochSeconds = (long)Math.Floor((now - DateTimeOffset.UnixEpoch).TotalSeconds);
|
||||
var payload = new JwtPayload
|
||||
{
|
||||
["htm"] = method.Method.ToUpperInvariant(),
|
||||
["htu"] = NormalizeTarget(targetUri),
|
||||
["iat"] = epochSeconds,
|
||||
["jti"] = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(accessToken));
|
||||
payload["ath"] = Base64UrlEncoder.Encode(hash);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static string NormalizeTarget(Uri uri)
|
||||
{
|
||||
if (!uri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("DPoP proofs require absolute target URIs.");
|
||||
}
|
||||
|
||||
return uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped);
|
||||
}
|
||||
|
||||
private DpopKeyMaterial GetOrLoadKeyMaterial()
|
||||
{
|
||||
if (keyMaterial is not null)
|
||||
{
|
||||
return keyMaterial;
|
||||
}
|
||||
|
||||
lock (sync)
|
||||
{
|
||||
if (keyMaterial is not null)
|
||||
{
|
||||
return keyMaterial;
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
|
||||
if (!options.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("DPoP is not enabled in the current configuration.");
|
||||
}
|
||||
|
||||
var resolvedPath = ResolveKeyPath(options.KeyPath);
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
throw new FileNotFoundException($"DPoP key file not found at '{resolvedPath}'.", resolvedPath);
|
||||
}
|
||||
|
||||
var pem = File.ReadAllText(resolvedPath);
|
||||
ECDsa ecdsa;
|
||||
try
|
||||
{
|
||||
ecdsa = ECDsa.Create();
|
||||
if (!string.IsNullOrWhiteSpace(options.KeyPassphrase))
|
||||
{
|
||||
ecdsa.ImportFromEncryptedPem(pem, options.KeyPassphrase);
|
||||
}
|
||||
else
|
||||
{
|
||||
ecdsa.ImportFromPem(pem);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to load DPoP private key.", ex);
|
||||
}
|
||||
|
||||
var securityKey = new ECDsaSecurityKey(ecdsa)
|
||||
{
|
||||
KeyId = ComputeKeyId(ecdsa)
|
||||
};
|
||||
|
||||
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
|
||||
jwk.Kid ??= securityKey.KeyId;
|
||||
|
||||
keyMaterial = new DpopKeyMaterial(ecdsa, securityKey, jwk, MapAlgorithm(options.Algorithm));
|
||||
logger.LogInformation("Loaded DPoP key from {Path} (alg: {Algorithm}).", resolvedPath, options.Algorithm);
|
||||
return keyMaterial;
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveKeyPath(string path)
|
||||
{
|
||||
if (Path.IsPathRooted(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, path));
|
||||
}
|
||||
|
||||
private static string ComputeKeyId(ECDsa ecdsa)
|
||||
{
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: false);
|
||||
var buffer = new byte[(parameters.Q.X?.Length ?? 0) + (parameters.Q.Y?.Length ?? 0)];
|
||||
var offset = 0;
|
||||
if (parameters.Q.X is not null)
|
||||
{
|
||||
Buffer.BlockCopy(parameters.Q.X, 0, buffer, offset, parameters.Q.X.Length);
|
||||
offset += parameters.Q.X.Length;
|
||||
}
|
||||
|
||||
if (parameters.Q.Y is not null)
|
||||
{
|
||||
Buffer.BlockCopy(parameters.Q.Y, 0, buffer, offset, parameters.Q.Y.Length);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer);
|
||||
return Base64UrlEncoder.Encode(hash);
|
||||
}
|
||||
|
||||
private static string MapAlgorithm(string algorithm)
|
||||
=> algorithm switch
|
||||
{
|
||||
"ES256" => SecurityAlgorithms.EcdsaSha256,
|
||||
"ES384" => SecurityAlgorithms.EcdsaSha384,
|
||||
_ => throw new InvalidOperationException($"Unsupported DPoP signing algorithm '{algorithm}'.")
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (keyMaterial is { } material)
|
||||
{
|
||||
material.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DpopKeyMaterial : IDisposable
|
||||
{
|
||||
public DpopKeyMaterial(ECDsa ecdsa, ECDsaSecurityKey securityKey, JsonWebKey jwk, string signingAlgorithm)
|
||||
{
|
||||
Ecdsa = ecdsa;
|
||||
SecurityKey = securityKey;
|
||||
Jwk = jwk;
|
||||
SigningAlgorithm = signingAlgorithm;
|
||||
}
|
||||
|
||||
public ECDsa Ecdsa { get; }
|
||||
public ECDsaSecurityKey SecurityKey { get; }
|
||||
public JsonWebKey Jwk { get; }
|
||||
public string SigningAlgorithm { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Ecdsa.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal sealed class PolicyGatewayDpopProofGenerator : IDisposable
|
||||
{
|
||||
private readonly IHostEnvironment hostEnvironment;
|
||||
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<PolicyGatewayDpopProofGenerator> logger;
|
||||
private DpopKeyMaterial? keyMaterial;
|
||||
private readonly object sync = new();
|
||||
|
||||
public PolicyGatewayDpopProofGenerator(
|
||||
IHostEnvironment hostEnvironment,
|
||||
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PolicyGatewayDpopProofGenerator> logger)
|
||||
{
|
||||
this.hostEnvironment = hostEnvironment ?? throw new ArgumentNullException(nameof(hostEnvironment));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
|
||||
return options.Enabled;
|
||||
}
|
||||
}
|
||||
|
||||
public string CreateProof(HttpMethod method, Uri targetUri, string? accessToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(method);
|
||||
ArgumentNullException.ThrowIfNull(targetUri);
|
||||
|
||||
if (!Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("DPoP proof requested while DPoP is disabled.");
|
||||
}
|
||||
|
||||
var material = GetOrLoadKeyMaterial();
|
||||
var header = CreateHeader(material);
|
||||
var payload = CreatePayload(method, targetUri, accessToken);
|
||||
|
||||
var jwt = new JwtSecurityToken(header, payload);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
return handler.WriteToken(jwt);
|
||||
}
|
||||
|
||||
private JwtHeader CreateHeader(DpopKeyMaterial material)
|
||||
{
|
||||
var header = new JwtHeader(new SigningCredentials(material.SecurityKey, material.SigningAlgorithm));
|
||||
header["typ"] = "dpop+jwt";
|
||||
header["jwk"] = new Dictionary<string, object>
|
||||
{
|
||||
["kty"] = material.Jwk.Kty,
|
||||
["crv"] = material.Jwk.Crv,
|
||||
["x"] = material.Jwk.X,
|
||||
["y"] = material.Jwk.Y,
|
||||
["kid"] = material.Jwk.Kid
|
||||
};
|
||||
return header;
|
||||
}
|
||||
|
||||
private JwtPayload CreatePayload(HttpMethod method, Uri targetUri, string? accessToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var epochSeconds = (long)Math.Floor((now - DateTimeOffset.UnixEpoch).TotalSeconds);
|
||||
var payload = new JwtPayload
|
||||
{
|
||||
["htm"] = method.Method.ToUpperInvariant(),
|
||||
["htu"] = NormalizeTarget(targetUri),
|
||||
["iat"] = epochSeconds,
|
||||
["jti"] = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(accessToken));
|
||||
payload["ath"] = Base64UrlEncoder.Encode(hash);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static string NormalizeTarget(Uri uri)
|
||||
{
|
||||
if (!uri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("DPoP proofs require absolute target URIs.");
|
||||
}
|
||||
|
||||
return uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped);
|
||||
}
|
||||
|
||||
private DpopKeyMaterial GetOrLoadKeyMaterial()
|
||||
{
|
||||
if (keyMaterial is not null)
|
||||
{
|
||||
return keyMaterial;
|
||||
}
|
||||
|
||||
lock (sync)
|
||||
{
|
||||
if (keyMaterial is not null)
|
||||
{
|
||||
return keyMaterial;
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
|
||||
if (!options.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("DPoP is not enabled in the current configuration.");
|
||||
}
|
||||
|
||||
var resolvedPath = ResolveKeyPath(options.KeyPath);
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
throw new FileNotFoundException($"DPoP key file not found at '{resolvedPath}'.", resolvedPath);
|
||||
}
|
||||
|
||||
var pem = File.ReadAllText(resolvedPath);
|
||||
ECDsa ecdsa;
|
||||
try
|
||||
{
|
||||
ecdsa = ECDsa.Create();
|
||||
if (!string.IsNullOrWhiteSpace(options.KeyPassphrase))
|
||||
{
|
||||
ecdsa.ImportFromEncryptedPem(pem, options.KeyPassphrase);
|
||||
}
|
||||
else
|
||||
{
|
||||
ecdsa.ImportFromPem(pem);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to load DPoP private key.", ex);
|
||||
}
|
||||
|
||||
var securityKey = new ECDsaSecurityKey(ecdsa)
|
||||
{
|
||||
KeyId = ComputeKeyId(ecdsa)
|
||||
};
|
||||
|
||||
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
|
||||
jwk.Kid ??= securityKey.KeyId;
|
||||
|
||||
keyMaterial = new DpopKeyMaterial(ecdsa, securityKey, jwk, MapAlgorithm(options.Algorithm));
|
||||
logger.LogInformation("Loaded DPoP key from {Path} (alg: {Algorithm}).", resolvedPath, options.Algorithm);
|
||||
return keyMaterial;
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveKeyPath(string path)
|
||||
{
|
||||
if (Path.IsPathRooted(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, path));
|
||||
}
|
||||
|
||||
private static string ComputeKeyId(ECDsa ecdsa)
|
||||
{
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: false);
|
||||
var buffer = new byte[(parameters.Q.X?.Length ?? 0) + (parameters.Q.Y?.Length ?? 0)];
|
||||
var offset = 0;
|
||||
if (parameters.Q.X is not null)
|
||||
{
|
||||
Buffer.BlockCopy(parameters.Q.X, 0, buffer, offset, parameters.Q.X.Length);
|
||||
offset += parameters.Q.X.Length;
|
||||
}
|
||||
|
||||
if (parameters.Q.Y is not null)
|
||||
{
|
||||
Buffer.BlockCopy(parameters.Q.Y, 0, buffer, offset, parameters.Q.Y.Length);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer);
|
||||
return Base64UrlEncoder.Encode(hash);
|
||||
}
|
||||
|
||||
private static string MapAlgorithm(string algorithm)
|
||||
=> algorithm switch
|
||||
{
|
||||
"ES256" => SecurityAlgorithms.EcdsaSha256,
|
||||
"ES384" => SecurityAlgorithms.EcdsaSha384,
|
||||
_ => throw new InvalidOperationException($"Unsupported DPoP signing algorithm '{algorithm}'.")
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (keyMaterial is { } material)
|
||||
{
|
||||
material.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DpopKeyMaterial : IDisposable
|
||||
{
|
||||
public DpopKeyMaterial(ECDsa ecdsa, ECDsaSecurityKey securityKey, JsonWebKey jwk, string signingAlgorithm)
|
||||
{
|
||||
Ecdsa = ecdsa;
|
||||
SecurityKey = securityKey;
|
||||
Jwk = jwk;
|
||||
SigningAlgorithm = signingAlgorithm;
|
||||
}
|
||||
|
||||
public ECDsa Ecdsa { get; }
|
||||
public ECDsaSecurityKey SecurityKey { get; }
|
||||
public JsonWebKey Jwk { get; }
|
||||
public string SigningAlgorithm { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Ecdsa.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
using System;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal sealed class PolicyGatewayMetrics : IDisposable
|
||||
{
|
||||
private static readonly KeyValuePair<string, object?>[] EmptyTags = Array.Empty<KeyValuePair<string, object?>>();
|
||||
|
||||
private readonly Meter meter;
|
||||
|
||||
public PolicyGatewayMetrics()
|
||||
{
|
||||
meter = new Meter("StellaOps.Policy.Gateway", "1.0.0");
|
||||
ActivationRequests = meter.CreateCounter<long>(
|
||||
"policy_gateway_activation_requests_total",
|
||||
unit: "count",
|
||||
description: "Total policy activation proxy requests processed by the gateway.");
|
||||
ActivationLatencyMs = meter.CreateHistogram<double>(
|
||||
"policy_gateway_activation_latency_ms",
|
||||
unit: "ms",
|
||||
description: "Latency distribution for policy activation proxy calls.");
|
||||
}
|
||||
|
||||
public Counter<long> ActivationRequests { get; }
|
||||
|
||||
public Histogram<double> ActivationLatencyMs { get; }
|
||||
|
||||
public void RecordActivation(string outcome, string source, double elapsedMilliseconds)
|
||||
{
|
||||
var tags = BuildTags(outcome, source);
|
||||
ActivationRequests.Add(1, tags);
|
||||
ActivationLatencyMs.Record(elapsedMilliseconds, tags);
|
||||
}
|
||||
|
||||
private static KeyValuePair<string, object?>[] BuildTags(string outcome, string source)
|
||||
{
|
||||
outcome = string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome;
|
||||
source = string.IsNullOrWhiteSpace(source) ? "unspecified" : source;
|
||||
return new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("outcome", outcome),
|
||||
new KeyValuePair<string, object?>("source", source)
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
meter.Dispose();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal sealed class PolicyGatewayMetrics : IDisposable
|
||||
{
|
||||
private static readonly KeyValuePair<string, object?>[] EmptyTags = Array.Empty<KeyValuePair<string, object?>>();
|
||||
|
||||
private readonly Meter meter;
|
||||
|
||||
public PolicyGatewayMetrics()
|
||||
{
|
||||
meter = new Meter("StellaOps.Policy.Gateway", "1.0.0");
|
||||
ActivationRequests = meter.CreateCounter<long>(
|
||||
"policy_gateway_activation_requests_total",
|
||||
unit: "count",
|
||||
description: "Total policy activation proxy requests processed by the gateway.");
|
||||
ActivationLatencyMs = meter.CreateHistogram<double>(
|
||||
"policy_gateway_activation_latency_ms",
|
||||
unit: "ms",
|
||||
description: "Latency distribution for policy activation proxy calls.");
|
||||
}
|
||||
|
||||
public Counter<long> ActivationRequests { get; }
|
||||
|
||||
public Histogram<double> ActivationLatencyMs { get; }
|
||||
|
||||
public void RecordActivation(string outcome, string source, double elapsedMilliseconds)
|
||||
{
|
||||
var tags = BuildTags(outcome, source);
|
||||
ActivationRequests.Add(1, tags);
|
||||
ActivationLatencyMs.Record(elapsedMilliseconds, tags);
|
||||
}
|
||||
|
||||
private static KeyValuePair<string, object?>[] BuildTags(string outcome, string source)
|
||||
{
|
||||
outcome = string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome;
|
||||
source = string.IsNullOrWhiteSpace(source) ? "unspecified" : source;
|
||||
return new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("outcome", outcome),
|
||||
new KeyValuePair<string, object?>("source", source)
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
meter.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ using System.Text.Json;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Migration;
|
||||
|
||||
/// <summary>
|
||||
/// Converts MongoDB policy documents (as JSON) to migration data transfer objects.
|
||||
/// Converts legacy policy documents (as JSON) to migration data transfer objects.
|
||||
/// Task reference: PG-T4.9
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This converter handles the transformation of MongoDB document JSON exports
|
||||
/// This converter handles the transformation of legacy document JSON exports
|
||||
/// into DTOs suitable for PostgreSQL import. The caller is responsible for
|
||||
/// exporting MongoDB documents as JSON before passing them to this converter.
|
||||
/// exporting legacy documents as JSON before passing them to this converter.
|
||||
/// </remarks>
|
||||
public static class MongoDocumentConverter
|
||||
public static class LegacyDocumentConverter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -19,9 +19,9 @@ public static class MongoDocumentConverter
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Converts a MongoDB PolicyDocument (as JSON) to PackMigrationData.
|
||||
/// Converts a legacy PolicyDocument (as JSON) to PackMigrationData.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON representation of the MongoDB document.</param>
|
||||
/// <param name="json">The JSON representation of the legacy document.</param>
|
||||
/// <returns>Migration data transfer object.</returns>
|
||||
public static PackMigrationData ConvertPackFromJson(string json)
|
||||
{
|
||||
@@ -48,9 +48,9 @@ public static class MongoDocumentConverter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a MongoDB PolicyRevisionDocument (as JSON) to PackVersionMigrationData.
|
||||
/// Converts a legacy PolicyRevisionDocument (as JSON) to PackVersionMigrationData.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON representation of the MongoDB document.</param>
|
||||
/// <param name="json">The JSON representation of the legacy document.</param>
|
||||
/// <returns>Migration data transfer object.</returns>
|
||||
public static PackVersionMigrationData ConvertVersionFromJson(string json)
|
||||
{
|
||||
@@ -253,7 +253,7 @@ public static class MongoDocumentConverter
|
||||
return result;
|
||||
}
|
||||
|
||||
// Handle MongoDB extended JSON date format {"$date": ...}
|
||||
// Handle legacy extended JSON date format {"$date": ...}
|
||||
if (prop.ValueKind == JsonValueKind.Object && prop.TryGetProperty("$date", out var dateProp))
|
||||
{
|
||||
if (dateProp.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(dateProp.GetString(), out var dateResult))
|
||||
@@ -287,7 +287,7 @@ public static class MongoDocumentConverter
|
||||
return result;
|
||||
}
|
||||
|
||||
// Handle MongoDB extended JSON date format
|
||||
// Handle legacy extended JSON date format
|
||||
if (prop.ValueKind == JsonValueKind.Object && prop.TryGetProperty("$date", out var dateProp))
|
||||
{
|
||||
if (dateProp.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(dateProp.GetString(), out var dateResult))
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public interface IPolicyAuditRepository
|
||||
{
|
||||
Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default);
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public interface IPolicyAuditRepository
|
||||
{
|
||||
Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed class InMemoryPolicyAuditRepository : IPolicyAuditRepository
|
||||
{
|
||||
private readonly List<PolicyAuditEntry> _entries = new();
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public async Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (entry is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entry));
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_entries.Add(entry);
|
||||
_entries.Sort(static (left, right) => left.CreatedAt.CompareTo(right.CreatedAt));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
IEnumerable<PolicyAuditEntry> query = _entries;
|
||||
if (limit > 0)
|
||||
{
|
||||
query = query.TakeLast(limit);
|
||||
}
|
||||
|
||||
return query.ToImmutableArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed class InMemoryPolicyAuditRepository : IPolicyAuditRepository
|
||||
{
|
||||
private readonly List<PolicyAuditEntry> _entries = new();
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public async Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (entry is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entry));
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_entries.Add(entry);
|
||||
_entries.Sort(static (left, right) => left.CreatedAt.CompareTo(right.CreatedAt));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
IEnumerable<PolicyAuditEntry> query = _entries;
|
||||
if (limit > 0)
|
||||
{
|
||||
query = query.TakeLast(limit);
|
||||
}
|
||||
|
||||
return query.ToImmutableArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyAuditEntry(
|
||||
Guid Id,
|
||||
DateTimeOffset CreatedAt,
|
||||
string Action,
|
||||
string RevisionId,
|
||||
string Digest,
|
||||
string? Actor,
|
||||
string Message);
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyAuditEntry(
|
||||
Guid Id,
|
||||
DateTimeOffset CreatedAt,
|
||||
string Action,
|
||||
string RevisionId,
|
||||
string Digest,
|
||||
string? Actor,
|
||||
string Message);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,77 +1,77 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyDiagnosticsReport(
|
||||
string Version,
|
||||
int RuleCount,
|
||||
int ErrorCount,
|
||||
int WarningCount,
|
||||
DateTimeOffset GeneratedAt,
|
||||
ImmutableArray<PolicyIssue> Issues,
|
||||
ImmutableArray<string> Recommendations);
|
||||
|
||||
public static class PolicyDiagnostics
|
||||
{
|
||||
public static PolicyDiagnosticsReport Create(PolicyBindingResult bindingResult, TimeProvider? timeProvider = null)
|
||||
{
|
||||
if (bindingResult is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(bindingResult));
|
||||
}
|
||||
|
||||
var time = (timeProvider ?? TimeProvider.System).GetUtcNow();
|
||||
var errorCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Error);
|
||||
var warningCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Warning);
|
||||
|
||||
var recommendations = BuildRecommendations(bindingResult.Document, errorCount, warningCount);
|
||||
|
||||
return new PolicyDiagnosticsReport(
|
||||
bindingResult.Document.Version,
|
||||
bindingResult.Document.Rules.Length,
|
||||
errorCount,
|
||||
warningCount,
|
||||
time,
|
||||
bindingResult.Issues,
|
||||
recommendations);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> BuildRecommendations(PolicyDocument document, int errorCount, int warningCount)
|
||||
{
|
||||
var messages = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
if (errorCount > 0)
|
||||
{
|
||||
messages.Add("Resolve policy errors before promoting the revision; fallback rules may be applied while errors remain.");
|
||||
}
|
||||
|
||||
if (warningCount > 0)
|
||||
{
|
||||
messages.Add("Review policy warnings and ensure intentional overrides are documented.");
|
||||
}
|
||||
|
||||
if (document.Rules.Length == 0)
|
||||
{
|
||||
messages.Add("Add at least one policy rule to enforce gating logic.");
|
||||
}
|
||||
|
||||
var quietRules = document.Rules
|
||||
.Where(static rule => rule.Action.Quiet)
|
||||
.Select(static rule => rule.Name)
|
||||
.ToArray();
|
||||
|
||||
if (quietRules.Length > 0)
|
||||
{
|
||||
messages.Add($"Quiet rules detected ({string.Join(", ", quietRules)}); verify scoring behaviour aligns with expectations.");
|
||||
}
|
||||
|
||||
if (messages.Count == 0)
|
||||
{
|
||||
messages.Add("Policy validated successfully; no additional action required.");
|
||||
}
|
||||
|
||||
return messages.ToImmutable();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyDiagnosticsReport(
|
||||
string Version,
|
||||
int RuleCount,
|
||||
int ErrorCount,
|
||||
int WarningCount,
|
||||
DateTimeOffset GeneratedAt,
|
||||
ImmutableArray<PolicyIssue> Issues,
|
||||
ImmutableArray<string> Recommendations);
|
||||
|
||||
public static class PolicyDiagnostics
|
||||
{
|
||||
public static PolicyDiagnosticsReport Create(PolicyBindingResult bindingResult, TimeProvider? timeProvider = null)
|
||||
{
|
||||
if (bindingResult is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(bindingResult));
|
||||
}
|
||||
|
||||
var time = (timeProvider ?? TimeProvider.System).GetUtcNow();
|
||||
var errorCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Error);
|
||||
var warningCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Warning);
|
||||
|
||||
var recommendations = BuildRecommendations(bindingResult.Document, errorCount, warningCount);
|
||||
|
||||
return new PolicyDiagnosticsReport(
|
||||
bindingResult.Document.Version,
|
||||
bindingResult.Document.Rules.Length,
|
||||
errorCount,
|
||||
warningCount,
|
||||
time,
|
||||
bindingResult.Issues,
|
||||
recommendations);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> BuildRecommendations(PolicyDocument document, int errorCount, int warningCount)
|
||||
{
|
||||
var messages = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
if (errorCount > 0)
|
||||
{
|
||||
messages.Add("Resolve policy errors before promoting the revision; fallback rules may be applied while errors remain.");
|
||||
}
|
||||
|
||||
if (warningCount > 0)
|
||||
{
|
||||
messages.Add("Review policy warnings and ensure intentional overrides are documented.");
|
||||
}
|
||||
|
||||
if (document.Rules.Length == 0)
|
||||
{
|
||||
messages.Add("Add at least one policy rule to enforce gating logic.");
|
||||
}
|
||||
|
||||
var quietRules = document.Rules
|
||||
.Where(static rule => rule.Action.Quiet)
|
||||
.Select(static rule => rule.Name)
|
||||
.ToArray();
|
||||
|
||||
if (quietRules.Length > 0)
|
||||
{
|
||||
messages.Add($"Quiet rules detected ({string.Join(", ", quietRules)}); verify scoring behaviour aligns with expectations.");
|
||||
}
|
||||
|
||||
if (messages.Count == 0)
|
||||
{
|
||||
messages.Add("Policy validated successfully; no additional action required.");
|
||||
}
|
||||
|
||||
return messages.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyDigest
|
||||
{
|
||||
public static string Compute(PolicyDocument document)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
SkipValidation = true,
|
||||
}))
|
||||
{
|
||||
WriteDocument(writer, document);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer.WrittenSpan);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void WriteDocument(Utf8JsonWriter writer, PolicyDocument document)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("version", document.Version);
|
||||
|
||||
if (!document.Metadata.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("metadata");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in document.Metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WriteString(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WritePropertyName("rules");
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyDigest
|
||||
{
|
||||
public static string Compute(PolicyDocument document)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
SkipValidation = true,
|
||||
}))
|
||||
{
|
||||
WriteDocument(writer, document);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer.WrittenSpan);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void WriteDocument(Utf8JsonWriter writer, PolicyDocument document)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("version", document.Version);
|
||||
|
||||
if (!document.Metadata.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("metadata");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in document.Metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WriteString(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WritePropertyName("rules");
|
||||
writer.WriteStartArray();
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
@@ -90,143 +90,143 @@ public static class PolicyDigest
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
private static void WriteRule(Utf8JsonWriter writer, PolicyRule rule)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", rule.Name);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.Identifier))
|
||||
{
|
||||
writer.WriteString("id", rule.Identifier);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.Description))
|
||||
{
|
||||
writer.WriteString("description", rule.Description);
|
||||
}
|
||||
|
||||
WriteMetadata(writer, rule.Metadata);
|
||||
WriteSeverities(writer, rule.Severities);
|
||||
WriteStringArray(writer, "environments", rule.Environments);
|
||||
WriteStringArray(writer, "sources", rule.Sources);
|
||||
WriteStringArray(writer, "vendors", rule.Vendors);
|
||||
WriteStringArray(writer, "licenses", rule.Licenses);
|
||||
WriteStringArray(writer, "tags", rule.Tags);
|
||||
|
||||
if (!rule.Match.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("match");
|
||||
writer.WriteStartObject();
|
||||
WriteStringArray(writer, "images", rule.Match.Images);
|
||||
WriteStringArray(writer, "repositories", rule.Match.Repositories);
|
||||
WriteStringArray(writer, "packages", rule.Match.Packages);
|
||||
WriteStringArray(writer, "purls", rule.Match.Purls);
|
||||
WriteStringArray(writer, "cves", rule.Match.Cves);
|
||||
WriteStringArray(writer, "paths", rule.Match.Paths);
|
||||
WriteStringArray(writer, "layerDigests", rule.Match.LayerDigests);
|
||||
WriteStringArray(writer, "usedByEntrypoint", rule.Match.UsedByEntrypoint);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
WriteAction(writer, rule.Action);
|
||||
|
||||
if (rule.Expires is DateTimeOffset expires)
|
||||
{
|
||||
writer.WriteString("expires", expires.ToUniversalTime().ToString("O"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.Justification))
|
||||
{
|
||||
writer.WriteString("justification", rule.Justification);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteAction(Utf8JsonWriter writer, PolicyAction action)
|
||||
{
|
||||
writer.WritePropertyName("action");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", action.Type.ToString().ToLowerInvariant());
|
||||
|
||||
if (action.Quiet)
|
||||
{
|
||||
writer.WriteBoolean("quiet", true);
|
||||
}
|
||||
|
||||
if (action.Ignore is { } ignore)
|
||||
{
|
||||
if (ignore.Until is DateTimeOffset until)
|
||||
{
|
||||
writer.WriteString("until", until.ToUniversalTime().ToString("O"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ignore.Justification))
|
||||
{
|
||||
writer.WriteString("justification", ignore.Justification);
|
||||
}
|
||||
}
|
||||
|
||||
if (action.Escalate is { } escalate)
|
||||
{
|
||||
if (escalate.MinimumSeverity is { } severity)
|
||||
{
|
||||
writer.WriteString("severity", severity.ToString());
|
||||
}
|
||||
|
||||
if (escalate.RequireKev)
|
||||
{
|
||||
writer.WriteBoolean("kev", true);
|
||||
}
|
||||
|
||||
if (escalate.MinimumEpss is double epss)
|
||||
{
|
||||
writer.WriteNumber("epss", epss);
|
||||
}
|
||||
}
|
||||
|
||||
if (action.RequireVex is { } requireVex)
|
||||
{
|
||||
WriteStringArray(writer, "vendors", requireVex.Vendors);
|
||||
WriteStringArray(writer, "justifications", requireVex.Justifications);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteMetadata(Utf8JsonWriter writer, ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName("metadata");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WriteString(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteSeverities(Utf8JsonWriter writer, ImmutableArray<PolicySeverity> severities)
|
||||
{
|
||||
if (severities.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName("severity");
|
||||
writer.WriteStartArray();
|
||||
foreach (var severity in severities)
|
||||
{
|
||||
writer.WriteStringValue(severity.ToString());
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
|
||||
private static void WriteRule(Utf8JsonWriter writer, PolicyRule rule)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", rule.Name);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.Identifier))
|
||||
{
|
||||
writer.WriteString("id", rule.Identifier);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.Description))
|
||||
{
|
||||
writer.WriteString("description", rule.Description);
|
||||
}
|
||||
|
||||
WriteMetadata(writer, rule.Metadata);
|
||||
WriteSeverities(writer, rule.Severities);
|
||||
WriteStringArray(writer, "environments", rule.Environments);
|
||||
WriteStringArray(writer, "sources", rule.Sources);
|
||||
WriteStringArray(writer, "vendors", rule.Vendors);
|
||||
WriteStringArray(writer, "licenses", rule.Licenses);
|
||||
WriteStringArray(writer, "tags", rule.Tags);
|
||||
|
||||
if (!rule.Match.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("match");
|
||||
writer.WriteStartObject();
|
||||
WriteStringArray(writer, "images", rule.Match.Images);
|
||||
WriteStringArray(writer, "repositories", rule.Match.Repositories);
|
||||
WriteStringArray(writer, "packages", rule.Match.Packages);
|
||||
WriteStringArray(writer, "purls", rule.Match.Purls);
|
||||
WriteStringArray(writer, "cves", rule.Match.Cves);
|
||||
WriteStringArray(writer, "paths", rule.Match.Paths);
|
||||
WriteStringArray(writer, "layerDigests", rule.Match.LayerDigests);
|
||||
WriteStringArray(writer, "usedByEntrypoint", rule.Match.UsedByEntrypoint);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
WriteAction(writer, rule.Action);
|
||||
|
||||
if (rule.Expires is DateTimeOffset expires)
|
||||
{
|
||||
writer.WriteString("expires", expires.ToUniversalTime().ToString("O"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.Justification))
|
||||
{
|
||||
writer.WriteString("justification", rule.Justification);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteAction(Utf8JsonWriter writer, PolicyAction action)
|
||||
{
|
||||
writer.WritePropertyName("action");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", action.Type.ToString().ToLowerInvariant());
|
||||
|
||||
if (action.Quiet)
|
||||
{
|
||||
writer.WriteBoolean("quiet", true);
|
||||
}
|
||||
|
||||
if (action.Ignore is { } ignore)
|
||||
{
|
||||
if (ignore.Until is DateTimeOffset until)
|
||||
{
|
||||
writer.WriteString("until", until.ToUniversalTime().ToString("O"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ignore.Justification))
|
||||
{
|
||||
writer.WriteString("justification", ignore.Justification);
|
||||
}
|
||||
}
|
||||
|
||||
if (action.Escalate is { } escalate)
|
||||
{
|
||||
if (escalate.MinimumSeverity is { } severity)
|
||||
{
|
||||
writer.WriteString("severity", severity.ToString());
|
||||
}
|
||||
|
||||
if (escalate.RequireKev)
|
||||
{
|
||||
writer.WriteBoolean("kev", true);
|
||||
}
|
||||
|
||||
if (escalate.MinimumEpss is double epss)
|
||||
{
|
||||
writer.WriteNumber("epss", epss);
|
||||
}
|
||||
}
|
||||
|
||||
if (action.RequireVex is { } requireVex)
|
||||
{
|
||||
WriteStringArray(writer, "vendors", requireVex.Vendors);
|
||||
WriteStringArray(writer, "justifications", requireVex.Justifications);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteMetadata(Utf8JsonWriter writer, ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName("metadata");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WriteString(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteSeverities(Utf8JsonWriter writer, ImmutableArray<PolicySeverity> severities)
|
||||
{
|
||||
if (severities.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName("severity");
|
||||
writer.WriteStartArray();
|
||||
foreach (var severity in severities)
|
||||
{
|
||||
writer.WriteStringValue(severity.ToString());
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values)
|
||||
{
|
||||
if (values.IsDefaultOrEmpty)
|
||||
|
||||
@@ -23,164 +23,164 @@ public sealed record PolicyDocument(
|
||||
public static class PolicySchema
|
||||
{
|
||||
public const string SchemaId = "https://schemas.stella-ops.org/policy/policy-schema@1.json";
|
||||
public const string CurrentVersion = "1.0";
|
||||
|
||||
public static PolicyDocumentFormat DetectFormat(string fileName)
|
||||
{
|
||||
if (fileName is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(fileName));
|
||||
}
|
||||
|
||||
var lower = fileName.Trim().ToLowerInvariant();
|
||||
public const string CurrentVersion = "1.0";
|
||||
|
||||
public static PolicyDocumentFormat DetectFormat(string fileName)
|
||||
{
|
||||
if (fileName is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(fileName));
|
||||
}
|
||||
|
||||
var lower = fileName.Trim().ToLowerInvariant();
|
||||
if (lower.EndsWith(".yaml", StringComparison.Ordinal)
|
||||
|| lower.EndsWith(".yml", StringComparison.Ordinal)
|
||||
|| lower.EndsWith(".stella", StringComparison.Ordinal))
|
||||
{
|
||||
return PolicyDocumentFormat.Yaml;
|
||||
}
|
||||
|
||||
return PolicyDocumentFormat.Json;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PolicyRule(
|
||||
string Name,
|
||||
string? Identifier,
|
||||
string? Description,
|
||||
PolicyAction Action,
|
||||
ImmutableArray<PolicySeverity> Severities,
|
||||
ImmutableArray<string> Environments,
|
||||
ImmutableArray<string> Sources,
|
||||
ImmutableArray<string> Vendors,
|
||||
ImmutableArray<string> Licenses,
|
||||
ImmutableArray<string> Tags,
|
||||
PolicyRuleMatchCriteria Match,
|
||||
DateTimeOffset? Expires,
|
||||
string? Justification,
|
||||
ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public static PolicyRuleMatchCriteria EmptyMatch { get; } = new(
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
|
||||
public static PolicyRule Create(
|
||||
string name,
|
||||
PolicyAction action,
|
||||
ImmutableArray<PolicySeverity> severities,
|
||||
ImmutableArray<string> environments,
|
||||
ImmutableArray<string> sources,
|
||||
ImmutableArray<string> vendors,
|
||||
ImmutableArray<string> licenses,
|
||||
ImmutableArray<string> tags,
|
||||
PolicyRuleMatchCriteria match,
|
||||
DateTimeOffset? expires,
|
||||
string? justification,
|
||||
string? identifier = null,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null)
|
||||
{
|
||||
metadata ??= ImmutableDictionary<string, string>.Empty;
|
||||
return new PolicyRule(
|
||||
name,
|
||||
identifier,
|
||||
description,
|
||||
action,
|
||||
severities,
|
||||
environments,
|
||||
sources,
|
||||
vendors,
|
||||
licenses,
|
||||
tags,
|
||||
match,
|
||||
expires,
|
||||
justification,
|
||||
metadata);
|
||||
}
|
||||
|
||||
public bool MatchesAnyEnvironment => Environments.IsDefaultOrEmpty;
|
||||
}
|
||||
|
||||
public sealed record PolicyRuleMatchCriteria(
|
||||
ImmutableArray<string> Images,
|
||||
ImmutableArray<string> Repositories,
|
||||
ImmutableArray<string> Packages,
|
||||
ImmutableArray<string> Purls,
|
||||
ImmutableArray<string> Cves,
|
||||
ImmutableArray<string> Paths,
|
||||
ImmutableArray<string> LayerDigests,
|
||||
ImmutableArray<string> UsedByEntrypoint)
|
||||
{
|
||||
public static PolicyRuleMatchCriteria Create(
|
||||
ImmutableArray<string> images,
|
||||
ImmutableArray<string> repositories,
|
||||
ImmutableArray<string> packages,
|
||||
ImmutableArray<string> purls,
|
||||
ImmutableArray<string> cves,
|
||||
ImmutableArray<string> paths,
|
||||
ImmutableArray<string> layerDigests,
|
||||
ImmutableArray<string> usedByEntrypoint)
|
||||
=> new(
|
||||
images,
|
||||
repositories,
|
||||
packages,
|
||||
purls,
|
||||
cves,
|
||||
paths,
|
||||
layerDigests,
|
||||
usedByEntrypoint);
|
||||
|
||||
public static PolicyRuleMatchCriteria Empty { get; } = new(
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
|
||||
public bool IsEmpty =>
|
||||
Images.IsDefaultOrEmpty &&
|
||||
Repositories.IsDefaultOrEmpty &&
|
||||
Packages.IsDefaultOrEmpty &&
|
||||
Purls.IsDefaultOrEmpty &&
|
||||
Cves.IsDefaultOrEmpty &&
|
||||
Paths.IsDefaultOrEmpty &&
|
||||
LayerDigests.IsDefaultOrEmpty &&
|
||||
UsedByEntrypoint.IsDefaultOrEmpty;
|
||||
}
|
||||
|
||||
|
||||
return PolicyDocumentFormat.Json;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PolicyRule(
|
||||
string Name,
|
||||
string? Identifier,
|
||||
string? Description,
|
||||
PolicyAction Action,
|
||||
ImmutableArray<PolicySeverity> Severities,
|
||||
ImmutableArray<string> Environments,
|
||||
ImmutableArray<string> Sources,
|
||||
ImmutableArray<string> Vendors,
|
||||
ImmutableArray<string> Licenses,
|
||||
ImmutableArray<string> Tags,
|
||||
PolicyRuleMatchCriteria Match,
|
||||
DateTimeOffset? Expires,
|
||||
string? Justification,
|
||||
ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public static PolicyRuleMatchCriteria EmptyMatch { get; } = new(
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
|
||||
public static PolicyRule Create(
|
||||
string name,
|
||||
PolicyAction action,
|
||||
ImmutableArray<PolicySeverity> severities,
|
||||
ImmutableArray<string> environments,
|
||||
ImmutableArray<string> sources,
|
||||
ImmutableArray<string> vendors,
|
||||
ImmutableArray<string> licenses,
|
||||
ImmutableArray<string> tags,
|
||||
PolicyRuleMatchCriteria match,
|
||||
DateTimeOffset? expires,
|
||||
string? justification,
|
||||
string? identifier = null,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null)
|
||||
{
|
||||
metadata ??= ImmutableDictionary<string, string>.Empty;
|
||||
return new PolicyRule(
|
||||
name,
|
||||
identifier,
|
||||
description,
|
||||
action,
|
||||
severities,
|
||||
environments,
|
||||
sources,
|
||||
vendors,
|
||||
licenses,
|
||||
tags,
|
||||
match,
|
||||
expires,
|
||||
justification,
|
||||
metadata);
|
||||
}
|
||||
|
||||
public bool MatchesAnyEnvironment => Environments.IsDefaultOrEmpty;
|
||||
}
|
||||
|
||||
public sealed record PolicyRuleMatchCriteria(
|
||||
ImmutableArray<string> Images,
|
||||
ImmutableArray<string> Repositories,
|
||||
ImmutableArray<string> Packages,
|
||||
ImmutableArray<string> Purls,
|
||||
ImmutableArray<string> Cves,
|
||||
ImmutableArray<string> Paths,
|
||||
ImmutableArray<string> LayerDigests,
|
||||
ImmutableArray<string> UsedByEntrypoint)
|
||||
{
|
||||
public static PolicyRuleMatchCriteria Create(
|
||||
ImmutableArray<string> images,
|
||||
ImmutableArray<string> repositories,
|
||||
ImmutableArray<string> packages,
|
||||
ImmutableArray<string> purls,
|
||||
ImmutableArray<string> cves,
|
||||
ImmutableArray<string> paths,
|
||||
ImmutableArray<string> layerDigests,
|
||||
ImmutableArray<string> usedByEntrypoint)
|
||||
=> new(
|
||||
images,
|
||||
repositories,
|
||||
packages,
|
||||
purls,
|
||||
cves,
|
||||
paths,
|
||||
layerDigests,
|
||||
usedByEntrypoint);
|
||||
|
||||
public static PolicyRuleMatchCriteria Empty { get; } = new(
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
|
||||
public bool IsEmpty =>
|
||||
Images.IsDefaultOrEmpty &&
|
||||
Repositories.IsDefaultOrEmpty &&
|
||||
Packages.IsDefaultOrEmpty &&
|
||||
Purls.IsDefaultOrEmpty &&
|
||||
Cves.IsDefaultOrEmpty &&
|
||||
Paths.IsDefaultOrEmpty &&
|
||||
LayerDigests.IsDefaultOrEmpty &&
|
||||
UsedByEntrypoint.IsDefaultOrEmpty;
|
||||
}
|
||||
|
||||
public sealed record PolicyAction(
|
||||
PolicyActionType Type,
|
||||
PolicyIgnoreOptions? Ignore,
|
||||
PolicyEscalateOptions? Escalate,
|
||||
PolicyRequireVexOptions? RequireVex,
|
||||
bool Quiet);
|
||||
|
||||
public enum PolicyActionType
|
||||
{
|
||||
Block,
|
||||
Ignore,
|
||||
Warn,
|
||||
Defer,
|
||||
Escalate,
|
||||
RequireVex,
|
||||
}
|
||||
|
||||
public sealed record PolicyIgnoreOptions(DateTimeOffset? Until, string? Justification);
|
||||
|
||||
public sealed record PolicyEscalateOptions(
|
||||
PolicySeverity? MinimumSeverity,
|
||||
bool RequireKev,
|
||||
double? MinimumEpss);
|
||||
|
||||
|
||||
public enum PolicyActionType
|
||||
{
|
||||
Block,
|
||||
Ignore,
|
||||
Warn,
|
||||
Defer,
|
||||
Escalate,
|
||||
RequireVex,
|
||||
}
|
||||
|
||||
public sealed record PolicyIgnoreOptions(DateTimeOffset? Until, string? Justification);
|
||||
|
||||
public sealed record PolicyEscalateOptions(
|
||||
PolicySeverity? MinimumSeverity,
|
||||
bool RequireKev,
|
||||
double? MinimumEpss);
|
||||
|
||||
public sealed record PolicyRequireVexOptions(
|
||||
ImmutableArray<string> Vendors,
|
||||
ImmutableArray<string> Justifications);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,51 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyFinding(
|
||||
string FindingId,
|
||||
PolicySeverity Severity,
|
||||
string? Environment,
|
||||
string? Source,
|
||||
string? Vendor,
|
||||
string? License,
|
||||
string? Image,
|
||||
string? Repository,
|
||||
string? Package,
|
||||
string? Purl,
|
||||
string? Cve,
|
||||
string? Path,
|
||||
string? LayerDigest,
|
||||
ImmutableArray<string> Tags)
|
||||
{
|
||||
public static PolicyFinding Create(
|
||||
string findingId,
|
||||
PolicySeverity severity,
|
||||
string? environment = null,
|
||||
string? source = null,
|
||||
string? vendor = null,
|
||||
string? license = null,
|
||||
string? image = null,
|
||||
string? repository = null,
|
||||
string? package = null,
|
||||
string? purl = null,
|
||||
string? cve = null,
|
||||
string? path = null,
|
||||
string? layerDigest = null,
|
||||
ImmutableArray<string>? tags = null)
|
||||
=> new(
|
||||
findingId,
|
||||
severity,
|
||||
environment,
|
||||
source,
|
||||
vendor,
|
||||
license,
|
||||
image,
|
||||
repository,
|
||||
package,
|
||||
purl,
|
||||
cve,
|
||||
path,
|
||||
layerDigest,
|
||||
tags ?? ImmutableArray<string>.Empty);
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyFinding(
|
||||
string FindingId,
|
||||
PolicySeverity Severity,
|
||||
string? Environment,
|
||||
string? Source,
|
||||
string? Vendor,
|
||||
string? License,
|
||||
string? Image,
|
||||
string? Repository,
|
||||
string? Package,
|
||||
string? Purl,
|
||||
string? Cve,
|
||||
string? Path,
|
||||
string? LayerDigest,
|
||||
ImmutableArray<string> Tags)
|
||||
{
|
||||
public static PolicyFinding Create(
|
||||
string findingId,
|
||||
PolicySeverity severity,
|
||||
string? environment = null,
|
||||
string? source = null,
|
||||
string? vendor = null,
|
||||
string? license = null,
|
||||
string? image = null,
|
||||
string? repository = null,
|
||||
string? package = null,
|
||||
string? purl = null,
|
||||
string? cve = null,
|
||||
string? path = null,
|
||||
string? layerDigest = null,
|
||||
ImmutableArray<string>? tags = null)
|
||||
=> new(
|
||||
findingId,
|
||||
severity,
|
||||
environment,
|
||||
source,
|
||||
vendor,
|
||||
license,
|
||||
image,
|
||||
repository,
|
||||
package,
|
||||
purl,
|
||||
cve,
|
||||
path,
|
||||
layerDigest,
|
||||
tags ?? ImmutableArray<string>.Empty);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a validation or normalization issue discovered while processing a policy document.
|
||||
/// </summary>
|
||||
public sealed record PolicyIssue(string Code, string Message, PolicyIssueSeverity Severity, string Path)
|
||||
{
|
||||
public static PolicyIssue Error(string code, string message, string path)
|
||||
=> new(code, message, PolicyIssueSeverity.Error, path);
|
||||
|
||||
public static PolicyIssue Warning(string code, string message, string path)
|
||||
=> new(code, message, PolicyIssueSeverity.Warning, path);
|
||||
|
||||
public static PolicyIssue Info(string code, string message, string path)
|
||||
=> new(code, message, PolicyIssueSeverity.Info, path);
|
||||
|
||||
public PolicyIssue EnsurePath(string fallbackPath)
|
||||
=> string.IsNullOrWhiteSpace(Path) ? this with { Path = fallbackPath } : this;
|
||||
}
|
||||
|
||||
public enum PolicyIssueSeverity
|
||||
{
|
||||
Error,
|
||||
Warning,
|
||||
Info,
|
||||
}
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a validation or normalization issue discovered while processing a policy document.
|
||||
/// </summary>
|
||||
public sealed record PolicyIssue(string Code, string Message, PolicyIssueSeverity Severity, string Path)
|
||||
{
|
||||
public static PolicyIssue Error(string code, string message, string path)
|
||||
=> new(code, message, PolicyIssueSeverity.Error, path);
|
||||
|
||||
public static PolicyIssue Warning(string code, string message, string path)
|
||||
=> new(code, message, PolicyIssueSeverity.Warning, path);
|
||||
|
||||
public static PolicyIssue Info(string code, string message, string path)
|
||||
=> new(code, message, PolicyIssueSeverity.Info, path);
|
||||
|
||||
public PolicyIssue EnsurePath(string fallbackPath)
|
||||
=> string.IsNullOrWhiteSpace(Path) ? this with { Path = fallbackPath } : this;
|
||||
}
|
||||
|
||||
public enum PolicyIssueSeverity
|
||||
{
|
||||
Error,
|
||||
Warning,
|
||||
Info,
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyPreviewRequest(
|
||||
string ImageDigest,
|
||||
ImmutableArray<PolicyFinding> Findings,
|
||||
ImmutableArray<PolicyVerdict> BaselineVerdicts,
|
||||
PolicySnapshot? SnapshotOverride = null,
|
||||
PolicySnapshotContent? ProposedPolicy = null);
|
||||
|
||||
public sealed record PolicyPreviewResponse(
|
||||
bool Success,
|
||||
string PolicyDigest,
|
||||
string? RevisionId,
|
||||
ImmutableArray<PolicyIssue> Issues,
|
||||
ImmutableArray<PolicyVerdictDiff> Diffs,
|
||||
int ChangedCount);
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyPreviewRequest(
|
||||
string ImageDigest,
|
||||
ImmutableArray<PolicyFinding> Findings,
|
||||
ImmutableArray<PolicyVerdict> BaselineVerdicts,
|
||||
PolicySnapshot? SnapshotOverride = null,
|
||||
PolicySnapshotContent? ProposedPolicy = null);
|
||||
|
||||
public sealed record PolicyPreviewResponse(
|
||||
bool Success,
|
||||
string PolicyDigest,
|
||||
string? RevisionId,
|
||||
ImmutableArray<PolicyIssue> Issues,
|
||||
ImmutableArray<PolicyVerdictDiff> Diffs,
|
||||
int ChangedCount);
|
||||
|
||||
@@ -1,142 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed class PolicyPreviewService
|
||||
{
|
||||
private readonly PolicySnapshotStore _snapshotStore;
|
||||
private readonly ILogger<PolicyPreviewService> _logger;
|
||||
|
||||
public PolicyPreviewService(PolicySnapshotStore snapshotStore, ILogger<PolicyPreviewService> logger)
|
||||
{
|
||||
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<PolicyPreviewResponse> PreviewAsync(PolicyPreviewRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var (snapshot, bindingIssues) = await ResolveSnapshotAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
_logger.LogWarning("Policy preview failed: snapshot unavailable or validation errors. Issues={Count}", bindingIssues.Length);
|
||||
return new PolicyPreviewResponse(false, string.Empty, null, bindingIssues, ImmutableArray<PolicyVerdictDiff>.Empty, 0);
|
||||
}
|
||||
|
||||
var projected = Evaluate(snapshot.Document, snapshot.ScoringConfig, request.Findings);
|
||||
var baseline = BuildBaseline(request.BaselineVerdicts, projected, snapshot.ScoringConfig);
|
||||
var diffs = BuildDiffs(baseline, projected);
|
||||
var changed = diffs.Count(static diff => diff.Changed);
|
||||
|
||||
_logger.LogDebug("Policy preview computed for {ImageDigest}. Changed={Changed}", request.ImageDigest, changed);
|
||||
|
||||
return new PolicyPreviewResponse(true, snapshot.Digest, snapshot.RevisionId, bindingIssues, diffs, changed);
|
||||
}
|
||||
|
||||
private async Task<(PolicySnapshot? Snapshot, ImmutableArray<PolicyIssue> Issues)> ResolveSnapshotAsync(PolicyPreviewRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ProposedPolicy is not null)
|
||||
{
|
||||
var binding = PolicyBinder.Bind(request.ProposedPolicy.Content, request.ProposedPolicy.Format);
|
||||
if (!binding.Success)
|
||||
{
|
||||
return (null, binding.Issues);
|
||||
}
|
||||
|
||||
var digest = PolicyDigest.Compute(binding.Document);
|
||||
var snapshot = new PolicySnapshot(
|
||||
request.SnapshotOverride?.RevisionNumber + 1 ?? 0,
|
||||
request.SnapshotOverride?.RevisionId ?? "preview",
|
||||
digest,
|
||||
DateTimeOffset.UtcNow,
|
||||
request.ProposedPolicy.Actor,
|
||||
request.ProposedPolicy.Format,
|
||||
binding.Document,
|
||||
binding.Issues,
|
||||
PolicyScoringConfig.Default);
|
||||
|
||||
return (snapshot, binding.Issues);
|
||||
}
|
||||
|
||||
if (request.SnapshotOverride is not null)
|
||||
{
|
||||
return (request.SnapshotOverride, ImmutableArray<PolicyIssue>.Empty);
|
||||
}
|
||||
|
||||
var latest = await _snapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (latest is not null)
|
||||
{
|
||||
return (latest, ImmutableArray<PolicyIssue>.Empty);
|
||||
}
|
||||
|
||||
return (null, ImmutableArray.Create(PolicyIssue.Error("policy.preview.snapshot_missing", "No policy snapshot is available for preview.", "$")));
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyVerdict> Evaluate(PolicyDocument document, PolicyScoringConfig scoringConfig, ImmutableArray<PolicyFinding> findings)
|
||||
{
|
||||
if (findings.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<PolicyVerdict>.Empty;
|
||||
}
|
||||
|
||||
var results = ImmutableArray.CreateBuilder<PolicyVerdict>(findings.Length);
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed class PolicyPreviewService
|
||||
{
|
||||
private readonly PolicySnapshotStore _snapshotStore;
|
||||
private readonly ILogger<PolicyPreviewService> _logger;
|
||||
|
||||
public PolicyPreviewService(PolicySnapshotStore snapshotStore, ILogger<PolicyPreviewService> logger)
|
||||
{
|
||||
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<PolicyPreviewResponse> PreviewAsync(PolicyPreviewRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var (snapshot, bindingIssues) = await ResolveSnapshotAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
_logger.LogWarning("Policy preview failed: snapshot unavailable or validation errors. Issues={Count}", bindingIssues.Length);
|
||||
return new PolicyPreviewResponse(false, string.Empty, null, bindingIssues, ImmutableArray<PolicyVerdictDiff>.Empty, 0);
|
||||
}
|
||||
|
||||
var projected = Evaluate(snapshot.Document, snapshot.ScoringConfig, request.Findings);
|
||||
var baseline = BuildBaseline(request.BaselineVerdicts, projected, snapshot.ScoringConfig);
|
||||
var diffs = BuildDiffs(baseline, projected);
|
||||
var changed = diffs.Count(static diff => diff.Changed);
|
||||
|
||||
_logger.LogDebug("Policy preview computed for {ImageDigest}. Changed={Changed}", request.ImageDigest, changed);
|
||||
|
||||
return new PolicyPreviewResponse(true, snapshot.Digest, snapshot.RevisionId, bindingIssues, diffs, changed);
|
||||
}
|
||||
|
||||
private async Task<(PolicySnapshot? Snapshot, ImmutableArray<PolicyIssue> Issues)> ResolveSnapshotAsync(PolicyPreviewRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ProposedPolicy is not null)
|
||||
{
|
||||
var binding = PolicyBinder.Bind(request.ProposedPolicy.Content, request.ProposedPolicy.Format);
|
||||
if (!binding.Success)
|
||||
{
|
||||
return (null, binding.Issues);
|
||||
}
|
||||
|
||||
var digest = PolicyDigest.Compute(binding.Document);
|
||||
var snapshot = new PolicySnapshot(
|
||||
request.SnapshotOverride?.RevisionNumber + 1 ?? 0,
|
||||
request.SnapshotOverride?.RevisionId ?? "preview",
|
||||
digest,
|
||||
DateTimeOffset.UtcNow,
|
||||
request.ProposedPolicy.Actor,
|
||||
request.ProposedPolicy.Format,
|
||||
binding.Document,
|
||||
binding.Issues,
|
||||
PolicyScoringConfig.Default);
|
||||
|
||||
return (snapshot, binding.Issues);
|
||||
}
|
||||
|
||||
if (request.SnapshotOverride is not null)
|
||||
{
|
||||
return (request.SnapshotOverride, ImmutableArray<PolicyIssue>.Empty);
|
||||
}
|
||||
|
||||
var latest = await _snapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (latest is not null)
|
||||
{
|
||||
return (latest, ImmutableArray<PolicyIssue>.Empty);
|
||||
}
|
||||
|
||||
return (null, ImmutableArray.Create(PolicyIssue.Error("policy.preview.snapshot_missing", "No policy snapshot is available for preview.", "$")));
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyVerdict> Evaluate(PolicyDocument document, PolicyScoringConfig scoringConfig, ImmutableArray<PolicyFinding> findings)
|
||||
{
|
||||
if (findings.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<PolicyVerdict>.Empty;
|
||||
}
|
||||
|
||||
var results = ImmutableArray.CreateBuilder<PolicyVerdict>(findings.Length);
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
var verdict = PolicyEvaluation.EvaluateFinding(document, scoringConfig, finding, out _);
|
||||
results.Add(verdict);
|
||||
}
|
||||
|
||||
return results.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, PolicyVerdict> BuildBaseline(ImmutableArray<PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected, PolicyScoringConfig scoringConfig)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, PolicyVerdict>(StringComparer.Ordinal);
|
||||
if (!baseline.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var verdict in baseline)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(verdict.FindingId) && !builder.ContainsKey(verdict.FindingId))
|
||||
{
|
||||
builder.Add(verdict.FindingId, verdict);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var verdict in projected)
|
||||
{
|
||||
if (!builder.ContainsKey(verdict.FindingId))
|
||||
{
|
||||
builder.Add(verdict.FindingId, PolicyVerdict.CreateBaseline(verdict.FindingId, scoringConfig));
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyVerdictDiff> BuildDiffs(ImmutableDictionary<string, PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected)
|
||||
{
|
||||
var diffs = ImmutableArray.CreateBuilder<PolicyVerdictDiff>(projected.Length);
|
||||
foreach (var verdict in projected.OrderBy(static v => v.FindingId, StringComparer.Ordinal))
|
||||
{
|
||||
var baseVerdict = baseline.TryGetValue(verdict.FindingId, out var existing)
|
||||
? existing
|
||||
: new PolicyVerdict(verdict.FindingId, PolicyVerdictStatus.Pass);
|
||||
|
||||
diffs.Add(new PolicyVerdictDiff(baseVerdict, verdict));
|
||||
}
|
||||
|
||||
return diffs.ToImmutable();
|
||||
}
|
||||
}
|
||||
results.Add(verdict);
|
||||
}
|
||||
|
||||
return results.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, PolicyVerdict> BuildBaseline(ImmutableArray<PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected, PolicyScoringConfig scoringConfig)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, PolicyVerdict>(StringComparer.Ordinal);
|
||||
if (!baseline.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var verdict in baseline)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(verdict.FindingId) && !builder.ContainsKey(verdict.FindingId))
|
||||
{
|
||||
builder.Add(verdict.FindingId, verdict);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var verdict in projected)
|
||||
{
|
||||
if (!builder.ContainsKey(verdict.FindingId))
|
||||
{
|
||||
builder.Add(verdict.FindingId, PolicyVerdict.CreateBaseline(verdict.FindingId, scoringConfig));
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyVerdictDiff> BuildDiffs(ImmutableDictionary<string, PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected)
|
||||
{
|
||||
var diffs = ImmutableArray.CreateBuilder<PolicyVerdictDiff>(projected.Length);
|
||||
foreach (var verdict in projected.OrderBy(static v => v.FindingId, StringComparer.Ordinal))
|
||||
{
|
||||
var baseVerdict = baseline.TryGetValue(verdict.FindingId, out var existing)
|
||||
? existing
|
||||
: new PolicyVerdict(verdict.FindingId, PolicyVerdictStatus.Pass);
|
||||
|
||||
diffs.Add(new PolicyVerdictDiff(baseVerdict, verdict));
|
||||
}
|
||||
|
||||
return diffs.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicySchemaResource
|
||||
{
|
||||
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-schema@1.json";
|
||||
|
||||
public static Stream OpenSchemaStream()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var stream = assembly.GetManifestResourceStream(SchemaResourceName);
|
||||
if (stream is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to locate embedded schema resource '{SchemaResourceName}'.");
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
public static string ReadSchemaJson()
|
||||
{
|
||||
using var stream = OpenSchemaStream();
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicySchemaResource
|
||||
{
|
||||
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-schema@1.json";
|
||||
|
||||
public static Stream OpenSchemaStream()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var stream = assembly.GetManifestResourceStream(SchemaResourceName);
|
||||
if (stream is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to locate embedded schema resource '{SchemaResourceName}'.");
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
public static string ReadSchemaJson()
|
||||
{
|
||||
using var stream = OpenSchemaStream();
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyScoringConfig(
|
||||
string Version,
|
||||
ImmutableDictionary<PolicySeverity, double> SeverityWeights,
|
||||
double QuietPenalty,
|
||||
double WarnPenalty,
|
||||
double IgnorePenalty,
|
||||
ImmutableDictionary<string, double> TrustOverrides,
|
||||
ImmutableDictionary<string, double> ReachabilityBuckets,
|
||||
PolicyUnknownConfidenceConfig UnknownConfidence)
|
||||
{
|
||||
public static string BaselineVersion => "1.0";
|
||||
|
||||
public static PolicyScoringConfig Default { get; } = PolicyScoringConfigBinder.LoadDefault();
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyScoringConfig(
|
||||
string Version,
|
||||
ImmutableDictionary<PolicySeverity, double> SeverityWeights,
|
||||
double QuietPenalty,
|
||||
double WarnPenalty,
|
||||
double IgnorePenalty,
|
||||
ImmutableDictionary<string, double> TrustOverrides,
|
||||
ImmutableDictionary<string, double> ReachabilityBuckets,
|
||||
PolicyUnknownConfidenceConfig UnknownConfidence)
|
||||
{
|
||||
public static string BaselineVersion => "1.0";
|
||||
|
||||
public static PolicyScoringConfig Default { get; } = PolicyScoringConfigBinder.LoadDefault();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +1,100 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyScoringConfigDigest
|
||||
{
|
||||
public static string Compute(PolicyScoringConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
SkipValidation = true,
|
||||
}))
|
||||
{
|
||||
WriteConfig(writer, config);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer.WrittenSpan);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void WriteConfig(Utf8JsonWriter writer, PolicyScoringConfig config)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("version", config.Version);
|
||||
|
||||
writer.WritePropertyName("severityWeights");
|
||||
writer.WriteStartObject();
|
||||
foreach (var severity in Enum.GetValues<PolicySeverity>())
|
||||
{
|
||||
var key = severity.ToString();
|
||||
var value = config.SeverityWeights.TryGetValue(severity, out var weight) ? weight : 0;
|
||||
writer.WriteNumber(key, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
|
||||
writer.WriteNumber("quietPenalty", config.QuietPenalty);
|
||||
writer.WriteNumber("warnPenalty", config.WarnPenalty);
|
||||
writer.WriteNumber("ignorePenalty", config.IgnorePenalty);
|
||||
|
||||
if (!config.TrustOverrides.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("trustOverrides");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in config.TrustOverrides.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteNumber(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
if (!config.ReachabilityBuckets.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("reachabilityBuckets");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in config.ReachabilityBuckets.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteNumber(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WritePropertyName("unknownConfidence");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteNumber("initial", config.UnknownConfidence.Initial);
|
||||
writer.WriteNumber("decayPerDay", config.UnknownConfidence.DecayPerDay);
|
||||
writer.WriteNumber("floor", config.UnknownConfidence.Floor);
|
||||
|
||||
if (!config.UnknownConfidence.Bands.IsDefaultOrEmpty)
|
||||
{
|
||||
writer.WritePropertyName("bands");
|
||||
writer.WriteStartArray();
|
||||
foreach (var band in config.UnknownConfidence.Bands
|
||||
.OrderByDescending(static b => b.Min)
|
||||
.ThenBy(static b => b.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", band.Name);
|
||||
writer.WriteNumber("min", band.Min);
|
||||
if (!string.IsNullOrWhiteSpace(band.Description))
|
||||
{
|
||||
writer.WriteString("description", band.Description);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyScoringConfigDigest
|
||||
{
|
||||
public static string Compute(PolicyScoringConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
SkipValidation = true,
|
||||
}))
|
||||
{
|
||||
WriteConfig(writer, config);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer.WrittenSpan);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void WriteConfig(Utf8JsonWriter writer, PolicyScoringConfig config)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("version", config.Version);
|
||||
|
||||
writer.WritePropertyName("severityWeights");
|
||||
writer.WriteStartObject();
|
||||
foreach (var severity in Enum.GetValues<PolicySeverity>())
|
||||
{
|
||||
var key = severity.ToString();
|
||||
var value = config.SeverityWeights.TryGetValue(severity, out var weight) ? weight : 0;
|
||||
writer.WriteNumber(key, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
|
||||
writer.WriteNumber("quietPenalty", config.QuietPenalty);
|
||||
writer.WriteNumber("warnPenalty", config.WarnPenalty);
|
||||
writer.WriteNumber("ignorePenalty", config.IgnorePenalty);
|
||||
|
||||
if (!config.TrustOverrides.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("trustOverrides");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in config.TrustOverrides.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteNumber(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
if (!config.ReachabilityBuckets.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("reachabilityBuckets");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in config.ReachabilityBuckets.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteNumber(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WritePropertyName("unknownConfidence");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteNumber("initial", config.UnknownConfidence.Initial);
|
||||
writer.WriteNumber("decayPerDay", config.UnknownConfidence.DecayPerDay);
|
||||
writer.WriteNumber("floor", config.UnknownConfidence.Floor);
|
||||
|
||||
if (!config.UnknownConfidence.Bands.IsDefaultOrEmpty)
|
||||
{
|
||||
writer.WritePropertyName("bands");
|
||||
writer.WriteStartArray();
|
||||
foreach (var band in config.UnknownConfidence.Bands
|
||||
.OrderByDescending(static b => b.Min)
|
||||
.ThenBy(static b => b.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", band.Name);
|
||||
writer.WriteNumber("min", band.Min);
|
||||
if (!string.IsNullOrWhiteSpace(band.Description))
|
||||
{
|
||||
writer.WriteString("description", band.Description);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Json.Schema;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyScoringSchema
|
||||
{
|
||||
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-scoring-schema@1.json";
|
||||
|
||||
private static readonly Lazy<JsonSchema> CachedSchema = new(LoadSchema, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
public static JsonSchema Schema => CachedSchema.Value;
|
||||
|
||||
private static JsonSchema LoadSchema()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
using var stream = assembly.GetManifestResourceStream(SchemaResourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource '{SchemaResourceName}' was not found.");
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
var schemaJson = reader.ReadToEnd();
|
||||
return JsonSchema.FromText(schemaJson);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Json.Schema;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyScoringSchema
|
||||
{
|
||||
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-scoring-schema@1.json";
|
||||
|
||||
private static readonly Lazy<JsonSchema> CachedSchema = new(LoadSchema, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
public static JsonSchema Schema => CachedSchema.Value;
|
||||
|
||||
private static JsonSchema LoadSchema()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
using var stream = assembly.GetManifestResourceStream(SchemaResourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource '{SchemaResourceName}' was not found.");
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
var schemaJson = reader.ReadToEnd();
|
||||
return JsonSchema.FromText(schemaJson);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicySnapshot(
|
||||
long RevisionNumber,
|
||||
string RevisionId,
|
||||
string Digest,
|
||||
DateTimeOffset CreatedAt,
|
||||
string? CreatedBy,
|
||||
PolicyDocumentFormat Format,
|
||||
PolicyDocument Document,
|
||||
ImmutableArray<PolicyIssue> Issues,
|
||||
PolicyScoringConfig ScoringConfig);
|
||||
|
||||
public sealed record PolicySnapshotContent(
|
||||
string Content,
|
||||
PolicyDocumentFormat Format,
|
||||
string? Actor,
|
||||
string? Source,
|
||||
string? Description);
|
||||
|
||||
public sealed record PolicySnapshotSaveResult(
|
||||
bool Success,
|
||||
bool Created,
|
||||
string Digest,
|
||||
PolicySnapshot? Snapshot,
|
||||
PolicyBindingResult BindingResult);
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicySnapshot(
|
||||
long RevisionNumber,
|
||||
string RevisionId,
|
||||
string Digest,
|
||||
DateTimeOffset CreatedAt,
|
||||
string? CreatedBy,
|
||||
PolicyDocumentFormat Format,
|
||||
PolicyDocument Document,
|
||||
ImmutableArray<PolicyIssue> Issues,
|
||||
PolicyScoringConfig ScoringConfig);
|
||||
|
||||
public sealed record PolicySnapshotContent(
|
||||
string Content,
|
||||
PolicyDocumentFormat Format,
|
||||
string? Actor,
|
||||
string? Source,
|
||||
string? Description);
|
||||
|
||||
public sealed record PolicySnapshotSaveResult(
|
||||
bool Success,
|
||||
bool Created,
|
||||
string Digest,
|
||||
PolicySnapshot? Snapshot,
|
||||
PolicyBindingResult BindingResult);
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed class PolicySnapshotStore
|
||||
{
|
||||
private readonly IPolicySnapshotRepository _snapshotRepository;
|
||||
private readonly IPolicyAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PolicySnapshotStore> _logger;
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public PolicySnapshotStore(
|
||||
IPolicySnapshotRepository snapshotRepository,
|
||||
IPolicyAuditRepository auditRepository,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<PolicySnapshotStore> logger)
|
||||
{
|
||||
_snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<PolicySnapshotSaveResult> SaveAsync(PolicySnapshotContent content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (content is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(content));
|
||||
}
|
||||
|
||||
var bindingResult = PolicyBinder.Bind(content.Content, content.Format);
|
||||
if (!bindingResult.Success)
|
||||
{
|
||||
_logger.LogWarning("Policy snapshot rejected due to validation errors (Format: {Format})", content.Format);
|
||||
return new PolicySnapshotSaveResult(false, false, string.Empty, null, bindingResult);
|
||||
}
|
||||
|
||||
var digest = PolicyDigest.Compute(bindingResult.Document);
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var latest = await _snapshotRepository.GetLatestAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (latest is not null && string.Equals(latest.Digest, digest, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogInformation("Policy snapshot unchanged; digest {Digest} matches revision {RevisionId}", digest, latest.RevisionId);
|
||||
return new PolicySnapshotSaveResult(true, false, digest, latest, bindingResult);
|
||||
}
|
||||
|
||||
var revisionNumber = (latest?.RevisionNumber ?? 0) + 1;
|
||||
var revisionId = $"rev-{revisionNumber}";
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var scoringConfig = PolicyScoringConfig.Default;
|
||||
|
||||
var snapshot = new PolicySnapshot(
|
||||
revisionNumber,
|
||||
revisionId,
|
||||
digest,
|
||||
createdAt,
|
||||
content.Actor,
|
||||
content.Format,
|
||||
bindingResult.Document,
|
||||
bindingResult.Issues,
|
||||
scoringConfig);
|
||||
|
||||
await _snapshotRepository.AddAsync(snapshot, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var auditMessage = content.Description ?? "Policy snapshot created";
|
||||
var auditEntry = new PolicyAuditEntry(
|
||||
Guid.NewGuid(),
|
||||
createdAt,
|
||||
"snapshot.created",
|
||||
revisionId,
|
||||
digest,
|
||||
content.Actor,
|
||||
auditMessage);
|
||||
|
||||
await _auditRepository.AddAsync(auditEntry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Policy snapshot saved. Revision {RevisionId}, digest {Digest}, issues {IssueCount}",
|
||||
revisionId,
|
||||
digest,
|
||||
bindingResult.Issues.Length);
|
||||
|
||||
return new PolicySnapshotSaveResult(true, true, digest, snapshot, bindingResult);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
|
||||
=> _snapshotRepository.GetLatestAsync(cancellationToken);
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed class PolicySnapshotStore
|
||||
{
|
||||
private readonly IPolicySnapshotRepository _snapshotRepository;
|
||||
private readonly IPolicyAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PolicySnapshotStore> _logger;
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public PolicySnapshotStore(
|
||||
IPolicySnapshotRepository snapshotRepository,
|
||||
IPolicyAuditRepository auditRepository,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<PolicySnapshotStore> logger)
|
||||
{
|
||||
_snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<PolicySnapshotSaveResult> SaveAsync(PolicySnapshotContent content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (content is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(content));
|
||||
}
|
||||
|
||||
var bindingResult = PolicyBinder.Bind(content.Content, content.Format);
|
||||
if (!bindingResult.Success)
|
||||
{
|
||||
_logger.LogWarning("Policy snapshot rejected due to validation errors (Format: {Format})", content.Format);
|
||||
return new PolicySnapshotSaveResult(false, false, string.Empty, null, bindingResult);
|
||||
}
|
||||
|
||||
var digest = PolicyDigest.Compute(bindingResult.Document);
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var latest = await _snapshotRepository.GetLatestAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (latest is not null && string.Equals(latest.Digest, digest, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogInformation("Policy snapshot unchanged; digest {Digest} matches revision {RevisionId}", digest, latest.RevisionId);
|
||||
return new PolicySnapshotSaveResult(true, false, digest, latest, bindingResult);
|
||||
}
|
||||
|
||||
var revisionNumber = (latest?.RevisionNumber ?? 0) + 1;
|
||||
var revisionId = $"rev-{revisionNumber}";
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var scoringConfig = PolicyScoringConfig.Default;
|
||||
|
||||
var snapshot = new PolicySnapshot(
|
||||
revisionNumber,
|
||||
revisionId,
|
||||
digest,
|
||||
createdAt,
|
||||
content.Actor,
|
||||
content.Format,
|
||||
bindingResult.Document,
|
||||
bindingResult.Issues,
|
||||
scoringConfig);
|
||||
|
||||
await _snapshotRepository.AddAsync(snapshot, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var auditMessage = content.Description ?? "Policy snapshot created";
|
||||
var auditEntry = new PolicyAuditEntry(
|
||||
Guid.NewGuid(),
|
||||
createdAt,
|
||||
"snapshot.created",
|
||||
revisionId,
|
||||
digest,
|
||||
content.Actor,
|
||||
auditMessage);
|
||||
|
||||
await _auditRepository.AddAsync(auditEntry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Policy snapshot saved. Revision {RevisionId}, digest {Digest}, issues {IssueCount}",
|
||||
revisionId,
|
||||
digest,
|
||||
bindingResult.Issues.Length);
|
||||
|
||||
return new PolicySnapshotSaveResult(true, true, digest, snapshot, bindingResult);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
|
||||
=> _snapshotRepository.GetLatestAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyUnknownConfidenceConfig(
|
||||
double Initial,
|
||||
double DecayPerDay,
|
||||
double Floor,
|
||||
ImmutableArray<PolicyUnknownConfidenceBand> Bands)
|
||||
{
|
||||
public double Clamp(double value)
|
||||
=> Math.Clamp(value, Floor, 1.0);
|
||||
|
||||
public PolicyUnknownConfidenceBand ResolveBand(double value)
|
||||
{
|
||||
if (Bands.IsDefaultOrEmpty)
|
||||
{
|
||||
return PolicyUnknownConfidenceBand.Default;
|
||||
}
|
||||
|
||||
foreach (var band in Bands)
|
||||
{
|
||||
if (value >= band.Min)
|
||||
{
|
||||
return band;
|
||||
}
|
||||
}
|
||||
|
||||
return Bands[Bands.Length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PolicyUnknownConfidenceBand(string Name, double Min, string? Description = null)
|
||||
{
|
||||
public static PolicyUnknownConfidenceBand Default { get; } = new("unspecified", 0, null);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyUnknownConfidenceConfig(
|
||||
double Initial,
|
||||
double DecayPerDay,
|
||||
double Floor,
|
||||
ImmutableArray<PolicyUnknownConfidenceBand> Bands)
|
||||
{
|
||||
public double Clamp(double value)
|
||||
=> Math.Clamp(value, Floor, 1.0);
|
||||
|
||||
public PolicyUnknownConfidenceBand ResolveBand(double value)
|
||||
{
|
||||
if (Bands.IsDefaultOrEmpty)
|
||||
{
|
||||
return PolicyUnknownConfidenceBand.Default;
|
||||
}
|
||||
|
||||
foreach (var band in Bands)
|
||||
{
|
||||
if (value >= band.Min)
|
||||
{
|
||||
return band;
|
||||
}
|
||||
}
|
||||
|
||||
return Bands[Bands.Length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PolicyUnknownConfidenceBand(string Name, double Min, string? Description = null)
|
||||
{
|
||||
public static PolicyUnknownConfidenceBand Default { get; } = new("unspecified", 0, null);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyValidationCliOptions
|
||||
{
|
||||
public IReadOnlyList<string> Inputs { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Writes machine-readable JSON instead of human-formatted text.
|
||||
/// </summary>
|
||||
public bool OutputJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When enabled, warnings cause a non-zero exit code.
|
||||
/// </summary>
|
||||
public bool Strict { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyValidationFileResult(
|
||||
string Path,
|
||||
PolicyBindingResult BindingResult,
|
||||
PolicyDiagnosticsReport Diagnostics);
|
||||
|
||||
public sealed class PolicyValidationCli
|
||||
{
|
||||
private readonly TextWriter _output;
|
||||
private readonly TextWriter _error;
|
||||
|
||||
public PolicyValidationCli(TextWriter? output = null, TextWriter? error = null)
|
||||
{
|
||||
_output = output ?? Console.Out;
|
||||
_error = error ?? Console.Error;
|
||||
}
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyValidationCliOptions
|
||||
{
|
||||
public IReadOnlyList<string> Inputs { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Writes machine-readable JSON instead of human-formatted text.
|
||||
/// </summary>
|
||||
public bool OutputJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When enabled, warnings cause a non-zero exit code.
|
||||
/// </summary>
|
||||
public bool Strict { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyValidationFileResult(
|
||||
string Path,
|
||||
PolicyBindingResult BindingResult,
|
||||
PolicyDiagnosticsReport Diagnostics);
|
||||
|
||||
public sealed class PolicyValidationCli
|
||||
{
|
||||
private readonly TextWriter _output;
|
||||
private readonly TextWriter _error;
|
||||
|
||||
public PolicyValidationCli(TextWriter? output = null, TextWriter? error = null)
|
||||
{
|
||||
_output = output ?? Console.Out;
|
||||
_error = error ?? Console.Error;
|
||||
}
|
||||
|
||||
public async Task<int> RunAsync(PolicyValidationCliOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
if (options.Inputs.Count == 0)
|
||||
{
|
||||
await _error.WriteLineAsync("No input files provided. Supply one or more policy file paths.");
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
|
||||
var results = new List<PolicyValidationFileResult>();
|
||||
foreach (var input in options.Inputs)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var resolvedPaths = ResolveInput(input);
|
||||
if (resolvedPaths.Count == 0)
|
||||
{
|
||||
await _error.WriteLineAsync($"No files matched '{input}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var path in resolvedPaths)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var format = PolicySchema.DetectFormat(path);
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
if (options.Inputs.Count == 0)
|
||||
{
|
||||
await _error.WriteLineAsync("No input files provided. Supply one or more policy file paths.");
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
|
||||
var results = new List<PolicyValidationFileResult>();
|
||||
foreach (var input in options.Inputs)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var resolvedPaths = ResolveInput(input);
|
||||
if (resolvedPaths.Count == 0)
|
||||
{
|
||||
await _error.WriteLineAsync($"No files matched '{input}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var path in resolvedPaths)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var format = PolicySchema.DetectFormat(path);
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
var bindingResult = PolicyBinder.Bind(content, format);
|
||||
var diagnostics = PolicyDiagnostics.Create(bindingResult);
|
||||
|
||||
@@ -83,170 +83,170 @@ public sealed class PolicyValidationCli
|
||||
Recommendations = diagnostics.Recommendations.Add($"canonical.spl.digest:{splHash}"),
|
||||
};
|
||||
}
|
||||
|
||||
results.Add(new PolicyValidationFileResult(path, bindingResult, diagnostics));
|
||||
}
|
||||
}
|
||||
|
||||
if (results.Count == 0)
|
||||
{
|
||||
await _error.WriteLineAsync("No files were processed.");
|
||||
return 65; // EX_DATAERR
|
||||
}
|
||||
|
||||
if (options.OutputJson)
|
||||
{
|
||||
WriteJson(results);
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteTextAsync(results, cancellationToken);
|
||||
}
|
||||
|
||||
var hasErrors = results.Any(static result => !result.BindingResult.Success);
|
||||
var hasWarnings = results.Any(static result => result.BindingResult.Issues.Any(static issue => issue.Severity == PolicyIssueSeverity.Warning));
|
||||
|
||||
if (hasErrors)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.Strict && hasWarnings)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async Task WriteTextAsync(IReadOnlyList<PolicyValidationFileResult> results, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var result in results)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var relativePath = MakeRelative(result.Path);
|
||||
await _output.WriteLineAsync($"{relativePath} [{result.BindingResult.Format}]");
|
||||
|
||||
if (result.BindingResult.Issues.Length == 0)
|
||||
{
|
||||
await _output.WriteLineAsync(" OK");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var issue in result.BindingResult.Issues)
|
||||
{
|
||||
var severity = issue.Severity.ToString().ToUpperInvariant().PadRight(7);
|
||||
await _output.WriteLineAsync($" {severity} {issue.Path} :: {issue.Message} ({issue.Code})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteJson(IReadOnlyList<PolicyValidationFileResult> results)
|
||||
{
|
||||
var payload = results.Select(static result => new
|
||||
{
|
||||
path = result.Path,
|
||||
format = result.BindingResult.Format.ToString().ToLowerInvariant(),
|
||||
success = result.BindingResult.Success,
|
||||
issues = result.BindingResult.Issues.Select(static issue => new
|
||||
{
|
||||
code = issue.Code,
|
||||
message = issue.Message,
|
||||
severity = issue.Severity.ToString().ToLowerInvariant(),
|
||||
path = issue.Path,
|
||||
}),
|
||||
diagnostics = new
|
||||
{
|
||||
version = result.Diagnostics.Version,
|
||||
ruleCount = result.Diagnostics.RuleCount,
|
||||
errorCount = result.Diagnostics.ErrorCount,
|
||||
warningCount = result.Diagnostics.WarningCount,
|
||||
generatedAt = result.Diagnostics.GeneratedAt,
|
||||
recommendations = result.Diagnostics.Recommendations,
|
||||
},
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
});
|
||||
_output.WriteLine(json);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveInput(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(input.Trim());
|
||||
if (File.Exists(expanded))
|
||||
{
|
||||
return new[] { Path.GetFullPath(expanded) };
|
||||
}
|
||||
|
||||
if (Directory.Exists(expanded))
|
||||
{
|
||||
return Directory.EnumerateFiles(expanded, "*.*", SearchOption.TopDirectoryOnly)
|
||||
.Where(static path => MatchesPolicyExtension(path))
|
||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(Path.GetFullPath)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(expanded);
|
||||
var searchPattern = Path.GetFileName(expanded);
|
||||
|
||||
if (string.IsNullOrEmpty(searchPattern))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
{
|
||||
directory = ".";
|
||||
}
|
||||
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return Directory.EnumerateFiles(directory, searchPattern, SearchOption.TopDirectoryOnly)
|
||||
.Where(static path => MatchesPolicyExtension(path))
|
||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(Path.GetFullPath)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool MatchesPolicyExtension(string path)
|
||||
{
|
||||
var extension = Path.GetExtension(path);
|
||||
|
||||
results.Add(new PolicyValidationFileResult(path, bindingResult, diagnostics));
|
||||
}
|
||||
}
|
||||
|
||||
if (results.Count == 0)
|
||||
{
|
||||
await _error.WriteLineAsync("No files were processed.");
|
||||
return 65; // EX_DATAERR
|
||||
}
|
||||
|
||||
if (options.OutputJson)
|
||||
{
|
||||
WriteJson(results);
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteTextAsync(results, cancellationToken);
|
||||
}
|
||||
|
||||
var hasErrors = results.Any(static result => !result.BindingResult.Success);
|
||||
var hasWarnings = results.Any(static result => result.BindingResult.Issues.Any(static issue => issue.Severity == PolicyIssueSeverity.Warning));
|
||||
|
||||
if (hasErrors)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.Strict && hasWarnings)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async Task WriteTextAsync(IReadOnlyList<PolicyValidationFileResult> results, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var result in results)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var relativePath = MakeRelative(result.Path);
|
||||
await _output.WriteLineAsync($"{relativePath} [{result.BindingResult.Format}]");
|
||||
|
||||
if (result.BindingResult.Issues.Length == 0)
|
||||
{
|
||||
await _output.WriteLineAsync(" OK");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var issue in result.BindingResult.Issues)
|
||||
{
|
||||
var severity = issue.Severity.ToString().ToUpperInvariant().PadRight(7);
|
||||
await _output.WriteLineAsync($" {severity} {issue.Path} :: {issue.Message} ({issue.Code})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteJson(IReadOnlyList<PolicyValidationFileResult> results)
|
||||
{
|
||||
var payload = results.Select(static result => new
|
||||
{
|
||||
path = result.Path,
|
||||
format = result.BindingResult.Format.ToString().ToLowerInvariant(),
|
||||
success = result.BindingResult.Success,
|
||||
issues = result.BindingResult.Issues.Select(static issue => new
|
||||
{
|
||||
code = issue.Code,
|
||||
message = issue.Message,
|
||||
severity = issue.Severity.ToString().ToLowerInvariant(),
|
||||
path = issue.Path,
|
||||
}),
|
||||
diagnostics = new
|
||||
{
|
||||
version = result.Diagnostics.Version,
|
||||
ruleCount = result.Diagnostics.RuleCount,
|
||||
errorCount = result.Diagnostics.ErrorCount,
|
||||
warningCount = result.Diagnostics.WarningCount,
|
||||
generatedAt = result.Diagnostics.GeneratedAt,
|
||||
recommendations = result.Diagnostics.Recommendations,
|
||||
},
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
});
|
||||
_output.WriteLine(json);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveInput(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(input.Trim());
|
||||
if (File.Exists(expanded))
|
||||
{
|
||||
return new[] { Path.GetFullPath(expanded) };
|
||||
}
|
||||
|
||||
if (Directory.Exists(expanded))
|
||||
{
|
||||
return Directory.EnumerateFiles(expanded, "*.*", SearchOption.TopDirectoryOnly)
|
||||
.Where(static path => MatchesPolicyExtension(path))
|
||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(Path.GetFullPath)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(expanded);
|
||||
var searchPattern = Path.GetFileName(expanded);
|
||||
|
||||
if (string.IsNullOrEmpty(searchPattern))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
{
|
||||
directory = ".";
|
||||
}
|
||||
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return Directory.EnumerateFiles(directory, searchPattern, SearchOption.TopDirectoryOnly)
|
||||
.Where(static path => MatchesPolicyExtension(path))
|
||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(Path.GetFullPath)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool MatchesPolicyExtension(string path)
|
||||
{
|
||||
var extension = Path.GetExtension(path);
|
||||
return extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase)
|
||||
|| extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)
|
||||
|| extension.Equals(".json", StringComparison.OrdinalIgnoreCase)
|
||||
|| extension.Equals(".stella", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string MakeRelative(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
var current = Directory.GetCurrentDirectory();
|
||||
if (fullPath.StartsWith(current, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return fullPath[current.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string MakeRelative(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
var current = Directory.GetCurrentDirectory();
|
||||
if (fullPath.StartsWith(current, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return fullPath[current.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public enum PolicyVerdictStatus
|
||||
{
|
||||
Pass,
|
||||
Blocked,
|
||||
Ignored,
|
||||
Warned,
|
||||
Deferred,
|
||||
Escalated,
|
||||
RequiresVex,
|
||||
}
|
||||
|
||||
public sealed record PolicyVerdict(
|
||||
string FindingId,
|
||||
PolicyVerdictStatus Status,
|
||||
string? RuleName = null,
|
||||
string? RuleAction = null,
|
||||
string? Notes = null,
|
||||
double Score = 0,
|
||||
string ConfigVersion = "1.0",
|
||||
ImmutableDictionary<string, double>? Inputs = null,
|
||||
string? QuietedBy = null,
|
||||
bool Quiet = false,
|
||||
double? UnknownConfidence = null,
|
||||
string? ConfidenceBand = null,
|
||||
double? UnknownAgeDays = null,
|
||||
string? SourceTrust = null,
|
||||
string? Reachability = null)
|
||||
{
|
||||
public static PolicyVerdict CreateBaseline(string findingId, PolicyScoringConfig scoringConfig)
|
||||
{
|
||||
var inputs = ImmutableDictionary<string, double>.Empty;
|
||||
return new PolicyVerdict(
|
||||
findingId,
|
||||
PolicyVerdictStatus.Pass,
|
||||
RuleName: null,
|
||||
RuleAction: null,
|
||||
Notes: null,
|
||||
Score: 0,
|
||||
ConfigVersion: scoringConfig.Version,
|
||||
Inputs: inputs,
|
||||
QuietedBy: null,
|
||||
Quiet: false,
|
||||
UnknownConfidence: null,
|
||||
ConfidenceBand: null,
|
||||
UnknownAgeDays: null,
|
||||
SourceTrust: null,
|
||||
Reachability: null);
|
||||
}
|
||||
|
||||
public ImmutableDictionary<string, double> GetInputs()
|
||||
=> Inputs ?? ImmutableDictionary<string, double>.Empty;
|
||||
}
|
||||
|
||||
public sealed record PolicyVerdictDiff(
|
||||
PolicyVerdict Baseline,
|
||||
PolicyVerdict Projected)
|
||||
{
|
||||
public bool Changed
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Baseline.Status != Projected.Status)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.RuleName, Projected.RuleName, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Math.Abs(Baseline.Score - Projected.Score) > 0.0001)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.QuietedBy, Projected.QuietedBy, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var baselineConfidence = Baseline.UnknownConfidence ?? 0;
|
||||
var projectedConfidence = Projected.UnknownConfidence ?? 0;
|
||||
if (Math.Abs(baselineConfidence - projectedConfidence) > 0.0001)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.ConfidenceBand, Projected.ConfidenceBand, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.SourceTrust, Projected.SourceTrust, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.Reachability, Projected.Reachability, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public enum PolicyVerdictStatus
|
||||
{
|
||||
Pass,
|
||||
Blocked,
|
||||
Ignored,
|
||||
Warned,
|
||||
Deferred,
|
||||
Escalated,
|
||||
RequiresVex,
|
||||
}
|
||||
|
||||
public sealed record PolicyVerdict(
|
||||
string FindingId,
|
||||
PolicyVerdictStatus Status,
|
||||
string? RuleName = null,
|
||||
string? RuleAction = null,
|
||||
string? Notes = null,
|
||||
double Score = 0,
|
||||
string ConfigVersion = "1.0",
|
||||
ImmutableDictionary<string, double>? Inputs = null,
|
||||
string? QuietedBy = null,
|
||||
bool Quiet = false,
|
||||
double? UnknownConfidence = null,
|
||||
string? ConfidenceBand = null,
|
||||
double? UnknownAgeDays = null,
|
||||
string? SourceTrust = null,
|
||||
string? Reachability = null)
|
||||
{
|
||||
public static PolicyVerdict CreateBaseline(string findingId, PolicyScoringConfig scoringConfig)
|
||||
{
|
||||
var inputs = ImmutableDictionary<string, double>.Empty;
|
||||
return new PolicyVerdict(
|
||||
findingId,
|
||||
PolicyVerdictStatus.Pass,
|
||||
RuleName: null,
|
||||
RuleAction: null,
|
||||
Notes: null,
|
||||
Score: 0,
|
||||
ConfigVersion: scoringConfig.Version,
|
||||
Inputs: inputs,
|
||||
QuietedBy: null,
|
||||
Quiet: false,
|
||||
UnknownConfidence: null,
|
||||
ConfidenceBand: null,
|
||||
UnknownAgeDays: null,
|
||||
SourceTrust: null,
|
||||
Reachability: null);
|
||||
}
|
||||
|
||||
public ImmutableDictionary<string, double> GetInputs()
|
||||
=> Inputs ?? ImmutableDictionary<string, double>.Empty;
|
||||
}
|
||||
|
||||
public sealed record PolicyVerdictDiff(
|
||||
PolicyVerdict Baseline,
|
||||
PolicyVerdict Projected)
|
||||
{
|
||||
public bool Changed
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Baseline.Status != Projected.Status)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.RuleName, Projected.RuleName, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Math.Abs(Baseline.Score - Projected.Score) > 0.0001)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.QuietedBy, Projected.QuietedBy, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var baselineConfidence = Baseline.UnknownConfidence ?? 0;
|
||||
var projectedConfidence = Projected.UnknownConfidence ?? 0;
|
||||
if (Math.Abs(baselineConfidence - projectedConfidence) > 0.0001)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.ConfidenceBand, Projected.ConfidenceBand, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.SourceTrust, Projected.SourceTrust, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.Reachability, Projected.Reachability, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public interface IPolicySnapshotRepository
|
||||
{
|
||||
Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default);
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public interface IPolicySnapshotRepository
|
||||
{
|
||||
Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed class InMemoryPolicySnapshotRepository : IPolicySnapshotRepository
|
||||
{
|
||||
private readonly List<PolicySnapshot> _snapshots = new();
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public async Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (snapshot is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(snapshot));
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_snapshots.Add(snapshot);
|
||||
_snapshots.Sort(static (left, right) => left.RevisionNumber.CompareTo(right.RevisionNumber));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return _snapshots.Count == 0 ? null : _snapshots[^1];
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
IEnumerable<PolicySnapshot> query = _snapshots;
|
||||
if (limit > 0)
|
||||
{
|
||||
query = query.TakeLast(limit);
|
||||
}
|
||||
|
||||
return query.ToImmutableArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed class InMemoryPolicySnapshotRepository : IPolicySnapshotRepository
|
||||
{
|
||||
private readonly List<PolicySnapshot> _snapshots = new();
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public async Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (snapshot is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(snapshot));
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_snapshots.Add(snapshot);
|
||||
_snapshots.Sort(static (left, right) => left.RevisionNumber.CompareTo(right.RevisionNumber));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return _snapshots.Count == 0 ? null : _snapshots[^1];
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
IEnumerable<PolicySnapshot> query = _snapshots;
|
||||
if (limit > 0)
|
||||
{
|
||||
query = query.TakeLast(limit);
|
||||
}
|
||||
|
||||
return query.ToImmutableArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +1,104 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyCompilerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Compile_BaselinePolicy_Succeeds()
|
||||
{
|
||||
const string source = """
|
||||
policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
metadata {
|
||||
description = "Block critical, escalate high, enforce VEX justifications."
|
||||
tags = ["baseline","production"]
|
||||
}
|
||||
|
||||
profile severity {
|
||||
map vendor_weight {
|
||||
source "GHSA" => +0.5
|
||||
source "OSV" => +0.0
|
||||
}
|
||||
env exposure_adjustments {
|
||||
if env.exposure == "internet" then +0.5
|
||||
}
|
||||
}
|
||||
|
||||
rule block_critical priority 5 {
|
||||
when severity.normalized >= "Critical"
|
||||
then status := "blocked"
|
||||
because "Critical severity must be remediated before deploy."
|
||||
}
|
||||
|
||||
rule escalate_high_internet {
|
||||
when severity.normalized == "High"
|
||||
and env.exposure == "internet"
|
||||
then escalate to severity_band("Critical")
|
||||
because "High severity on internet-exposed asset escalates to critical."
|
||||
}
|
||||
|
||||
rule require_vex_justification {
|
||||
when vex.any(status in ["not_affected","fixed"])
|
||||
and vex.justification in ["component_not_present","vulnerable_code_not_present"]
|
||||
then status := vex.status
|
||||
annotate winning_statement := vex.latest().statementId
|
||||
because "Respect strong vendor VEX claims."
|
||||
}
|
||||
|
||||
rule alert_warn_eol_runtime priority 1 {
|
||||
when severity.normalized <= "Medium"
|
||||
and sbom.has_tag("runtime:eol")
|
||||
then warn message "Runtime marked as EOL; upgrade recommended."
|
||||
because "Deprecated runtime should be upgraded."
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var compiler = new PolicyCompiler();
|
||||
var result = compiler.Compile(source);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new Xunit.Sdk.XunitException($"Compilation failed: {Describe(result.Diagnostics)}");
|
||||
}
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Checksum));
|
||||
Assert.NotEmpty(result.CanonicalRepresentation);
|
||||
Assert.All(result.Diagnostics, issue => Assert.NotEqual(PolicyIssueSeverity.Error, issue.Severity));
|
||||
|
||||
var document = Assert.IsType<PolicyIrDocument>(result.Document);
|
||||
Assert.Equal("Baseline Production Policy", document.Name);
|
||||
Assert.Equal("stella-dsl@1", document.Syntax);
|
||||
Assert.Equal(4, document.Rules.Length);
|
||||
Assert.Single(document.Profiles);
|
||||
var firstAction = Assert.IsType<PolicyIrAssignmentAction>(document.Rules[0].ThenActions[0]);
|
||||
Assert.Equal("status", firstAction.Target[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_MissingBecause_ReportsDiagnostic()
|
||||
{
|
||||
const string source = """
|
||||
policy "Incomplete" syntax "stella-dsl@1" {
|
||||
rule missing_because {
|
||||
when true
|
||||
then status := "suppressed"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var compiler = new PolicyCompiler();
|
||||
var result = compiler.Compile(source);
|
||||
|
||||
Assert.False(result.Success);
|
||||
PolicyIssue diagnostic = result.Diagnostics.First(issue => issue.Code == "POLICY-DSL-PARSE-006");
|
||||
Assert.Equal(PolicyIssueSeverity.Error, diagnostic.Severity);
|
||||
}
|
||||
|
||||
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
|
||||
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyCompilerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Compile_BaselinePolicy_Succeeds()
|
||||
{
|
||||
const string source = """
|
||||
policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
metadata {
|
||||
description = "Block critical, escalate high, enforce VEX justifications."
|
||||
tags = ["baseline","production"]
|
||||
}
|
||||
|
||||
profile severity {
|
||||
map vendor_weight {
|
||||
source "GHSA" => +0.5
|
||||
source "OSV" => +0.0
|
||||
}
|
||||
env exposure_adjustments {
|
||||
if env.exposure == "internet" then +0.5
|
||||
}
|
||||
}
|
||||
|
||||
rule block_critical priority 5 {
|
||||
when severity.normalized >= "Critical"
|
||||
then status := "blocked"
|
||||
because "Critical severity must be remediated before deploy."
|
||||
}
|
||||
|
||||
rule escalate_high_internet {
|
||||
when severity.normalized == "High"
|
||||
and env.exposure == "internet"
|
||||
then escalate to severity_band("Critical")
|
||||
because "High severity on internet-exposed asset escalates to critical."
|
||||
}
|
||||
|
||||
rule require_vex_justification {
|
||||
when vex.any(status in ["not_affected","fixed"])
|
||||
and vex.justification in ["component_not_present","vulnerable_code_not_present"]
|
||||
then status := vex.status
|
||||
annotate winning_statement := vex.latest().statementId
|
||||
because "Respect strong vendor VEX claims."
|
||||
}
|
||||
|
||||
rule alert_warn_eol_runtime priority 1 {
|
||||
when severity.normalized <= "Medium"
|
||||
and sbom.has_tag("runtime:eol")
|
||||
then warn message "Runtime marked as EOL; upgrade recommended."
|
||||
because "Deprecated runtime should be upgraded."
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var compiler = new PolicyCompiler();
|
||||
var result = compiler.Compile(source);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new Xunit.Sdk.XunitException($"Compilation failed: {Describe(result.Diagnostics)}");
|
||||
}
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Checksum));
|
||||
Assert.NotEmpty(result.CanonicalRepresentation);
|
||||
Assert.All(result.Diagnostics, issue => Assert.NotEqual(PolicyIssueSeverity.Error, issue.Severity));
|
||||
|
||||
var document = Assert.IsType<PolicyIrDocument>(result.Document);
|
||||
Assert.Equal("Baseline Production Policy", document.Name);
|
||||
Assert.Equal("stella-dsl@1", document.Syntax);
|
||||
Assert.Equal(4, document.Rules.Length);
|
||||
Assert.Single(document.Profiles);
|
||||
var firstAction = Assert.IsType<PolicyIrAssignmentAction>(document.Rules[0].ThenActions[0]);
|
||||
Assert.Equal("status", firstAction.Target[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_MissingBecause_ReportsDiagnostic()
|
||||
{
|
||||
const string source = """
|
||||
policy "Incomplete" syntax "stella-dsl@1" {
|
||||
rule missing_because {
|
||||
when true
|
||||
then status := "suppressed"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var compiler = new PolicyCompiler();
|
||||
var result = compiler.Compile(source);
|
||||
|
||||
Assert.False(result.Success);
|
||||
PolicyIssue diagnostic = result.Diagnostics.First(issue => issue.Code == "POLICY-DSL-PARSE-006");
|
||||
Assert.Equal(PolicyIssueSeverity.Error, diagnostic.Severity);
|
||||
}
|
||||
|
||||
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
|
||||
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,44 @@
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public class PolicyPackRepositoryTests
|
||||
{
|
||||
private readonly InMemoryPolicyPackRepository repository = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_WithSingleApprover_ActivatesImmediately()
|
||||
{
|
||||
await repository.CreateAsync("pack-1", "Pack", CancellationToken.None);
|
||||
await repository.UpsertRevisionAsync("pack-1", 1, requiresTwoPersonApproval: false, PolicyRevisionStatus.Approved, CancellationToken.None);
|
||||
|
||||
var result = await repository.RecordActivationAsync("pack-1", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyActivationResultStatus.Activated, result.Status);
|
||||
Assert.NotNull(result.Revision);
|
||||
Assert.Equal(PolicyRevisionStatus.Active, result.Revision!.Status);
|
||||
Assert.Single(result.Revision.Approvals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_WithTwoPersonRequirement_ReturnsPendingUntilSecondApproval()
|
||||
{
|
||||
await repository.CreateAsync("pack-2", "Pack", CancellationToken.None);
|
||||
await repository.UpsertRevisionAsync("pack-2", 1, requiresTwoPersonApproval: true, PolicyRevisionStatus.Approved, CancellationToken.None);
|
||||
|
||||
var first = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
Assert.Equal(PolicyActivationResultStatus.PendingSecondApproval, first.Status);
|
||||
Assert.Equal(PolicyRevisionStatus.Approved, first.Revision!.Status);
|
||||
Assert.Single(first.Revision.Approvals);
|
||||
|
||||
var duplicate = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
Assert.Equal(PolicyActivationResultStatus.DuplicateApproval, duplicate.Status);
|
||||
|
||||
var second = await repository.RecordActivationAsync("pack-2", 1, "bob", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
Assert.Equal(PolicyActivationResultStatus.Activated, second.Status);
|
||||
Assert.Equal(PolicyRevisionStatus.Active, second.Revision!.Status);
|
||||
Assert.Equal(2, second.Revision.Approvals.Length);
|
||||
}
|
||||
}
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public class PolicyPackRepositoryTests
|
||||
{
|
||||
private readonly InMemoryPolicyPackRepository repository = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_WithSingleApprover_ActivatesImmediately()
|
||||
{
|
||||
await repository.CreateAsync("pack-1", "Pack", CancellationToken.None);
|
||||
await repository.UpsertRevisionAsync("pack-1", 1, requiresTwoPersonApproval: false, PolicyRevisionStatus.Approved, CancellationToken.None);
|
||||
|
||||
var result = await repository.RecordActivationAsync("pack-1", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyActivationResultStatus.Activated, result.Status);
|
||||
Assert.NotNull(result.Revision);
|
||||
Assert.Equal(PolicyRevisionStatus.Active, result.Revision!.Status);
|
||||
Assert.Single(result.Revision.Approvals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_WithTwoPersonRequirement_ReturnsPendingUntilSecondApproval()
|
||||
{
|
||||
await repository.CreateAsync("pack-2", "Pack", CancellationToken.None);
|
||||
await repository.UpsertRevisionAsync("pack-2", 1, requiresTwoPersonApproval: true, PolicyRevisionStatus.Approved, CancellationToken.None);
|
||||
|
||||
var first = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
Assert.Equal(PolicyActivationResultStatus.PendingSecondApproval, first.Status);
|
||||
Assert.Equal(PolicyRevisionStatus.Approved, first.Revision!.Status);
|
||||
Assert.Single(first.Revision.Approvals);
|
||||
|
||||
var duplicate = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
Assert.Equal(PolicyActivationResultStatus.DuplicateApproval, duplicate.Status);
|
||||
|
||||
var second = await repository.RecordActivationAsync("pack-2", 1, "bob", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
Assert.Equal(PolicyActivationResultStatus.Activated, second.Status);
|
||||
Assert.Equal(PolicyRevisionStatus.Active, second.Revision!.Status);
|
||||
Assert.Equal(2, second.Revision.Approvals.Length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,212 +1,212 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Policy.Gateway.Clients;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public class PolicyEngineClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ActivateRevision_UsesServiceTokenWhenForwardingContextMissing()
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.ClientCredentials.Enabled = true;
|
||||
options.PolicyEngine.ClientCredentials.ClientId = "policy-gateway";
|
||||
options.PolicyEngine.ClientCredentials.ClientSecret = "secret";
|
||||
options.PolicyEngine.ClientCredentials.Scopes.Clear();
|
||||
options.PolicyEngine.ClientCredentials.Scopes.Add("policy:activate");
|
||||
options.PolicyEngine.BaseAddress = "https://policy-engine.test/";
|
||||
|
||||
var optionsMonitor = new TestOptionsMonitor(options);
|
||||
var tokenClient = new StubTokenClient();
|
||||
var dpopGenerator = new PolicyGatewayDpopProofGenerator(new StubHostEnvironment(), optionsMonitor, TimeProvider.System, NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
var tokenProvider = new PolicyEngineTokenProvider(tokenClient, optionsMonitor, dpopGenerator, TimeProvider.System, NullLogger<PolicyEngineTokenProvider>.Instance);
|
||||
|
||||
using var recordingHandler = new RecordingHandler();
|
||||
using var httpClient = new HttpClient(recordingHandler)
|
||||
{
|
||||
BaseAddress = new Uri(options.PolicyEngine.BaseAddress)
|
||||
};
|
||||
|
||||
var client = new PolicyEngineClient(httpClient, Microsoft.Extensions.Options.Options.Create(options), tokenProvider, NullLogger<PolicyEngineClient>.Instance);
|
||||
|
||||
var request = new ActivatePolicyRevisionRequest("comment");
|
||||
var result = await client.ActivatePolicyRevisionAsync(null, "pack-123", 7, request, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(recordingHandler.LastRequest);
|
||||
var authorization = recordingHandler.LastRequest!.Headers.Authorization;
|
||||
Assert.NotNull(authorization);
|
||||
Assert.Equal("Bearer", authorization!.Scheme);
|
||||
Assert.Equal("service-token", authorization.Parameter);
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metrics_RecordActivation_EmitsExpectedTags()
|
||||
{
|
||||
using var metrics = new PolicyGatewayMetrics();
|
||||
using var listener = new MeterListener();
|
||||
var measurements = new List<(long Value, string Outcome, string Source)>();
|
||||
var latencies = new List<(double Value, string Outcome, string Source)>();
|
||||
|
||||
listener.InstrumentPublished += (instrument, meterListener) =>
|
||||
{
|
||||
if (!string.Equals(instrument.Meter.Name, "StellaOps.Policy.Gateway", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_requests_total")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
measurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, value, tags, state) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_latency_ms")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
latencies.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
|
||||
metrics.RecordActivation("activated", "service", 42.5);
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
Assert.Contains(measurements, entry => entry.Value == 1 && entry.Outcome == "activated" && entry.Source == "service");
|
||||
Assert.Contains(latencies, entry => entry.Outcome == "activated" && entry.Source == "service" && entry.Value == 42.5);
|
||||
}
|
||||
|
||||
private static string GetTag(ReadOnlySpan<KeyValuePair<string, object?>> tags, string key)
|
||||
{
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (string.Equals(tag.Key, key, StringComparison.Ordinal))
|
||||
{
|
||||
return tag.Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static PolicyGatewayOptions CreateGatewayOptions()
|
||||
{
|
||||
return new PolicyGatewayOptions
|
||||
{
|
||||
PolicyEngine =
|
||||
{
|
||||
BaseAddress = "https://policy-engine.test/"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
|
||||
{
|
||||
public TestOptionsMonitor(PolicyGatewayOptions current)
|
||||
{
|
||||
CurrentValue = current;
|
||||
}
|
||||
|
||||
public PolicyGatewayOptions CurrentValue { get; }
|
||||
|
||||
public PolicyGatewayOptions Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
|
||||
|
||||
private sealed class EmptyDisposable : IDisposable
|
||||
{
|
||||
public static readonly EmptyDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? LastAdditionalParameters { get; private set; }
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestCount++;
|
||||
LastAdditionalParameters = additionalParameters;
|
||||
return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", DateTimeOffset.UtcNow.AddMinutes(5), Array.Empty<string>()));
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
|
||||
var payload = JsonSerializer.Serialize(new PolicyRevisionActivationDto("activated", new PolicyRevisionDto(7, "Activated", false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, Array.Empty<PolicyActivationApprovalDto>())));
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = "Development";
|
||||
public string ApplicationName { get; set; } = "PolicyGatewayTests";
|
||||
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Policy.Gateway.Clients;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public class PolicyEngineClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ActivateRevision_UsesServiceTokenWhenForwardingContextMissing()
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.ClientCredentials.Enabled = true;
|
||||
options.PolicyEngine.ClientCredentials.ClientId = "policy-gateway";
|
||||
options.PolicyEngine.ClientCredentials.ClientSecret = "secret";
|
||||
options.PolicyEngine.ClientCredentials.Scopes.Clear();
|
||||
options.PolicyEngine.ClientCredentials.Scopes.Add("policy:activate");
|
||||
options.PolicyEngine.BaseAddress = "https://policy-engine.test/";
|
||||
|
||||
var optionsMonitor = new TestOptionsMonitor(options);
|
||||
var tokenClient = new StubTokenClient();
|
||||
var dpopGenerator = new PolicyGatewayDpopProofGenerator(new StubHostEnvironment(), optionsMonitor, TimeProvider.System, NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
var tokenProvider = new PolicyEngineTokenProvider(tokenClient, optionsMonitor, dpopGenerator, TimeProvider.System, NullLogger<PolicyEngineTokenProvider>.Instance);
|
||||
|
||||
using var recordingHandler = new RecordingHandler();
|
||||
using var httpClient = new HttpClient(recordingHandler)
|
||||
{
|
||||
BaseAddress = new Uri(options.PolicyEngine.BaseAddress)
|
||||
};
|
||||
|
||||
var client = new PolicyEngineClient(httpClient, Microsoft.Extensions.Options.Options.Create(options), tokenProvider, NullLogger<PolicyEngineClient>.Instance);
|
||||
|
||||
var request = new ActivatePolicyRevisionRequest("comment");
|
||||
var result = await client.ActivatePolicyRevisionAsync(null, "pack-123", 7, request, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(recordingHandler.LastRequest);
|
||||
var authorization = recordingHandler.LastRequest!.Headers.Authorization;
|
||||
Assert.NotNull(authorization);
|
||||
Assert.Equal("Bearer", authorization!.Scheme);
|
||||
Assert.Equal("service-token", authorization.Parameter);
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metrics_RecordActivation_EmitsExpectedTags()
|
||||
{
|
||||
using var metrics = new PolicyGatewayMetrics();
|
||||
using var listener = new MeterListener();
|
||||
var measurements = new List<(long Value, string Outcome, string Source)>();
|
||||
var latencies = new List<(double Value, string Outcome, string Source)>();
|
||||
|
||||
listener.InstrumentPublished += (instrument, meterListener) =>
|
||||
{
|
||||
if (!string.Equals(instrument.Meter.Name, "StellaOps.Policy.Gateway", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_requests_total")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
measurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, value, tags, state) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_latency_ms")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
latencies.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
|
||||
metrics.RecordActivation("activated", "service", 42.5);
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
Assert.Contains(measurements, entry => entry.Value == 1 && entry.Outcome == "activated" && entry.Source == "service");
|
||||
Assert.Contains(latencies, entry => entry.Outcome == "activated" && entry.Source == "service" && entry.Value == 42.5);
|
||||
}
|
||||
|
||||
private static string GetTag(ReadOnlySpan<KeyValuePair<string, object?>> tags, string key)
|
||||
{
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (string.Equals(tag.Key, key, StringComparison.Ordinal))
|
||||
{
|
||||
return tag.Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static PolicyGatewayOptions CreateGatewayOptions()
|
||||
{
|
||||
return new PolicyGatewayOptions
|
||||
{
|
||||
PolicyEngine =
|
||||
{
|
||||
BaseAddress = "https://policy-engine.test/"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
|
||||
{
|
||||
public TestOptionsMonitor(PolicyGatewayOptions current)
|
||||
{
|
||||
CurrentValue = current;
|
||||
}
|
||||
|
||||
public PolicyGatewayOptions CurrentValue { get; }
|
||||
|
||||
public PolicyGatewayOptions Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
|
||||
|
||||
private sealed class EmptyDisposable : IDisposable
|
||||
{
|
||||
public static readonly EmptyDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? LastAdditionalParameters { get; private set; }
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestCount++;
|
||||
LastAdditionalParameters = additionalParameters;
|
||||
return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", DateTimeOffset.UtcNow.AddMinutes(5), Array.Empty<string>()));
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
|
||||
var payload = JsonSerializer.Serialize(new PolicyRevisionActivationDto("activated", new PolicyRevisionDto(7, "Activated", false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, Array.Empty<PolicyActivationApprovalDto>())));
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = "Development";
|
||||
public string ApplicationName { get; set; } = "PolicyGatewayTests";
|
||||
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,167 +1,167 @@
|
||||
using System.Globalization;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public sealed class PolicyGatewayDpopProofGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateProof_Throws_WhenDpopDisabled()
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.Dpop.Enabled = false;
|
||||
|
||||
using var generator = new PolicyGatewayDpopProofGenerator(
|
||||
new StubHostEnvironment(AppContext.BaseDirectory),
|
||||
new TestOptionsMonitor(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
generator.CreateProof(HttpMethod.Get, new Uri("https://policy-engine.example/api"), null));
|
||||
|
||||
Assert.Equal("DPoP proof requested while DPoP is disabled.", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProof_Throws_WhenKeyFileMissing()
|
||||
{
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.Dpop.Enabled = true;
|
||||
options.PolicyEngine.Dpop.KeyPath = "missing-key.pem";
|
||||
|
||||
using var generator = new PolicyGatewayDpopProofGenerator(
|
||||
new StubHostEnvironment(tempRoot.FullName),
|
||||
new TestOptionsMonitor(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
|
||||
var exception = Assert.Throws<FileNotFoundException>(() =>
|
||||
generator.CreateProof(HttpMethod.Post, new Uri("https://policy-engine.example/token"), null));
|
||||
|
||||
Assert.Contains("missing-key.pem", exception.FileName, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempRoot.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProof_UsesConfiguredAlgorithmAndEmbedsTokenHash()
|
||||
{
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var keyPath = CreateEcKey(tempRoot, ECCurve.NamedCurves.nistP384);
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.Dpop.Enabled = true;
|
||||
options.PolicyEngine.Dpop.KeyPath = keyPath;
|
||||
options.PolicyEngine.Dpop.Algorithm = "ES384";
|
||||
|
||||
using var generator = new PolicyGatewayDpopProofGenerator(
|
||||
new StubHostEnvironment(tempRoot.FullName),
|
||||
new TestOptionsMonitor(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
|
||||
const string accessToken = "sample-access-token";
|
||||
var proof = generator.CreateProof(HttpMethod.Delete, new Uri("https://policy-engine.example/api/resource"), accessToken);
|
||||
|
||||
var token = new JwtSecurityTokenHandler().ReadJwtToken(proof);
|
||||
|
||||
Assert.Equal("dpop+jwt", token.Header.Typ);
|
||||
Assert.Equal("ES384", token.Header.Alg);
|
||||
Assert.Equal("DELETE", token.Payload.TryGetValue("htm", out var method) ? method?.ToString() : null);
|
||||
Assert.Equal("https://policy-engine.example/api/resource", token.Payload.TryGetValue("htu", out var uri) ? uri?.ToString() : null);
|
||||
|
||||
Assert.True(token.Payload.TryGetValue("iat", out var issuedAt));
|
||||
Assert.True(long.TryParse(Convert.ToString(issuedAt, CultureInfo.InvariantCulture), out var epoch));
|
||||
Assert.True(epoch > 0);
|
||||
|
||||
Assert.True(token.Payload.TryGetValue("jti", out var jti));
|
||||
Assert.False(string.IsNullOrWhiteSpace(Convert.ToString(jti, CultureInfo.InvariantCulture)));
|
||||
|
||||
Assert.True(token.Payload.TryGetValue("ath", out var ath));
|
||||
var expectedHash = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(accessToken)));
|
||||
Assert.Equal(expectedHash, ath?.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempRoot.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static PolicyGatewayOptions CreateGatewayOptions()
|
||||
{
|
||||
return new PolicyGatewayOptions
|
||||
{
|
||||
PolicyEngine =
|
||||
{
|
||||
BaseAddress = "https://policy-engine.example"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateEcKey(DirectoryInfo directory, ECCurve curve)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(curve);
|
||||
var privateKey = ecdsa.ExportPkcs8PrivateKey();
|
||||
var pem = PemEncoding.Write("PRIVATE KEY", privateKey);
|
||||
var path = Path.Combine(directory.FullName, "policy-gateway-dpop.pem");
|
||||
File.WriteAllText(path, pem);
|
||||
return path;
|
||||
}
|
||||
|
||||
private sealed class StubHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public StubHostEnvironment(string contentRootPath)
|
||||
{
|
||||
ContentRootPath = contentRootPath;
|
||||
}
|
||||
|
||||
public string ApplicationName { get; set; } = "PolicyGatewayTests";
|
||||
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
|
||||
public string ContentRootPath { get; set; }
|
||||
|
||||
public string EnvironmentName { get; set; } = Environments.Development;
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
|
||||
{
|
||||
public TestOptionsMonitor(PolicyGatewayOptions current)
|
||||
{
|
||||
CurrentValue = current;
|
||||
}
|
||||
|
||||
public PolicyGatewayOptions CurrentValue { get; }
|
||||
|
||||
public PolicyGatewayOptions Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
|
||||
|
||||
private sealed class EmptyDisposable : IDisposable
|
||||
{
|
||||
public static readonly EmptyDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Globalization;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public sealed class PolicyGatewayDpopProofGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateProof_Throws_WhenDpopDisabled()
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.Dpop.Enabled = false;
|
||||
|
||||
using var generator = new PolicyGatewayDpopProofGenerator(
|
||||
new StubHostEnvironment(AppContext.BaseDirectory),
|
||||
new TestOptionsMonitor(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
generator.CreateProof(HttpMethod.Get, new Uri("https://policy-engine.example/api"), null));
|
||||
|
||||
Assert.Equal("DPoP proof requested while DPoP is disabled.", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProof_Throws_WhenKeyFileMissing()
|
||||
{
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.Dpop.Enabled = true;
|
||||
options.PolicyEngine.Dpop.KeyPath = "missing-key.pem";
|
||||
|
||||
using var generator = new PolicyGatewayDpopProofGenerator(
|
||||
new StubHostEnvironment(tempRoot.FullName),
|
||||
new TestOptionsMonitor(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
|
||||
var exception = Assert.Throws<FileNotFoundException>(() =>
|
||||
generator.CreateProof(HttpMethod.Post, new Uri("https://policy-engine.example/token"), null));
|
||||
|
||||
Assert.Contains("missing-key.pem", exception.FileName, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempRoot.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProof_UsesConfiguredAlgorithmAndEmbedsTokenHash()
|
||||
{
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var keyPath = CreateEcKey(tempRoot, ECCurve.NamedCurves.nistP384);
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.Dpop.Enabled = true;
|
||||
options.PolicyEngine.Dpop.KeyPath = keyPath;
|
||||
options.PolicyEngine.Dpop.Algorithm = "ES384";
|
||||
|
||||
using var generator = new PolicyGatewayDpopProofGenerator(
|
||||
new StubHostEnvironment(tempRoot.FullName),
|
||||
new TestOptionsMonitor(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
|
||||
const string accessToken = "sample-access-token";
|
||||
var proof = generator.CreateProof(HttpMethod.Delete, new Uri("https://policy-engine.example/api/resource"), accessToken);
|
||||
|
||||
var token = new JwtSecurityTokenHandler().ReadJwtToken(proof);
|
||||
|
||||
Assert.Equal("dpop+jwt", token.Header.Typ);
|
||||
Assert.Equal("ES384", token.Header.Alg);
|
||||
Assert.Equal("DELETE", token.Payload.TryGetValue("htm", out var method) ? method?.ToString() : null);
|
||||
Assert.Equal("https://policy-engine.example/api/resource", token.Payload.TryGetValue("htu", out var uri) ? uri?.ToString() : null);
|
||||
|
||||
Assert.True(token.Payload.TryGetValue("iat", out var issuedAt));
|
||||
Assert.True(long.TryParse(Convert.ToString(issuedAt, CultureInfo.InvariantCulture), out var epoch));
|
||||
Assert.True(epoch > 0);
|
||||
|
||||
Assert.True(token.Payload.TryGetValue("jti", out var jti));
|
||||
Assert.False(string.IsNullOrWhiteSpace(Convert.ToString(jti, CultureInfo.InvariantCulture)));
|
||||
|
||||
Assert.True(token.Payload.TryGetValue("ath", out var ath));
|
||||
var expectedHash = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(accessToken)));
|
||||
Assert.Equal(expectedHash, ath?.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempRoot.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static PolicyGatewayOptions CreateGatewayOptions()
|
||||
{
|
||||
return new PolicyGatewayOptions
|
||||
{
|
||||
PolicyEngine =
|
||||
{
|
||||
BaseAddress = "https://policy-engine.example"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateEcKey(DirectoryInfo directory, ECCurve curve)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(curve);
|
||||
var privateKey = ecdsa.ExportPkcs8PrivateKey();
|
||||
var pem = PemEncoding.Write("PRIVATE KEY", privateKey);
|
||||
var path = Path.Combine(directory.FullName, "policy-gateway-dpop.pem");
|
||||
File.WriteAllText(path, pem);
|
||||
return path;
|
||||
}
|
||||
|
||||
private sealed class StubHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public StubHostEnvironment(string contentRootPath)
|
||||
{
|
||||
ContentRootPath = contentRootPath;
|
||||
}
|
||||
|
||||
public string ApplicationName { get; set; } = "PolicyGatewayTests";
|
||||
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
|
||||
public string ContentRootPath { get; set; }
|
||||
|
||||
public string EnvironmentName { get; set; } = Environments.Development;
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
|
||||
{
|
||||
public TestOptionsMonitor(PolicyGatewayOptions current)
|
||||
{
|
||||
CurrentValue = current;
|
||||
}
|
||||
|
||||
public PolicyGatewayOptions CurrentValue { get; }
|
||||
|
||||
public PolicyGatewayOptions Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
|
||||
|
||||
private sealed class EmptyDisposable : IDisposable
|
||||
{
|
||||
public static readonly EmptyDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyBinderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Bind_ValidYaml_ReturnsSuccess()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
sources: [NVD]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("1.0", result.Document.Version);
|
||||
Assert.Single(result.Document.Rules);
|
||||
Assert.Empty(result.Issues);
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyBinderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Bind_ValidYaml_ReturnsSuccess()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
sources: [NVD]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("1.0", result.Document.Version);
|
||||
Assert.Single(result.Document.Rules);
|
||||
Assert.Empty(result.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -99,59 +99,59 @@ public sealed class PolicyBinderTests
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "policy.exceptions.effect.downgrade.missingSeverity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_InvalidSeverity_ReturnsError()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Invalid Severity
|
||||
severity: [Nope]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "policy.severity.invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_StrictMode_FailsOnWarnings()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Quiet Warning
|
||||
sources: ["", "NVD"]
|
||||
action: ignore
|
||||
""";
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"policy-{Guid.NewGuid():N}.yaml");
|
||||
await File.WriteAllTextAsync(path, yaml);
|
||||
|
||||
try
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
var cli = new PolicyValidationCli(output, error);
|
||||
var options = new PolicyValidationCliOptions
|
||||
{
|
||||
Inputs = new[] { path },
|
||||
Strict = true,
|
||||
};
|
||||
|
||||
var exitCode = await cli.RunAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, exitCode);
|
||||
Assert.Contains("WARNING", output.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[Fact]
|
||||
public void Bind_InvalidSeverity_ReturnsError()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Invalid Severity
|
||||
severity: [Nope]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "policy.severity.invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_StrictMode_FailsOnWarnings()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Quiet Warning
|
||||
sources: ["", "NVD"]
|
||||
action: ignore
|
||||
""";
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"policy-{Guid.NewGuid():N}.yaml");
|
||||
await File.WriteAllTextAsync(path, yaml);
|
||||
|
||||
try
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
var cli = new PolicyValidationCli(output, error);
|
||||
var options = new PolicyValidationCliOptions
|
||||
{
|
||||
Inputs = new[] { path },
|
||||
Strict = true,
|
||||
};
|
||||
|
||||
var exitCode = await cli.RunAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, exitCode);
|
||||
Assert.Contains("WARNING", output.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
using System.Collections.Immutable;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyEvaluationTests
|
||||
{
|
||||
[Fact]
|
||||
public void EvaluateFinding_AppliesTrustAndReachabilityWeights()
|
||||
{
|
||||
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
|
||||
var rule = PolicyRule.Create(
|
||||
"BlockMedium",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Medium),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
using System.Collections.Immutable;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyEvaluationTests
|
||||
{
|
||||
[Fact]
|
||||
public void EvaluateFinding_AppliesTrustAndReachabilityWeights()
|
||||
{
|
||||
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
|
||||
var rule = PolicyRule.Create(
|
||||
"BlockMedium",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Medium),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
var document = new PolicyDocument(
|
||||
PolicySchema.CurrentVersion,
|
||||
ImmutableArray.Create(rule),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
PolicyExceptionConfiguration.Empty);
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-medium",
|
||||
PolicySeverity.Medium,
|
||||
source: "community",
|
||||
tags: ImmutableArray.Create("reachability:indirect"));
|
||||
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-medium",
|
||||
PolicySeverity.Medium,
|
||||
source: "community",
|
||||
tags: ImmutableArray.Create("reachability:indirect"));
|
||||
|
||||
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding, out var explanation);
|
||||
|
||||
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
|
||||
Assert.Equal(19.5, verdict.Score, 3);
|
||||
|
||||
@@ -48,43 +48,43 @@ public sealed class PolicyEvaluationTests
|
||||
Assert.NotNull(explanation);
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, explanation!.Decision);
|
||||
Assert.Equal("BlockMedium", explanation.RuleName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateFinding_QuietWithRequireVexAppliesQuietPenalty()
|
||||
{
|
||||
var ignoreOptions = new PolicyIgnoreOptions(null, null);
|
||||
var requireVexOptions = new PolicyRequireVexOptions(
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
var action = new PolicyAction(PolicyActionType.Ignore, ignoreOptions, null, requireVexOptions, true);
|
||||
var rule = PolicyRule.Create(
|
||||
"QuietIgnore",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Critical),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateFinding_QuietWithRequireVexAppliesQuietPenalty()
|
||||
{
|
||||
var ignoreOptions = new PolicyIgnoreOptions(null, null);
|
||||
var requireVexOptions = new PolicyRequireVexOptions(
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
var action = new PolicyAction(PolicyActionType.Ignore, ignoreOptions, null, requireVexOptions, true);
|
||||
var rule = PolicyRule.Create(
|
||||
"QuietIgnore",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Critical),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
|
||||
var document = new PolicyDocument(
|
||||
PolicySchema.CurrentVersion,
|
||||
ImmutableArray.Create(rule),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
PolicyExceptionConfiguration.Empty);
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-critical",
|
||||
PolicySeverity.Critical,
|
||||
tags: ImmutableArray.Create("reachability:entrypoint"));
|
||||
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-critical",
|
||||
PolicySeverity.Critical,
|
||||
tags: ImmutableArray.Create("reachability:entrypoint"));
|
||||
|
||||
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding, out var explanation);
|
||||
|
||||
|
||||
Assert.Equal(PolicyVerdictStatus.Ignored, verdict.Status);
|
||||
Assert.True(verdict.Quiet);
|
||||
Assert.Equal("QuietIgnore", verdict.QuietedBy);
|
||||
@@ -97,39 +97,39 @@ public sealed class PolicyEvaluationTests
|
||||
|
||||
Assert.NotNull(explanation);
|
||||
Assert.Equal(PolicyVerdictStatus.Ignored, explanation!.Decision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateFinding_UnknownSeverityComputesConfidence()
|
||||
{
|
||||
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
|
||||
var rule = PolicyRule.Create(
|
||||
"BlockUnknown",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Unknown),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateFinding_UnknownSeverityComputesConfidence()
|
||||
{
|
||||
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
|
||||
var rule = PolicyRule.Create(
|
||||
"BlockUnknown",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Unknown),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
|
||||
var document = new PolicyDocument(
|
||||
PolicySchema.CurrentVersion,
|
||||
ImmutableArray.Create(rule),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
PolicyExceptionConfiguration.Empty);
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-unknown",
|
||||
PolicySeverity.Unknown,
|
||||
tags: ImmutableArray.Create("reachability:unknown", "unknown-age-days:5"));
|
||||
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-unknown",
|
||||
PolicySeverity.Unknown,
|
||||
tags: ImmutableArray.Create("reachability:unknown", "unknown-age-days:5"));
|
||||
|
||||
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding, out var explanation);
|
||||
|
||||
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
|
||||
Assert.Equal(30, verdict.Score, 3); // 60 * 1 * 0.5
|
||||
Assert.Equal(0.55, verdict.UnknownConfidence ?? 0, 3);
|
||||
|
||||
@@ -1,185 +1,185 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyPreviewServiceTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public PolicyPreviewServiceTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output ?? throw new ArgumentNullException(nameof(output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_ComputesDiffs_ForBlockingRule()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, null), CancellationToken.None);
|
||||
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
var findings = ImmutableArray.Create(
|
||||
PolicyFinding.Create("finding-1", PolicySeverity.Critical, environment: "prod", source: "NVD"),
|
||||
PolicyFinding.Create("finding-2", PolicySeverity.Low));
|
||||
|
||||
var baseline = ImmutableArray.Create(
|
||||
new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass),
|
||||
new PolicyVerdict("finding-2", PolicyVerdictStatus.Pass));
|
||||
|
||||
var response = await service.PreviewAsync(new PolicyPreviewRequest(
|
||||
"sha256:abc",
|
||||
findings,
|
||||
baseline),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(response.Success);
|
||||
Assert.Equal(1, response.ChangedCount);
|
||||
var diff1 = Assert.Single(response.Diffs.Where(diff => diff.Projected.FindingId == "finding-1"));
|
||||
Assert.Equal(PolicyVerdictStatus.Pass, diff1.Baseline.Status);
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, diff1.Projected.Status);
|
||||
Assert.Equal("Block Critical", diff1.Projected.RuleName);
|
||||
Assert.True(diff1.Projected.Score > 0);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, diff1.Projected.ConfigVersion);
|
||||
Assert.Equal(PolicyVerdictStatus.Pass, response.Diffs.First(diff => diff.Projected.FindingId == "finding-2").Projected.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_UsesProposedPolicy_WhenProvided()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Ignore Dev
|
||||
environments: [dev]
|
||||
action:
|
||||
type: ignore
|
||||
justification: dev waiver
|
||||
""";
|
||||
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
var findings = ImmutableArray.Create(
|
||||
PolicyFinding.Create("finding-1", PolicySeverity.Medium, environment: "dev"));
|
||||
|
||||
var baseline = ImmutableArray.Create(new PolicyVerdict("finding-1", PolicyVerdictStatus.Blocked));
|
||||
|
||||
var response = await service.PreviewAsync(new PolicyPreviewRequest(
|
||||
"sha256:def",
|
||||
findings,
|
||||
baseline,
|
||||
SnapshotOverride: null,
|
||||
ProposedPolicy: new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "dev override")),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(response.Success);
|
||||
var diff = Assert.Single(response.Diffs);
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, diff.Baseline.Status);
|
||||
Assert.Equal(PolicyVerdictStatus.Ignored, diff.Projected.Status);
|
||||
Assert.Equal("Ignore Dev", diff.Projected.RuleName);
|
||||
Assert.True(diff.Projected.Score >= 0);
|
||||
Assert.Equal(1, response.ChangedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_ReturnsIssues_WhenPolicyInvalid()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
const string invalid = "version: 1.0";
|
||||
var request = new PolicyPreviewRequest(
|
||||
"sha256:ghi",
|
||||
ImmutableArray<PolicyFinding>.Empty,
|
||||
ImmutableArray<PolicyVerdict>.Empty,
|
||||
SnapshotOverride: null,
|
||||
ProposedPolicy: new PolicySnapshotContent(invalid, PolicyDocumentFormat.Yaml, null, null, null));
|
||||
|
||||
var response = await service.PreviewAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.False(response.Success);
|
||||
Assert.NotEmpty(response.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_QuietWithoutVexDowngradesToWarn()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Quiet Without VEX
|
||||
severity: [Low]
|
||||
quiet: true
|
||||
action:
|
||||
type: ignore
|
||||
""";
|
||||
|
||||
var binding = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
if (!binding.Success)
|
||||
{
|
||||
foreach (var issue in binding.Issues)
|
||||
{
|
||||
_output.WriteLine($"{issue.Severity} {issue.Code} {issue.Path} :: {issue.Message}");
|
||||
}
|
||||
|
||||
var parseMethod = typeof(PolicyBinder).GetMethod("ParseToNode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
var node = (System.Text.Json.Nodes.JsonNode?)parseMethod?.Invoke(null, new object[] { yaml, PolicyDocumentFormat.Yaml });
|
||||
_output.WriteLine(node?.ToJsonString() ?? "<null>");
|
||||
}
|
||||
Assert.True(binding.Success);
|
||||
Assert.Empty(binding.Issues);
|
||||
Assert.False(binding.Document.Rules[0].Metadata.ContainsKey("quiet"));
|
||||
Assert.True(binding.Document.Rules[0].Action.Quiet);
|
||||
|
||||
var store = new PolicySnapshotStore(new InMemoryPolicySnapshotRepository(), new InMemoryPolicyAuditRepository(), TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "quiet test"), CancellationToken.None);
|
||||
var snapshot = await store.GetLatestAsync();
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.True(snapshot!.Document.Rules[0].Action.Quiet);
|
||||
Assert.Null(snapshot.Document.Rules[0].Action.RequireVex);
|
||||
Assert.Equal(PolicyActionType.Ignore, snapshot.Document.Rules[0].Action.Type);
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyPreviewServiceTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public PolicyPreviewServiceTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output ?? throw new ArgumentNullException(nameof(output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_ComputesDiffs_ForBlockingRule()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, null), CancellationToken.None);
|
||||
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
var findings = ImmutableArray.Create(
|
||||
PolicyFinding.Create("finding-1", PolicySeverity.Critical, environment: "prod", source: "NVD"),
|
||||
PolicyFinding.Create("finding-2", PolicySeverity.Low));
|
||||
|
||||
var baseline = ImmutableArray.Create(
|
||||
new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass),
|
||||
new PolicyVerdict("finding-2", PolicyVerdictStatus.Pass));
|
||||
|
||||
var response = await service.PreviewAsync(new PolicyPreviewRequest(
|
||||
"sha256:abc",
|
||||
findings,
|
||||
baseline),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(response.Success);
|
||||
Assert.Equal(1, response.ChangedCount);
|
||||
var diff1 = Assert.Single(response.Diffs.Where(diff => diff.Projected.FindingId == "finding-1"));
|
||||
Assert.Equal(PolicyVerdictStatus.Pass, diff1.Baseline.Status);
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, diff1.Projected.Status);
|
||||
Assert.Equal("Block Critical", diff1.Projected.RuleName);
|
||||
Assert.True(diff1.Projected.Score > 0);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, diff1.Projected.ConfigVersion);
|
||||
Assert.Equal(PolicyVerdictStatus.Pass, response.Diffs.First(diff => diff.Projected.FindingId == "finding-2").Projected.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_UsesProposedPolicy_WhenProvided()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Ignore Dev
|
||||
environments: [dev]
|
||||
action:
|
||||
type: ignore
|
||||
justification: dev waiver
|
||||
""";
|
||||
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
var findings = ImmutableArray.Create(
|
||||
PolicyFinding.Create("finding-1", PolicySeverity.Medium, environment: "dev"));
|
||||
|
||||
var baseline = ImmutableArray.Create(new PolicyVerdict("finding-1", PolicyVerdictStatus.Blocked));
|
||||
|
||||
var response = await service.PreviewAsync(new PolicyPreviewRequest(
|
||||
"sha256:def",
|
||||
findings,
|
||||
baseline,
|
||||
SnapshotOverride: null,
|
||||
ProposedPolicy: new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "dev override")),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(response.Success);
|
||||
var diff = Assert.Single(response.Diffs);
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, diff.Baseline.Status);
|
||||
Assert.Equal(PolicyVerdictStatus.Ignored, diff.Projected.Status);
|
||||
Assert.Equal("Ignore Dev", diff.Projected.RuleName);
|
||||
Assert.True(diff.Projected.Score >= 0);
|
||||
Assert.Equal(1, response.ChangedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_ReturnsIssues_WhenPolicyInvalid()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
const string invalid = "version: 1.0";
|
||||
var request = new PolicyPreviewRequest(
|
||||
"sha256:ghi",
|
||||
ImmutableArray<PolicyFinding>.Empty,
|
||||
ImmutableArray<PolicyVerdict>.Empty,
|
||||
SnapshotOverride: null,
|
||||
ProposedPolicy: new PolicySnapshotContent(invalid, PolicyDocumentFormat.Yaml, null, null, null));
|
||||
|
||||
var response = await service.PreviewAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.False(response.Success);
|
||||
Assert.NotEmpty(response.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_QuietWithoutVexDowngradesToWarn()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Quiet Without VEX
|
||||
severity: [Low]
|
||||
quiet: true
|
||||
action:
|
||||
type: ignore
|
||||
""";
|
||||
|
||||
var binding = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
if (!binding.Success)
|
||||
{
|
||||
foreach (var issue in binding.Issues)
|
||||
{
|
||||
_output.WriteLine($"{issue.Severity} {issue.Code} {issue.Path} :: {issue.Message}");
|
||||
}
|
||||
|
||||
var parseMethod = typeof(PolicyBinder).GetMethod("ParseToNode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
var node = (System.Text.Json.Nodes.JsonNode?)parseMethod?.Invoke(null, new object[] { yaml, PolicyDocumentFormat.Yaml });
|
||||
_output.WriteLine(node?.ToJsonString() ?? "<null>");
|
||||
}
|
||||
Assert.True(binding.Success);
|
||||
Assert.Empty(binding.Issues);
|
||||
Assert.False(binding.Document.Rules[0].Metadata.ContainsKey("quiet"));
|
||||
Assert.True(binding.Document.Rules[0].Action.Quiet);
|
||||
|
||||
var store = new PolicySnapshotStore(new InMemoryPolicySnapshotRepository(), new InMemoryPolicyAuditRepository(), TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "quiet test"), CancellationToken.None);
|
||||
var snapshot = await store.GetLatestAsync();
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.True(snapshot!.Document.Rules[0].Action.Quiet);
|
||||
Assert.Null(snapshot.Document.Rules[0].Action.RequireVex);
|
||||
Assert.Equal(PolicyActionType.Ignore, snapshot.Document.Rules[0].Action.Type);
|
||||
var manualVerdict = PolicyEvaluation.EvaluateFinding(snapshot.Document, snapshot.ScoringConfig, PolicyFinding.Create("finding-quiet", PolicySeverity.Low), out _);
|
||||
Assert.Equal(PolicyVerdictStatus.Warned, manualVerdict.Status);
|
||||
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
var findings = ImmutableArray.Create(PolicyFinding.Create("finding-quiet", PolicySeverity.Low));
|
||||
var baseline = ImmutableArray<PolicyVerdict>.Empty;
|
||||
|
||||
var response = await service.PreviewAsync(new PolicyPreviewRequest(
|
||||
"sha256:quiet",
|
||||
findings,
|
||||
baseline),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(response.Success);
|
||||
var verdict = Assert.Single(response.Diffs).Projected;
|
||||
Assert.Equal(PolicyVerdictStatus.Warned, verdict.Status);
|
||||
Assert.Contains("requireVex", verdict.Notes, System.StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(verdict.Score >= 0);
|
||||
}
|
||||
}
|
||||
Assert.Equal(PolicyVerdictStatus.Warned, manualVerdict.Status);
|
||||
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
var findings = ImmutableArray.Create(PolicyFinding.Create("finding-quiet", PolicySeverity.Low));
|
||||
var baseline = ImmutableArray<PolicyVerdict>.Empty;
|
||||
|
||||
var response = await service.PreviewAsync(new PolicyPreviewRequest(
|
||||
"sha256:quiet",
|
||||
findings,
|
||||
baseline),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(response.Success);
|
||||
var verdict = Assert.Single(response.Diffs).Projected;
|
||||
Assert.Equal(PolicyVerdictStatus.Warned, verdict.Status);
|
||||
Assert.Contains("requireVex", verdict.Notes, System.StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(verdict.Score >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyScoringConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoadDefaultReturnsConfig()
|
||||
{
|
||||
var config = PolicyScoringConfigBinder.LoadDefault();
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal("1.0", config.Version);
|
||||
Assert.NotEmpty(config.SeverityWeights);
|
||||
Assert.True(config.SeverityWeights.ContainsKey(PolicySeverity.Critical));
|
||||
Assert.True(config.QuietPenalty > 0);
|
||||
Assert.NotEmpty(config.ReachabilityBuckets);
|
||||
Assert.Contains("entrypoint", config.ReachabilityBuckets.Keys);
|
||||
Assert.False(config.UnknownConfidence.Bands.IsDefaultOrEmpty);
|
||||
Assert.Equal("high", config.UnknownConfidence.Bands[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRejectsEmptyContent()
|
||||
{
|
||||
var result = PolicyScoringConfigBinder.Bind(string.Empty, PolicyDocumentFormat.Json);
|
||||
Assert.False(result.Success);
|
||||
Assert.NotEmpty(result.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRejectsInvalidSchema()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"version": "1.0",
|
||||
"severityWeights": {
|
||||
"Critical": 90.0
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Issues, issue => issue.Code.StartsWith("scoring.schema", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Null(result.Config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultResourceDigestMatchesGolden()
|
||||
{
|
||||
var assembly = typeof(PolicyScoringConfig).Assembly;
|
||||
using var stream = assembly.GetManifestResourceStream("StellaOps.Policy.Schemas.policy-scoring-default.json")
|
||||
?? throw new InvalidOperationException("Unable to locate embedded scoring default resource.");
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
|
||||
var binding = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
|
||||
Assert.True(binding.Success);
|
||||
Assert.NotNull(binding.Config);
|
||||
|
||||
var digest = PolicyScoringConfigDigest.Compute(binding.Config!);
|
||||
Assert.Equal("5ef2e43a112cb00753beb7811dd2e1720f2385e2289d0fb6abcf7bbbb8cda2d2", digest);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyScoringConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoadDefaultReturnsConfig()
|
||||
{
|
||||
var config = PolicyScoringConfigBinder.LoadDefault();
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal("1.0", config.Version);
|
||||
Assert.NotEmpty(config.SeverityWeights);
|
||||
Assert.True(config.SeverityWeights.ContainsKey(PolicySeverity.Critical));
|
||||
Assert.True(config.QuietPenalty > 0);
|
||||
Assert.NotEmpty(config.ReachabilityBuckets);
|
||||
Assert.Contains("entrypoint", config.ReachabilityBuckets.Keys);
|
||||
Assert.False(config.UnknownConfidence.Bands.IsDefaultOrEmpty);
|
||||
Assert.Equal("high", config.UnknownConfidence.Bands[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRejectsEmptyContent()
|
||||
{
|
||||
var result = PolicyScoringConfigBinder.Bind(string.Empty, PolicyDocumentFormat.Json);
|
||||
Assert.False(result.Success);
|
||||
Assert.NotEmpty(result.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRejectsInvalidSchema()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"version": "1.0",
|
||||
"severityWeights": {
|
||||
"Critical": 90.0
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Issues, issue => issue.Code.StartsWith("scoring.schema", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Null(result.Config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultResourceDigestMatchesGolden()
|
||||
{
|
||||
var assembly = typeof(PolicyScoringConfig).Assembly;
|
||||
using var stream = assembly.GetManifestResourceStream("StellaOps.Policy.Schemas.policy-scoring-default.json")
|
||||
?? throw new InvalidOperationException("Unable to locate embedded scoring default resource.");
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
|
||||
var binding = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
|
||||
Assert.True(binding.Success);
|
||||
Assert.NotNull(binding.Config);
|
||||
|
||||
var digest = PolicyScoringConfigDigest.Compute(binding.Config!);
|
||||
Assert.Equal("5ef2e43a112cb00753beb7811dd2e1720f2385e2289d0fb6abcf7bbbb8cda2d2", digest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +1,94 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicySnapshotStoreTests
|
||||
{
|
||||
private const string BasePolicyYaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
action: block
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_CreatesNewSnapshotAndAuditEntry()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
|
||||
|
||||
var result = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.Created);
|
||||
Assert.NotNull(result.Snapshot);
|
||||
Assert.Equal("rev-1", result.Snapshot!.RevisionId);
|
||||
Assert.Equal(result.Digest, result.Snapshot.Digest);
|
||||
Assert.Equal(timeProvider.GetUtcNow(), result.Snapshot.CreatedAt);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, result.Snapshot.ScoringConfig.Version);
|
||||
|
||||
var latest = await store.GetLatestAsync();
|
||||
Assert.Equal(result.Snapshot, latest);
|
||||
|
||||
var audits = await auditRepo.ListAsync(10);
|
||||
Assert.Single(audits);
|
||||
Assert.Equal(result.Digest, audits[0].Digest);
|
||||
Assert.Equal("snapshot.created", audits[0].Action);
|
||||
Assert.Equal("rev-1", audits[0].RevisionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_DoesNotCreateNewRevisionWhenDigestUnchanged()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
|
||||
var first = await store.SaveAsync(content, CancellationToken.None);
|
||||
Assert.True(first.Created);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
var second = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.True(second.Success);
|
||||
Assert.False(second.Created);
|
||||
Assert.Equal(first.Digest, second.Digest);
|
||||
Assert.Equal("rev-1", second.Snapshot!.RevisionId);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, second.Snapshot.ScoringConfig.Version);
|
||||
|
||||
var audits = await auditRepo.ListAsync(10);
|
||||
Assert.Single(audits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ReturnsFailureWhenValidationFails()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
const string invalidYaml = "version: '1.0'\nrules: []";
|
||||
var content = new PolicySnapshotContent(invalidYaml, PolicyDocumentFormat.Yaml, null, null, null);
|
||||
|
||||
var result = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.Created);
|
||||
Assert.Null(result.Snapshot);
|
||||
|
||||
var audits = await auditRepo.ListAsync(5);
|
||||
Assert.Empty(audits);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicySnapshotStoreTests
|
||||
{
|
||||
private const string BasePolicyYaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
action: block
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_CreatesNewSnapshotAndAuditEntry()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
|
||||
|
||||
var result = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.Created);
|
||||
Assert.NotNull(result.Snapshot);
|
||||
Assert.Equal("rev-1", result.Snapshot!.RevisionId);
|
||||
Assert.Equal(result.Digest, result.Snapshot.Digest);
|
||||
Assert.Equal(timeProvider.GetUtcNow(), result.Snapshot.CreatedAt);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, result.Snapshot.ScoringConfig.Version);
|
||||
|
||||
var latest = await store.GetLatestAsync();
|
||||
Assert.Equal(result.Snapshot, latest);
|
||||
|
||||
var audits = await auditRepo.ListAsync(10);
|
||||
Assert.Single(audits);
|
||||
Assert.Equal(result.Digest, audits[0].Digest);
|
||||
Assert.Equal("snapshot.created", audits[0].Action);
|
||||
Assert.Equal("rev-1", audits[0].RevisionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_DoesNotCreateNewRevisionWhenDigestUnchanged()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
|
||||
var first = await store.SaveAsync(content, CancellationToken.None);
|
||||
Assert.True(first.Created);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
var second = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.True(second.Success);
|
||||
Assert.False(second.Created);
|
||||
Assert.Equal(first.Digest, second.Digest);
|
||||
Assert.Equal("rev-1", second.Snapshot!.RevisionId);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, second.Snapshot.ScoringConfig.Version);
|
||||
|
||||
var audits = await auditRepo.ListAsync(10);
|
||||
Assert.Single(audits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ReturnsFailureWhenValidationFails()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
const string invalidYaml = "version: '1.0'\nrules: []";
|
||||
var content = new PolicySnapshotContent(invalidYaml, PolicyDocumentFormat.Yaml, null, null, null);
|
||||
|
||||
var result = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.Created);
|
||||
Assert.Null(result.Snapshot);
|
||||
|
||||
var audits = await auditRepo.ListAsync(5);
|
||||
Assert.Empty(audits);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user