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

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 &lt; 0.8).
/// </summary>
public bool IsMediumConfidence => Confidence >= 0.5m && Confidence < 0.8m;
/// <summary>
/// Whether this reachability data has low confidence (&lt; 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 &lt; 0.8).
/// </summary>
public bool IsMediumConfidence => Confidence >= 0.5m && Confidence < 0.8m;
/// <summary>
/// Whether this reachability data has low confidence (&lt; 0.5).
/// </summary>
public bool IsLowConfidence => Confidence < 0.5m;
}
/// <summary>

View File

@@ -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,
};
}
}

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Policy.Engine.Tests")]
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Policy.Engine.Tests")]

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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]}";
}
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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];
}

View File

@@ -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;
}
}

View File

@@ -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";
}

View File

@@ -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
};
}

View File

@@ -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;
}
}

View File

@@ -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
{
}

View File

@@ -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.");
}
}

View File

@@ -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.");
}
}
}

View File

@@ -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
{
}

View File

@@ -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
{
}

View File

@@ -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));
}
}
}

View File

@@ -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;
}
}