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

View File

@@ -7,8 +7,8 @@ namespace StellaOps.Policy.Gateway.Clients;
internal interface IPolicyEngineClient
{
Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(GatewayForwardingContext? forwardingContext, CancellationToken cancellationToken);
Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(GatewayForwardingContext? forwardingContext, CancellationToken cancellationToken);
Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(GatewayForwardingContext? forwardingContext, CreatePolicyPackRequest request, CancellationToken cancellationToken);
Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(GatewayForwardingContext? forwardingContext, string packId, CreatePolicyRevisionRequest request, CancellationToken cancellationToken);

View File

@@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -14,79 +14,79 @@ using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using StellaOps.Policy.Scoring;
using StellaOps.Policy.Scoring.Receipts;
namespace StellaOps.Policy.Gateway.Clients;
internal sealed class PolicyEngineClient : IPolicyEngineClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly HttpClient httpClient;
private readonly PolicyEngineTokenProvider tokenProvider;
private readonly ILogger<PolicyEngineClient> logger;
private readonly PolicyGatewayOptions options;
public PolicyEngineClient(
HttpClient httpClient,
IOptions<PolicyGatewayOptions> options,
PolicyEngineTokenProvider tokenProvider,
ILogger<PolicyEngineClient> logger)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
this.options = options.Value ?? throw new InvalidOperationException("Policy Gateway options must be configured.");
if (httpClient.BaseAddress is null)
{
httpClient.BaseAddress = this.options.PolicyEngine.BaseUri;
}
httpClient.DefaultRequestHeaders.Accept.Clear();
httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
}
public Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(
GatewayForwardingContext? forwardingContext,
CancellationToken cancellationToken)
=> SendAsync<IReadOnlyList<PolicyPackSummaryDto>>(
HttpMethod.Get,
"api/policy/packs",
forwardingContext,
content: null,
cancellationToken);
public Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(
GatewayForwardingContext? forwardingContext,
CreatePolicyPackRequest request,
CancellationToken cancellationToken)
=> SendAsync<PolicyPackDto>(
HttpMethod.Post,
"api/policy/packs",
forwardingContext,
request,
cancellationToken);
public Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(
GatewayForwardingContext? forwardingContext,
string packId,
CreatePolicyRevisionRequest request,
CancellationToken cancellationToken)
=> SendAsync<PolicyRevisionDto>(
HttpMethod.Post,
$"api/policy/packs/{Uri.EscapeDataString(packId)}/revisions",
forwardingContext,
request,
cancellationToken);
namespace StellaOps.Policy.Gateway.Clients;
internal sealed class PolicyEngineClient : IPolicyEngineClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly HttpClient httpClient;
private readonly PolicyEngineTokenProvider tokenProvider;
private readonly ILogger<PolicyEngineClient> logger;
private readonly PolicyGatewayOptions options;
public PolicyEngineClient(
HttpClient httpClient,
IOptions<PolicyGatewayOptions> options,
PolicyEngineTokenProvider tokenProvider,
ILogger<PolicyEngineClient> logger)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
this.options = options.Value ?? throw new InvalidOperationException("Policy Gateway options must be configured.");
if (httpClient.BaseAddress is null)
{
httpClient.BaseAddress = this.options.PolicyEngine.BaseUri;
}
httpClient.DefaultRequestHeaders.Accept.Clear();
httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
}
public Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(
GatewayForwardingContext? forwardingContext,
CancellationToken cancellationToken)
=> SendAsync<IReadOnlyList<PolicyPackSummaryDto>>(
HttpMethod.Get,
"api/policy/packs",
forwardingContext,
content: null,
cancellationToken);
public Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(
GatewayForwardingContext? forwardingContext,
CreatePolicyPackRequest request,
CancellationToken cancellationToken)
=> SendAsync<PolicyPackDto>(
HttpMethod.Post,
"api/policy/packs",
forwardingContext,
request,
cancellationToken);
public Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(
GatewayForwardingContext? forwardingContext,
string packId,
CreatePolicyRevisionRequest request,
CancellationToken cancellationToken)
=> SendAsync<PolicyRevisionDto>(
HttpMethod.Post,
$"api/policy/packs/{Uri.EscapeDataString(packId)}/revisions",
forwardingContext,
request,
cancellationToken);
public Task<PolicyEngineResponse<PolicyRevisionActivationDto>> ActivatePolicyRevisionAsync(
GatewayForwardingContext? forwardingContext,
string packId,
@@ -154,103 +154,103 @@ internal sealed class PolicyEngineClient : IPolicyEngineClient
forwardingContext,
content: null,
cancellationToken);
private async Task<PolicyEngineResponse<TSuccess>> SendAsync<TSuccess>(
HttpMethod method,
string relativeUri,
GatewayForwardingContext? forwardingContext,
object? content,
CancellationToken cancellationToken)
{
var absoluteUri = httpClient.BaseAddress is not null
? new Uri(httpClient.BaseAddress, relativeUri)
: new Uri(relativeUri, UriKind.Absolute);
using var request = new HttpRequestMessage(method, absoluteUri);
if (forwardingContext is not null)
{
forwardingContext.Apply(request);
}
else
{
var serviceAuthorization = await tokenProvider.GetAuthorizationAsync(method, absoluteUri, cancellationToken).ConfigureAwait(false);
if (serviceAuthorization is null)
{
logger.LogWarning(
"Policy Engine request {Method} {Uri} lacks caller credentials and client credentials flow is disabled.",
method,
absoluteUri);
var problem = new ProblemDetails
{
Title = "Upstream authorization missing",
Detail = "Caller did not present credentials and client credentials flow is disabled.",
Status = StatusCodes.Status401Unauthorized
};
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.Unauthorized, problem);
}
var authorization = serviceAuthorization.Value;
authorization.Apply(request);
}
if (content is not null)
{
request.Content = JsonContent.Create(content, options: SerializerOptions);
}
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var location = response.Headers.Location?.ToString();
if (response.IsSuccessStatusCode)
{
if (response.Content is null || response.Content.Headers.ContentLength == 0)
{
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, value: default, location);
}
try
{
var successValue = await response.Content.ReadFromJsonAsync<TSuccess>(SerializerOptions, cancellationToken).ConfigureAwait(false);
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, successValue, location);
}
catch (JsonException ex)
{
logger.LogError(ex, "Failed to deserialize Policy Engine response for {Path}.", relativeUri);
var problem = new ProblemDetails
{
Title = "Invalid upstream response",
Detail = "Policy Engine returned an unexpected payload.",
Status = StatusCodes.Status502BadGateway
};
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.BadGateway, problem);
}
}
var problemDetails = await ReadProblemDetailsAsync(response, cancellationToken).ConfigureAwait(false);
return PolicyEngineResponse<TSuccess>.Failure(response.StatusCode, problemDetails);
}
private async Task<ProblemDetails?> ReadProblemDetailsAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.Content is null)
{
return null;
}
try
{
return await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
logger.LogDebug(ex, "Policy Engine returned non-ProblemDetails error response for {StatusCode}.", (int)response.StatusCode);
return new ProblemDetails
{
Title = "Upstream error",
Detail = $"Policy Engine responded with {(int)response.StatusCode} {response.ReasonPhrase}.",
Status = (int)response.StatusCode
};
}
}
}
private async Task<PolicyEngineResponse<TSuccess>> SendAsync<TSuccess>(
HttpMethod method,
string relativeUri,
GatewayForwardingContext? forwardingContext,
object? content,
CancellationToken cancellationToken)
{
var absoluteUri = httpClient.BaseAddress is not null
? new Uri(httpClient.BaseAddress, relativeUri)
: new Uri(relativeUri, UriKind.Absolute);
using var request = new HttpRequestMessage(method, absoluteUri);
if (forwardingContext is not null)
{
forwardingContext.Apply(request);
}
else
{
var serviceAuthorization = await tokenProvider.GetAuthorizationAsync(method, absoluteUri, cancellationToken).ConfigureAwait(false);
if (serviceAuthorization is null)
{
logger.LogWarning(
"Policy Engine request {Method} {Uri} lacks caller credentials and client credentials flow is disabled.",
method,
absoluteUri);
var problem = new ProblemDetails
{
Title = "Upstream authorization missing",
Detail = "Caller did not present credentials and client credentials flow is disabled.",
Status = StatusCodes.Status401Unauthorized
};
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.Unauthorized, problem);
}
var authorization = serviceAuthorization.Value;
authorization.Apply(request);
}
if (content is not null)
{
request.Content = JsonContent.Create(content, options: SerializerOptions);
}
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var location = response.Headers.Location?.ToString();
if (response.IsSuccessStatusCode)
{
if (response.Content is null || response.Content.Headers.ContentLength == 0)
{
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, value: default, location);
}
try
{
var successValue = await response.Content.ReadFromJsonAsync<TSuccess>(SerializerOptions, cancellationToken).ConfigureAwait(false);
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, successValue, location);
}
catch (JsonException ex)
{
logger.LogError(ex, "Failed to deserialize Policy Engine response for {Path}.", relativeUri);
var problem = new ProblemDetails
{
Title = "Invalid upstream response",
Detail = "Policy Engine returned an unexpected payload.",
Status = StatusCodes.Status502BadGateway
};
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.BadGateway, problem);
}
}
var problemDetails = await ReadProblemDetailsAsync(response, cancellationToken).ConfigureAwait(false);
return PolicyEngineResponse<TSuccess>.Failure(response.StatusCode, problemDetails);
}
private async Task<ProblemDetails?> ReadProblemDetailsAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.Content is null)
{
return null;
}
try
{
return await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
logger.LogDebug(ex, "Policy Engine returned non-ProblemDetails error response for {StatusCode}.", (int)response.StatusCode);
return new ProblemDetails
{
Title = "Upstream error",
Detail = $"Policy Engine responded with {(int)response.StatusCode} {response.ReasonPhrase}.",
Status = (int)response.StatusCode
};
}
}
}

View File

@@ -1,31 +1,31 @@
using System.Net;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Policy.Gateway.Clients;
internal sealed class PolicyEngineResponse<TSuccess>
{
private PolicyEngineResponse(HttpStatusCode statusCode, TSuccess? value, ProblemDetails? problem, string? location)
{
StatusCode = statusCode;
Value = value;
Problem = problem;
Location = location;
}
public HttpStatusCode StatusCode { get; }
public TSuccess? Value { get; }
public ProblemDetails? Problem { get; }
public string? Location { get; }
public bool IsSuccess => Problem is null && StatusCode is >= HttpStatusCode.OK and < HttpStatusCode.MultipleChoices;
public static PolicyEngineResponse<TSuccess> Success(HttpStatusCode statusCode, TSuccess? value, string? location)
=> new(statusCode, value, problem: null, location);
public static PolicyEngineResponse<TSuccess> Failure(HttpStatusCode statusCode, ProblemDetails? problem)
=> new(statusCode, value: default, problem, location: null);
}
using System.Net;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Policy.Gateway.Clients;
internal sealed class PolicyEngineResponse<TSuccess>
{
private PolicyEngineResponse(HttpStatusCode statusCode, TSuccess? value, ProblemDetails? problem, string? location)
{
StatusCode = statusCode;
Value = value;
Problem = problem;
Location = location;
}
public HttpStatusCode StatusCode { get; }
public TSuccess? Value { get; }
public ProblemDetails? Problem { get; }
public string? Location { get; }
public bool IsSuccess => Problem is null && StatusCode is >= HttpStatusCode.OK and < HttpStatusCode.MultipleChoices;
public static PolicyEngineResponse<TSuccess> Success(HttpStatusCode statusCode, TSuccess? value, string? location)
=> new(statusCode, value, problem: null, location);
public static PolicyEngineResponse<TSuccess> Failure(HttpStatusCode statusCode, ProblemDetails? problem)
=> new(statusCode, value: default, problem, location: null);
}

View File

@@ -1,71 +1,71 @@
using System;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Policy.Gateway.Clients;
internal static class PolicyEngineResponseExtensions
{
public static IResult ToMinimalResult<T>(this PolicyEngineResponse<T> response)
{
if (response is null)
{
throw new ArgumentNullException(nameof(response));
}
if (response.IsSuccess)
{
return CreateSuccessResult(response);
}
return CreateErrorResult(response);
}
private static IResult CreateSuccessResult<T>(PolicyEngineResponse<T> response)
{
var value = response.Value;
switch (response.StatusCode)
{
case HttpStatusCode.Created:
if (!string.IsNullOrWhiteSpace(response.Location))
{
return Results.Created(response.Location, value);
}
return Results.Json(value, statusCode: StatusCodes.Status201Created);
case HttpStatusCode.Accepted:
if (!string.IsNullOrWhiteSpace(response.Location))
{
return Results.Accepted(response.Location, value);
}
return Results.Json(value, statusCode: StatusCodes.Status202Accepted);
case HttpStatusCode.NoContent:
return Results.NoContent();
default:
return Results.Json(value, statusCode: (int)response.StatusCode);
}
}
private static IResult CreateErrorResult<T>(PolicyEngineResponse<T> response)
{
var problem = response.Problem;
if (problem is null)
{
return Results.StatusCode((int)response.StatusCode);
}
var statusCode = problem.Status ?? (int)response.StatusCode;
return Results.Problem(
title: problem.Title,
detail: problem.Detail,
type: problem.Type,
instance: problem.Instance,
statusCode: statusCode,
extensions: problem.Extensions);
}
}
using System;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Policy.Gateway.Clients;
internal static class PolicyEngineResponseExtensions
{
public static IResult ToMinimalResult<T>(this PolicyEngineResponse<T> response)
{
if (response is null)
{
throw new ArgumentNullException(nameof(response));
}
if (response.IsSuccess)
{
return CreateSuccessResult(response);
}
return CreateErrorResult(response);
}
private static IResult CreateSuccessResult<T>(PolicyEngineResponse<T> response)
{
var value = response.Value;
switch (response.StatusCode)
{
case HttpStatusCode.Created:
if (!string.IsNullOrWhiteSpace(response.Location))
{
return Results.Created(response.Location, value);
}
return Results.Json(value, statusCode: StatusCodes.Status201Created);
case HttpStatusCode.Accepted:
if (!string.IsNullOrWhiteSpace(response.Location))
{
return Results.Accepted(response.Location, value);
}
return Results.Json(value, statusCode: StatusCodes.Status202Accepted);
case HttpStatusCode.NoContent:
return Results.NoContent();
default:
return Results.Json(value, statusCode: (int)response.StatusCode);
}
}
private static IResult CreateErrorResult<T>(PolicyEngineResponse<T> response)
{
var problem = response.Problem;
if (problem is null)
{
return Results.StatusCode((int)response.StatusCode);
}
var statusCode = problem.Status ?? (int)response.StatusCode;
return Results.Problem(
title: problem.Title,
detail: problem.Detail,
type: problem.Type,
instance: problem.Instance,
statusCode: statusCode,
extensions: problem.Extensions);
}
}

View File

@@ -1,59 +1,59 @@
using System;
using System.Net.Http;
using Microsoft.AspNetCore.Http;
namespace StellaOps.Policy.Gateway.Infrastructure;
internal sealed record GatewayForwardingContext(string Authorization, string? Dpop, string? Tenant)
{
private static readonly string[] ForwardedHeaders =
{
"Authorization",
"DPoP",
"X-Stella-Tenant"
};
public void Apply(HttpRequestMessage request)
{
ArgumentNullException.ThrowIfNull(request);
request.Headers.TryAddWithoutValidation(ForwardedHeaders[0], Authorization);
if (!string.IsNullOrWhiteSpace(Dpop))
{
request.Headers.TryAddWithoutValidation(ForwardedHeaders[1], Dpop);
}
if (!string.IsNullOrWhiteSpace(Tenant))
{
request.Headers.TryAddWithoutValidation(ForwardedHeaders[2], Tenant);
}
}
public static bool TryCreate(HttpContext context, out GatewayForwardingContext forwardingContext)
{
ArgumentNullException.ThrowIfNull(context);
var authorization = context.Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authorization))
{
forwardingContext = null!;
return false;
}
var dpop = context.Request.Headers["DPoP"].ToString();
if (string.IsNullOrWhiteSpace(dpop))
{
dpop = null;
}
var tenant = context.Request.Headers["X-Stella-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenant))
{
tenant = null;
}
forwardingContext = new GatewayForwardingContext(authorization.Trim(), dpop, tenant);
return true;
}
}
using System;
using System.Net.Http;
using Microsoft.AspNetCore.Http;
namespace StellaOps.Policy.Gateway.Infrastructure;
internal sealed record GatewayForwardingContext(string Authorization, string? Dpop, string? Tenant)
{
private static readonly string[] ForwardedHeaders =
{
"Authorization",
"DPoP",
"X-Stella-Tenant"
};
public void Apply(HttpRequestMessage request)
{
ArgumentNullException.ThrowIfNull(request);
request.Headers.TryAddWithoutValidation(ForwardedHeaders[0], Authorization);
if (!string.IsNullOrWhiteSpace(Dpop))
{
request.Headers.TryAddWithoutValidation(ForwardedHeaders[1], Dpop);
}
if (!string.IsNullOrWhiteSpace(Tenant))
{
request.Headers.TryAddWithoutValidation(ForwardedHeaders[2], Tenant);
}
}
public static bool TryCreate(HttpContext context, out GatewayForwardingContext forwardingContext)
{
ArgumentNullException.ThrowIfNull(context);
var authorization = context.Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authorization))
{
forwardingContext = null!;
return false;
}
var dpop = context.Request.Headers["DPoP"].ToString();
if (string.IsNullOrWhiteSpace(dpop))
{
dpop = null;
}
var tenant = context.Request.Headers["X-Stella-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenant))
{
tenant = null;
}
forwardingContext = new GatewayForwardingContext(authorization.Trim(), dpop, tenant);
return true;
}
}

View File

@@ -1,323 +1,323 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Policy.Gateway.Options;
/// <summary>
/// Root configuration for the Policy Gateway host.
/// </summary>
public sealed class PolicyGatewayOptions
{
public const string SectionName = "PolicyGateway";
public PolicyGatewayTelemetryOptions Telemetry { get; } = new();
public PolicyGatewayResourceServerOptions ResourceServer { get; } = new();
public PolicyGatewayPolicyEngineOptions PolicyEngine { get; } = new();
public void Validate()
{
Telemetry.Validate();
ResourceServer.Validate();
PolicyEngine.Validate();
}
}
/// <summary>
/// Logging and telemetry configuration for the gateway.
/// </summary>
public sealed class PolicyGatewayTelemetryOptions
{
public LogLevel MinimumLogLevel { get; set; } = LogLevel.Information;
public void Validate()
{
if (!Enum.IsDefined(typeof(LogLevel), MinimumLogLevel))
{
throw new InvalidOperationException("Unsupported log level configured for Policy Gateway telemetry.");
}
}
}
/// <summary>
/// JWT resource server configuration for incoming requests handled by the gateway.
/// </summary>
public sealed class PolicyGatewayResourceServerOptions
{
public string Authority { get; set; } = "https://authority.stella-ops.local";
public string? MetadataAddress { get; set; }
= "https://authority.stella-ops.local/.well-known/openid-configuration";
public IList<string> Audiences { get; } = new List<string> { "api://policy-gateway" };
public IList<string> RequiredScopes { get; } = new List<string>
{
StellaOpsScopes.PolicyRead,
StellaOpsScopes.PolicyAuthor,
StellaOpsScopes.PolicyReview,
StellaOpsScopes.PolicyApprove,
StellaOpsScopes.PolicyOperate,
StellaOpsScopes.PolicySimulate,
StellaOpsScopes.PolicyRun,
StellaOpsScopes.PolicyActivate
};
public IList<string> RequiredTenants { get; } = new List<string>();
public IList<string> BypassNetworks { get; } = new List<string> { "127.0.0.1/32", "::1/128" };
public bool RequireHttpsMetadata { get; set; } = true;
public int BackchannelTimeoutSeconds { get; set; } = 30;
public int TokenClockSkewSeconds { get; set; } = 60;
public void Validate()
{
if (string.IsNullOrWhiteSpace(Authority))
{
throw new InvalidOperationException("Policy Gateway resource server configuration requires an Authority URL.");
}
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
{
throw new InvalidOperationException("Policy Gateway resource server Authority URL must be absolute.");
}
if (RequireHttpsMetadata &&
!authorityUri.IsLoopback &&
!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Policy Gateway resource server Authority URL must use HTTPS when metadata requires HTTPS.");
}
if (BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Policy Gateway resource server back-channel timeout must be greater than zero seconds.");
}
if (TokenClockSkewSeconds < 0 || TokenClockSkewSeconds > 300)
{
throw new InvalidOperationException("Policy Gateway resource server token clock skew must be between 0 and 300 seconds.");
}
NormalizeList(Audiences, toLower: false);
NormalizeList(RequiredScopes, toLower: true);
NormalizeList(RequiredTenants, toLower: true);
NormalizeList(BypassNetworks, toLower: false);
}
private static void NormalizeList(IList<string> values, bool toLower)
{
if (values.Count == 0)
{
return;
}
var unique = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = values.Count - 1; index >= 0; index--)
{
var value = values[index];
if (string.IsNullOrWhiteSpace(value))
{
values.RemoveAt(index);
continue;
}
var normalized = value.Trim();
if (toLower)
{
normalized = normalized.ToLowerInvariant();
}
if (!unique.Add(normalized))
{
values.RemoveAt(index);
continue;
}
values[index] = normalized;
}
}
}
/// <summary>
/// Outbound Policy Engine configuration used by the gateway to forward requests.
/// </summary>
public sealed class PolicyGatewayPolicyEngineOptions
{
public string BaseAddress { get; set; } = "https://policy-engine.stella-ops.local";
public string Audience { get; set; } = "api://policy-engine";
public PolicyGatewayClientCredentialsOptions ClientCredentials { get; } = new();
public PolicyGatewayDpopOptions Dpop { get; } = new();
public void Validate()
{
if (string.IsNullOrWhiteSpace(BaseAddress))
{
throw new InvalidOperationException("Policy Gateway requires a Policy Engine base address.");
}
if (!Uri.TryCreate(BaseAddress.Trim(), UriKind.Absolute, out var baseUri))
{
throw new InvalidOperationException("Policy Gateway Policy Engine base address must be an absolute URI.");
}
if (!string.Equals(baseUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && !baseUri.IsLoopback)
{
throw new InvalidOperationException("Policy Gateway Policy Engine base address must use HTTPS unless targeting loopback.");
}
if (string.IsNullOrWhiteSpace(Audience))
{
throw new InvalidOperationException("Policy Gateway requires a Policy Engine audience value for client credential flows.");
}
ClientCredentials.Validate();
Dpop.Validate();
}
public Uri BaseUri => new(BaseAddress, UriKind.Absolute);
}
/// <summary>
/// Client credential configuration for the gateway when calling the Policy Engine.
/// </summary>
public sealed class PolicyGatewayClientCredentialsOptions
{
public bool Enabled { get; set; } = true;
public string ClientId { get; set; } = "policy-gateway";
public string? ClientSecret { get; set; }
= "change-me";
public IList<string> Scopes { get; } = new List<string>
{
StellaOpsScopes.PolicyRead,
StellaOpsScopes.PolicyAuthor,
StellaOpsScopes.PolicyReview,
StellaOpsScopes.PolicyApprove,
StellaOpsScopes.PolicyOperate,
StellaOpsScopes.PolicySimulate,
StellaOpsScopes.PolicyRun,
StellaOpsScopes.PolicyActivate
};
public int BackchannelTimeoutSeconds { get; set; } = 30;
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(ClientId))
{
throw new InvalidOperationException("Policy Gateway client credential configuration requires a client identifier when enabled.");
}
if (Scopes.Count == 0)
{
throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one scope when enabled.");
}
var normalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = Scopes.Count - 1; index >= 0; index--)
{
var scope = Scopes[index];
if (string.IsNullOrWhiteSpace(scope))
{
Scopes.RemoveAt(index);
continue;
}
var trimmed = scope.Trim().ToLowerInvariant();
if (!normalized.Add(trimmed))
{
Scopes.RemoveAt(index);
continue;
}
Scopes[index] = trimmed;
}
if (Scopes.Count == 0)
{
throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one non-empty scope when enabled.");
}
if (BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Policy Gateway client credential back-channel timeout must be greater than zero seconds.");
}
}
public IReadOnlyList<string> NormalizedScopes => new ReadOnlyCollection<string>(Scopes);
public TimeSpan BackchannelTimeout => TimeSpan.FromSeconds(BackchannelTimeoutSeconds);
}
/// <summary>
/// DPoP sender-constrained credential configuration for outbound Policy Engine calls.
/// </summary>
public sealed class PolicyGatewayDpopOptions
{
public bool Enabled { get; set; } = false;
public string KeyPath { get; set; } = string.Empty;
public string? KeyPassphrase { get; set; }
= null;
public string Algorithm { get; set; } = "ES256";
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
public TimeSpan ClockSkew { get; set; } = TimeSpan.FromSeconds(30);
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(KeyPath))
{
throw new InvalidOperationException("Policy Gateway DPoP configuration requires a key path when enabled.");
}
if (string.IsNullOrWhiteSpace(Algorithm))
{
throw new InvalidOperationException("Policy Gateway DPoP configuration requires an algorithm when enabled.");
}
var normalizedAlgorithm = Algorithm.Trim().ToUpperInvariant();
if (normalizedAlgorithm is not ("ES256" or "ES384"))
{
throw new InvalidOperationException("Policy Gateway DPoP configuration supports only ES256 or ES384 algorithms.");
}
if (ProofLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("Policy Gateway DPoP proof lifetime must be greater than zero.");
}
if (ClockSkew < TimeSpan.Zero || ClockSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("Policy Gateway DPoP clock skew must be between 0 seconds and 5 minutes.");
}
Algorithm = normalizedAlgorithm;
}
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Policy.Gateway.Options;
/// <summary>
/// Root configuration for the Policy Gateway host.
/// </summary>
public sealed class PolicyGatewayOptions
{
public const string SectionName = "PolicyGateway";
public PolicyGatewayTelemetryOptions Telemetry { get; } = new();
public PolicyGatewayResourceServerOptions ResourceServer { get; } = new();
public PolicyGatewayPolicyEngineOptions PolicyEngine { get; } = new();
public void Validate()
{
Telemetry.Validate();
ResourceServer.Validate();
PolicyEngine.Validate();
}
}
/// <summary>
/// Logging and telemetry configuration for the gateway.
/// </summary>
public sealed class PolicyGatewayTelemetryOptions
{
public LogLevel MinimumLogLevel { get; set; } = LogLevel.Information;
public void Validate()
{
if (!Enum.IsDefined(typeof(LogLevel), MinimumLogLevel))
{
throw new InvalidOperationException("Unsupported log level configured for Policy Gateway telemetry.");
}
}
}
/// <summary>
/// JWT resource server configuration for incoming requests handled by the gateway.
/// </summary>
public sealed class PolicyGatewayResourceServerOptions
{
public string Authority { get; set; } = "https://authority.stella-ops.local";
public string? MetadataAddress { get; set; }
= "https://authority.stella-ops.local/.well-known/openid-configuration";
public IList<string> Audiences { get; } = new List<string> { "api://policy-gateway" };
public IList<string> RequiredScopes { get; } = new List<string>
{
StellaOpsScopes.PolicyRead,
StellaOpsScopes.PolicyAuthor,
StellaOpsScopes.PolicyReview,
StellaOpsScopes.PolicyApprove,
StellaOpsScopes.PolicyOperate,
StellaOpsScopes.PolicySimulate,
StellaOpsScopes.PolicyRun,
StellaOpsScopes.PolicyActivate
};
public IList<string> RequiredTenants { get; } = new List<string>();
public IList<string> BypassNetworks { get; } = new List<string> { "127.0.0.1/32", "::1/128" };
public bool RequireHttpsMetadata { get; set; } = true;
public int BackchannelTimeoutSeconds { get; set; } = 30;
public int TokenClockSkewSeconds { get; set; } = 60;
public void Validate()
{
if (string.IsNullOrWhiteSpace(Authority))
{
throw new InvalidOperationException("Policy Gateway resource server configuration requires an Authority URL.");
}
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
{
throw new InvalidOperationException("Policy Gateway resource server Authority URL must be absolute.");
}
if (RequireHttpsMetadata &&
!authorityUri.IsLoopback &&
!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Policy Gateway resource server Authority URL must use HTTPS when metadata requires HTTPS.");
}
if (BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Policy Gateway resource server back-channel timeout must be greater than zero seconds.");
}
if (TokenClockSkewSeconds < 0 || TokenClockSkewSeconds > 300)
{
throw new InvalidOperationException("Policy Gateway resource server token clock skew must be between 0 and 300 seconds.");
}
NormalizeList(Audiences, toLower: false);
NormalizeList(RequiredScopes, toLower: true);
NormalizeList(RequiredTenants, toLower: true);
NormalizeList(BypassNetworks, toLower: false);
}
private static void NormalizeList(IList<string> values, bool toLower)
{
if (values.Count == 0)
{
return;
}
var unique = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = values.Count - 1; index >= 0; index--)
{
var value = values[index];
if (string.IsNullOrWhiteSpace(value))
{
values.RemoveAt(index);
continue;
}
var normalized = value.Trim();
if (toLower)
{
normalized = normalized.ToLowerInvariant();
}
if (!unique.Add(normalized))
{
values.RemoveAt(index);
continue;
}
values[index] = normalized;
}
}
}
/// <summary>
/// Outbound Policy Engine configuration used by the gateway to forward requests.
/// </summary>
public sealed class PolicyGatewayPolicyEngineOptions
{
public string BaseAddress { get; set; } = "https://policy-engine.stella-ops.local";
public string Audience { get; set; } = "api://policy-engine";
public PolicyGatewayClientCredentialsOptions ClientCredentials { get; } = new();
public PolicyGatewayDpopOptions Dpop { get; } = new();
public void Validate()
{
if (string.IsNullOrWhiteSpace(BaseAddress))
{
throw new InvalidOperationException("Policy Gateway requires a Policy Engine base address.");
}
if (!Uri.TryCreate(BaseAddress.Trim(), UriKind.Absolute, out var baseUri))
{
throw new InvalidOperationException("Policy Gateway Policy Engine base address must be an absolute URI.");
}
if (!string.Equals(baseUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && !baseUri.IsLoopback)
{
throw new InvalidOperationException("Policy Gateway Policy Engine base address must use HTTPS unless targeting loopback.");
}
if (string.IsNullOrWhiteSpace(Audience))
{
throw new InvalidOperationException("Policy Gateway requires a Policy Engine audience value for client credential flows.");
}
ClientCredentials.Validate();
Dpop.Validate();
}
public Uri BaseUri => new(BaseAddress, UriKind.Absolute);
}
/// <summary>
/// Client credential configuration for the gateway when calling the Policy Engine.
/// </summary>
public sealed class PolicyGatewayClientCredentialsOptions
{
public bool Enabled { get; set; } = true;
public string ClientId { get; set; } = "policy-gateway";
public string? ClientSecret { get; set; }
= "change-me";
public IList<string> Scopes { get; } = new List<string>
{
StellaOpsScopes.PolicyRead,
StellaOpsScopes.PolicyAuthor,
StellaOpsScopes.PolicyReview,
StellaOpsScopes.PolicyApprove,
StellaOpsScopes.PolicyOperate,
StellaOpsScopes.PolicySimulate,
StellaOpsScopes.PolicyRun,
StellaOpsScopes.PolicyActivate
};
public int BackchannelTimeoutSeconds { get; set; } = 30;
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(ClientId))
{
throw new InvalidOperationException("Policy Gateway client credential configuration requires a client identifier when enabled.");
}
if (Scopes.Count == 0)
{
throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one scope when enabled.");
}
var normalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = Scopes.Count - 1; index >= 0; index--)
{
var scope = Scopes[index];
if (string.IsNullOrWhiteSpace(scope))
{
Scopes.RemoveAt(index);
continue;
}
var trimmed = scope.Trim().ToLowerInvariant();
if (!normalized.Add(trimmed))
{
Scopes.RemoveAt(index);
continue;
}
Scopes[index] = trimmed;
}
if (Scopes.Count == 0)
{
throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one non-empty scope when enabled.");
}
if (BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Policy Gateway client credential back-channel timeout must be greater than zero seconds.");
}
}
public IReadOnlyList<string> NormalizedScopes => new ReadOnlyCollection<string>(Scopes);
public TimeSpan BackchannelTimeout => TimeSpan.FromSeconds(BackchannelTimeoutSeconds);
}
/// <summary>
/// DPoP sender-constrained credential configuration for outbound Policy Engine calls.
/// </summary>
public sealed class PolicyGatewayDpopOptions
{
public bool Enabled { get; set; } = false;
public string KeyPath { get; set; } = string.Empty;
public string? KeyPassphrase { get; set; }
= null;
public string Algorithm { get; set; } = "ES256";
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
public TimeSpan ClockSkew { get; set; } = TimeSpan.FromSeconds(30);
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(KeyPath))
{
throw new InvalidOperationException("Policy Gateway DPoP configuration requires a key path when enabled.");
}
if (string.IsNullOrWhiteSpace(Algorithm))
{
throw new InvalidOperationException("Policy Gateway DPoP configuration requires an algorithm when enabled.");
}
var normalizedAlgorithm = Algorithm.Trim().ToUpperInvariant();
if (normalizedAlgorithm is not ("ES256" or "ES384"))
{
throw new InvalidOperationException("Policy Gateway DPoP configuration supports only ES256 or ES384 algorithms.");
}
if (ProofLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("Policy Gateway DPoP proof lifetime must be greater than zero.");
}
if (ClockSkew < TimeSpan.Zero || ClockSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("Policy Gateway DPoP clock skew must be between 0 seconds and 5 minutes.");
}
Algorithm = normalizedAlgorithm;
}
}

View File

@@ -1,155 +1,155 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NetEscapades.Configuration.Yaml;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Policy.Gateway.Clients;
using StellaOps.Policy.Gateway.Contracts;
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NetEscapades.Configuration.Yaml;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Policy.Gateway.Clients;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Infrastructure;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Polly;
using Polly.Extensions.Http;
using StellaOps.AirGap.Policy;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole();
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
options.ConfigureBuilder = configurationBuilder =>
{
var contentRoot = builder.Environment.ContentRootPath;
foreach (var relative in new[]
{
"../etc/policy-gateway.yaml",
"../etc/policy-gateway.local.yaml",
"policy-gateway.yaml",
"policy-gateway.local.yaml"
})
{
var path = Path.Combine(contentRoot, relative);
configurationBuilder.AddYamlFile(path, optional: true);
}
};
});
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyGatewayOptions>(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
options.BindingSection = PolicyGatewayOptions.SectionName;
options.ConfigureBuilder = configurationBuilder =>
{
foreach (var relative in new[]
{
"../etc/policy-gateway.yaml",
"../etc/policy-gateway.local.yaml",
"policy-gateway.yaml",
"policy-gateway.local.yaml"
})
{
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
configurationBuilder.AddYamlFile(path, optional: true);
}
};
options.PostBind = static (value, _) => value.Validate();
});
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole();
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
options.ConfigureBuilder = configurationBuilder =>
{
var contentRoot = builder.Environment.ContentRootPath;
foreach (var relative in new[]
{
"../etc/policy-gateway.yaml",
"../etc/policy-gateway.local.yaml",
"policy-gateway.yaml",
"policy-gateway.local.yaml"
})
{
var path = Path.Combine(contentRoot, relative);
configurationBuilder.AddYamlFile(path, optional: true);
}
};
});
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyGatewayOptions>(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
options.BindingSection = PolicyGatewayOptions.SectionName;
options.ConfigureBuilder = configurationBuilder =>
{
foreach (var relative in new[]
{
"../etc/policy-gateway.yaml",
"../etc/policy-gateway.local.yaml",
"policy-gateway.yaml",
"policy-gateway.local.yaml"
})
{
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
configurationBuilder.AddYamlFile(path, optional: true);
}
};
options.PostBind = static (value, _) => value.Validate();
});
builder.Configuration.AddConfiguration(bootstrap.Configuration);
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
builder.Logging.SetMinimumLevel(bootstrap.Options.Telemetry.MinimumLogLevel);
builder.Services.AddOptions<PolicyGatewayOptions>()
.Bind(builder.Configuration.GetSection(PolicyGatewayOptions.SectionName))
.Validate(options =>
{
try
{
options.Validate();
return true;
}
catch (Exception ex)
{
throw new OptionsValidationException(
PolicyGatewayOptions.SectionName,
typeof(PolicyGatewayOptions),
new[] { ex.Message });
}
})
.ValidateOnStart();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddStellaOpsScopeHandler();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
builder.Services.AddSingleton<PolicyGatewayMetrics>();
builder.Services.AddSingleton<PolicyGatewayDpopProofGenerator>();
builder.Services.AddSingleton<PolicyEngineTokenProvider>();
builder.Services.AddTransient<PolicyGatewayDpopHandler>();
if (bootstrap.Options.PolicyEngine.ClientCredentials.Enabled)
{
builder.Services.AddOptions<StellaOpsAuthClientOptions>()
.Configure(options =>
{
options.Authority = bootstrap.Options.ResourceServer.Authority;
options.ClientId = bootstrap.Options.PolicyEngine.ClientCredentials.ClientId;
options.ClientSecret = bootstrap.Options.PolicyEngine.ClientCredentials.ClientSecret;
options.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
foreach (var scope in bootstrap.Options.PolicyEngine.ClientCredentials.Scopes)
{
options.DefaultScopes.Add(scope);
}
})
.PostConfigure(static opt => opt.Validate());
builder.Services.TryAddSingleton<IStellaOpsTokenCache, InMemoryTokenCache>();
builder.Services.AddHttpClient<StellaOpsDiscoveryCache>((provider, client) =>
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = authOptions.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
builder.Services.AddHttpClient<StellaOpsJwksCache>((provider, client) =>
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = authOptions.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
builder.Services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) =>
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = authOptions.HttpTimeout;
})
.AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider))
.AddHttpMessageHandler<PolicyGatewayDpopHandler>();
}
builder.Services.AddOptions<PolicyGatewayOptions>()
.Bind(builder.Configuration.GetSection(PolicyGatewayOptions.SectionName))
.Validate(options =>
{
try
{
options.Validate();
return true;
}
catch (Exception ex)
{
throw new OptionsValidationException(
PolicyGatewayOptions.SectionName,
typeof(PolicyGatewayOptions),
new[] { ex.Message });
}
})
.ValidateOnStart();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddStellaOpsScopeHandler();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
builder.Services.AddSingleton<PolicyGatewayMetrics>();
builder.Services.AddSingleton<PolicyGatewayDpopProofGenerator>();
builder.Services.AddSingleton<PolicyEngineTokenProvider>();
builder.Services.AddTransient<PolicyGatewayDpopHandler>();
if (bootstrap.Options.PolicyEngine.ClientCredentials.Enabled)
{
builder.Services.AddOptions<StellaOpsAuthClientOptions>()
.Configure(options =>
{
options.Authority = bootstrap.Options.ResourceServer.Authority;
options.ClientId = bootstrap.Options.PolicyEngine.ClientCredentials.ClientId;
options.ClientSecret = bootstrap.Options.PolicyEngine.ClientCredentials.ClientSecret;
options.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
foreach (var scope in bootstrap.Options.PolicyEngine.ClientCredentials.Scopes)
{
options.DefaultScopes.Add(scope);
}
})
.PostConfigure(static opt => opt.Validate());
builder.Services.TryAddSingleton<IStellaOpsTokenCache, InMemoryTokenCache>();
builder.Services.AddHttpClient<StellaOpsDiscoveryCache>((provider, client) =>
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = authOptions.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
builder.Services.AddHttpClient<StellaOpsJwksCache>((provider, client) =>
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = authOptions.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
builder.Services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) =>
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = authOptions.HttpTimeout;
})
.AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider))
.AddHttpMessageHandler<PolicyGatewayDpopHandler>();
}
builder.Services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>((serviceProvider, client) =>
{
var gatewayOptions = serviceProvider.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value;
@@ -161,175 +161,175 @@ builder.Services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>((service
client.BaseAddress = gatewayOptions.PolicyEngine.BaseUri;
client.Timeout = TimeSpan.FromSeconds(gatewayOptions.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
})
.AddPolicyHandler(static (provider, _) => CreatePolicyEngineRetryPolicy(provider));
var app = builder.Build();
app.UseExceptionHandler(static appBuilder => appBuilder.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { error = "Unexpected gateway error." });
}));
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz");
app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
.WithName("Readiness");
app.MapGet("/", () => Results.Redirect("/healthz"));
var policyPacks = app.MapGroup("/api/policy/packs")
.WithTags("Policy Packs");
policyPacks.MapGet(string.Empty, async Task<IResult> (
HttpContext context,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.ListPolicyPacksAsync(forwardingContext, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
policyPacks.MapPost(string.Empty, async Task<IResult> (
HttpContext context,
CreatePolicyPackRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.CreatePolicyPackAsync(forwardingContext, request, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
policyPacks.MapPost("/{packId}/revisions", async Task<IResult> (
HttpContext context,
string packId,
CreatePolicyRevisionRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(packId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "packId is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.CreatePolicyRevisionAsync(forwardingContext, packId, request, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
.AddPolicyHandler(static (provider, _) => CreatePolicyEngineRetryPolicy(provider));
var app = builder.Build();
app.UseExceptionHandler(static appBuilder => appBuilder.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { error = "Unexpected gateway error." });
}));
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz");
app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
.WithName("Readiness");
app.MapGet("/", () => Results.Redirect("/healthz"));
var policyPacks = app.MapGroup("/api/policy/packs")
.WithTags("Policy Packs");
policyPacks.MapGet(string.Empty, async Task<IResult> (
HttpContext context,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.ListPolicyPacksAsync(forwardingContext, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
policyPacks.MapPost(string.Empty, async Task<IResult> (
HttpContext context,
CreatePolicyPackRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.CreatePolicyPackAsync(forwardingContext, request, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
policyPacks.MapPost("/{packId}/revisions", async Task<IResult> (
HttpContext context,
string packId,
CreatePolicyRevisionRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(packId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "packId is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.CreatePolicyRevisionAsync(forwardingContext, packId, request, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
policyPacks.MapPost("/{packId}/revisions/{version:int}:activate", async Task<IResult> (
HttpContext context,
string packId,
int version,
ActivatePolicyRevisionRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
PolicyGatewayMetrics metrics,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(packId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "packId is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
var source = "service";
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
source = "caller";
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.ActivatePolicyRevisionAsync(forwardingContext, packId, version, request, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
var outcome = DetermineActivationOutcome(response);
metrics.RecordActivation(outcome, source, stopwatch.Elapsed.TotalMilliseconds);
var logger = loggerFactory.CreateLogger("StellaOps.Policy.Gateway.Activation");
LogActivation(logger, packId, version, outcome, source, response.StatusCode);
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
PolicyGatewayMetrics metrics,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(packId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "packId is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
var source = "service";
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
source = "caller";
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.ActivatePolicyRevisionAsync(forwardingContext, packId, version, request, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
var outcome = DetermineActivationOutcome(response);
metrics.RecordActivation(outcome, source, stopwatch.Elapsed.TotalMilliseconds);
var logger = loggerFactory.CreateLogger("StellaOps.Policy.Gateway.Activation");
LogActivation(logger, packId, version, outcome, source, response.StatusCode);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
@@ -468,78 +468,78 @@ cvss.MapGet("/policies", async Task<IResult>(
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
app.Run();
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
var delays = authOptions.NormalizedRetryDelays;
if (delays.Count == 0)
{
return Policy.NoOpAsync<HttpResponseMessage>();
}
var loggerFactory = provider.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger("PolicyGateway.AuthorityHttp");
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(static message => message.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
delays.Count,
attempt => delays[attempt - 1],
(outcome, delay, attempt, _) =>
{
logger?.LogWarning(
outcome.Exception,
"Retrying Authority HTTP call ({Attempt}/{Total}) after {Reason}; waiting {Delay}.",
attempt,
delays.Count,
outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString(),
delay);
});
}
static IAsyncPolicy<HttpResponseMessage> CreatePolicyEngineRetryPolicy(IServiceProvider provider)
=> HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(static response => response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout)
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
static string DetermineActivationOutcome(PolicyEngineResponse<PolicyRevisionActivationDto> response)
{
if (response.IsSuccess)
{
return response.Value?.Status switch
{
"activated" => "activated",
"already_active" => "already_active",
"pending_second_approval" => "pending_second_approval",
_ => "success"
};
}
return response.StatusCode switch
{
HttpStatusCode.BadRequest => "bad_request",
HttpStatusCode.NotFound => "not_found",
HttpStatusCode.Unauthorized => "unauthorized",
HttpStatusCode.Forbidden => "forbidden",
_ => "error"
};
}
static void LogActivation(ILogger logger, string packId, int version, string outcome, string source, HttpStatusCode statusCode)
{
if (logger is null)
{
return;
}
var message = "Policy activation forwarded.";
var logLevel = outcome is "activated" or "already_active" or "pending_second_approval" ? LogLevel.Information : LogLevel.Warning;
logger.Log(logLevel, message + " Outcome={Outcome}; Source={Source}; PackId={PackId}; Version={Version}; StatusCode={StatusCode}.", outcome, source, packId, version, (int)statusCode);
}
public partial class Program
{
}
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
var delays = authOptions.NormalizedRetryDelays;
if (delays.Count == 0)
{
return Policy.NoOpAsync<HttpResponseMessage>();
}
var loggerFactory = provider.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger("PolicyGateway.AuthorityHttp");
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(static message => message.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
delays.Count,
attempt => delays[attempt - 1],
(outcome, delay, attempt, _) =>
{
logger?.LogWarning(
outcome.Exception,
"Retrying Authority HTTP call ({Attempt}/{Total}) after {Reason}; waiting {Delay}.",
attempt,
delays.Count,
outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString(),
delay);
});
}
static IAsyncPolicy<HttpResponseMessage> CreatePolicyEngineRetryPolicy(IServiceProvider provider)
=> HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(static response => response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout)
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
static string DetermineActivationOutcome(PolicyEngineResponse<PolicyRevisionActivationDto> response)
{
if (response.IsSuccess)
{
return response.Value?.Status switch
{
"activated" => "activated",
"already_active" => "already_active",
"pending_second_approval" => "pending_second_approval",
_ => "success"
};
}
return response.StatusCode switch
{
HttpStatusCode.BadRequest => "bad_request",
HttpStatusCode.NotFound => "not_found",
HttpStatusCode.Unauthorized => "unauthorized",
HttpStatusCode.Forbidden => "forbidden",
_ => "error"
};
}
static void LogActivation(ILogger logger, string packId, int version, string outcome, string source, HttpStatusCode statusCode)
{
if (logger is null)
{
return;
}
var message = "Policy activation forwarded.";
var logLevel = outcome is "activated" or "already_active" or "pending_second_approval" ? LogLevel.Information : LogLevel.Warning;
logger.Log(logLevel, message + " Outcome={Outcome}; Source={Source}; PackId={PackId}; Version={Version}; StatusCode={StatusCode}.", outcome, source, packId, version, (int)statusCode);
}
public partial class Program
{
}

View File

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

View File

@@ -1,123 +1,123 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Client;
using StellaOps.Policy.Gateway.Options;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyEngineTokenProvider
{
private readonly IStellaOpsTokenClient tokenClient;
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
private readonly PolicyGatewayDpopProofGenerator dpopGenerator;
private readonly TimeProvider timeProvider;
private readonly ILogger<PolicyEngineTokenProvider> logger;
private readonly SemaphoreSlim mutex = new(1, 1);
private CachedToken? cachedToken;
public PolicyEngineTokenProvider(
IStellaOpsTokenClient tokenClient,
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
PolicyGatewayDpopProofGenerator dpopGenerator,
TimeProvider timeProvider,
ILogger<PolicyEngineTokenProvider> logger)
{
this.tokenClient = tokenClient ?? throw new ArgumentNullException(nameof(tokenClient));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.dpopGenerator = dpopGenerator ?? throw new ArgumentNullException(nameof(dpopGenerator));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool IsEnabled => optionsMonitor.CurrentValue.PolicyEngine.ClientCredentials.Enabled;
public async ValueTask<PolicyGatewayAuthorization?> GetAuthorizationAsync(HttpMethod method, Uri targetUri, CancellationToken cancellationToken)
{
if (!IsEnabled)
{
return null;
}
var tokenResult = await GetTokenAsync(cancellationToken).ConfigureAwait(false);
if (tokenResult is null)
{
return null;
}
var token = tokenResult.Value;
string? proof = null;
if (dpopGenerator.Enabled)
{
proof = dpopGenerator.CreateProof(method, targetUri, token.AccessToken);
}
var scheme = string.Equals(token.TokenType, "dpop", StringComparison.OrdinalIgnoreCase)
? "DPoP"
: token.TokenType;
var authorization = $"{scheme} {token.AccessToken}";
return new PolicyGatewayAuthorization(authorization, proof, "service");
}
private async ValueTask<CachedToken?> GetTokenAsync(CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue.PolicyEngine;
if (!options.ClientCredentials.Enabled)
{
return null;
}
var now = timeProvider.GetUtcNow();
if (cachedToken is { } existing && existing.ExpiresAt > now + TimeSpan.FromSeconds(30))
{
return existing;
}
await mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (cachedToken is { } cached && cached.ExpiresAt > now + TimeSpan.FromSeconds(30))
{
return cached;
}
var scopeString = BuildScopeClaim(options);
var result = await tokenClient.RequestClientCredentialsTokenAsync(scopeString, null, cancellationToken).ConfigureAwait(false);
var expiresAt = result.ExpiresAtUtc;
cachedToken = new CachedToken(result.AccessToken, string.IsNullOrWhiteSpace(result.TokenType) ? "Bearer" : result.TokenType, expiresAt);
logger.LogInformation("Issued Policy Engine client credentials token; expires at {ExpiresAt:o}.", expiresAt);
return cachedToken;
}
finally
{
mutex.Release();
}
}
private string BuildScopeClaim(PolicyGatewayPolicyEngineOptions options)
{
var scopeSet = new SortedSet<string>(StringComparer.Ordinal)
{
$"aud:{options.Audience.Trim().ToLowerInvariant()}"
};
foreach (var scope in options.ClientCredentials.Scopes)
{
if (string.IsNullOrWhiteSpace(scope))
{
continue;
}
scopeSet.Add(scope.Trim());
}
return string.Join(' ', scopeSet);
}
private readonly record struct CachedToken(string AccessToken, string TokenType, DateTimeOffset ExpiresAt);
}
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Client;
using StellaOps.Policy.Gateway.Options;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyEngineTokenProvider
{
private readonly IStellaOpsTokenClient tokenClient;
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
private readonly PolicyGatewayDpopProofGenerator dpopGenerator;
private readonly TimeProvider timeProvider;
private readonly ILogger<PolicyEngineTokenProvider> logger;
private readonly SemaphoreSlim mutex = new(1, 1);
private CachedToken? cachedToken;
public PolicyEngineTokenProvider(
IStellaOpsTokenClient tokenClient,
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
PolicyGatewayDpopProofGenerator dpopGenerator,
TimeProvider timeProvider,
ILogger<PolicyEngineTokenProvider> logger)
{
this.tokenClient = tokenClient ?? throw new ArgumentNullException(nameof(tokenClient));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.dpopGenerator = dpopGenerator ?? throw new ArgumentNullException(nameof(dpopGenerator));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool IsEnabled => optionsMonitor.CurrentValue.PolicyEngine.ClientCredentials.Enabled;
public async ValueTask<PolicyGatewayAuthorization?> GetAuthorizationAsync(HttpMethod method, Uri targetUri, CancellationToken cancellationToken)
{
if (!IsEnabled)
{
return null;
}
var tokenResult = await GetTokenAsync(cancellationToken).ConfigureAwait(false);
if (tokenResult is null)
{
return null;
}
var token = tokenResult.Value;
string? proof = null;
if (dpopGenerator.Enabled)
{
proof = dpopGenerator.CreateProof(method, targetUri, token.AccessToken);
}
var scheme = string.Equals(token.TokenType, "dpop", StringComparison.OrdinalIgnoreCase)
? "DPoP"
: token.TokenType;
var authorization = $"{scheme} {token.AccessToken}";
return new PolicyGatewayAuthorization(authorization, proof, "service");
}
private async ValueTask<CachedToken?> GetTokenAsync(CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue.PolicyEngine;
if (!options.ClientCredentials.Enabled)
{
return null;
}
var now = timeProvider.GetUtcNow();
if (cachedToken is { } existing && existing.ExpiresAt > now + TimeSpan.FromSeconds(30))
{
return existing;
}
await mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (cachedToken is { } cached && cached.ExpiresAt > now + TimeSpan.FromSeconds(30))
{
return cached;
}
var scopeString = BuildScopeClaim(options);
var result = await tokenClient.RequestClientCredentialsTokenAsync(scopeString, null, cancellationToken).ConfigureAwait(false);
var expiresAt = result.ExpiresAtUtc;
cachedToken = new CachedToken(result.AccessToken, string.IsNullOrWhiteSpace(result.TokenType) ? "Bearer" : result.TokenType, expiresAt);
logger.LogInformation("Issued Policy Engine client credentials token; expires at {ExpiresAt:o}.", expiresAt);
return cachedToken;
}
finally
{
mutex.Release();
}
}
private string BuildScopeClaim(PolicyGatewayPolicyEngineOptions options)
{
var scopeSet = new SortedSet<string>(StringComparer.Ordinal)
{
$"aud:{options.Audience.Trim().ToLowerInvariant()}"
};
foreach (var scope in options.ClientCredentials.Scopes)
{
if (string.IsNullOrWhiteSpace(scope))
{
continue;
}
scopeSet.Add(scope.Trim());
}
return string.Join(' ', scopeSet);
}
private readonly record struct CachedToken(string AccessToken, string TokenType, DateTimeOffset ExpiresAt);
}

View File

@@ -1,24 +1,24 @@
using System;
using System.Net.Http;
namespace StellaOps.Policy.Gateway.Services;
internal readonly record struct PolicyGatewayAuthorization(string AuthorizationHeader, string? DpopProof, string Source)
{
public void Apply(HttpRequestMessage request)
{
ArgumentNullException.ThrowIfNull(request);
if (!string.IsNullOrWhiteSpace(AuthorizationHeader))
{
request.Headers.Remove("Authorization");
request.Headers.TryAddWithoutValidation("Authorization", AuthorizationHeader);
}
if (!string.IsNullOrWhiteSpace(DpopProof))
{
request.Headers.Remove("DPoP");
request.Headers.TryAddWithoutValidation("DPoP", DpopProof);
}
}
}
using System;
using System.Net.Http;
namespace StellaOps.Policy.Gateway.Services;
internal readonly record struct PolicyGatewayAuthorization(string AuthorizationHeader, string? DpopProof, string Source)
{
public void Apply(HttpRequestMessage request)
{
ArgumentNullException.ThrowIfNull(request);
if (!string.IsNullOrWhiteSpace(AuthorizationHeader))
{
request.Headers.Remove("Authorization");
request.Headers.TryAddWithoutValidation("Authorization", AuthorizationHeader);
}
if (!string.IsNullOrWhiteSpace(DpopProof))
{
request.Headers.Remove("DPoP");
request.Headers.TryAddWithoutValidation("DPoP", DpopProof);
}
}
}

View File

@@ -1,42 +1,42 @@
using System;
using System.Net.Http;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Gateway.Options;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyGatewayDpopHandler : DelegatingHandler
{
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
private readonly PolicyGatewayDpopProofGenerator proofGenerator;
public PolicyGatewayDpopHandler(
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
PolicyGatewayDpopProofGenerator proofGenerator)
{
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.proofGenerator = proofGenerator ?? throw new ArgumentNullException(nameof(proofGenerator));
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
if (options.Enabled &&
proofGenerator.Enabled &&
request.Method == HttpMethod.Post &&
request.RequestUri is { } uri &&
uri.AbsolutePath.Contains("/token", StringComparison.OrdinalIgnoreCase))
{
var proof = proofGenerator.CreateProof(request.Method, uri, accessToken: null);
request.Headers.Remove("DPoP");
request.Headers.TryAddWithoutValidation("DPoP", proof);
}
return base.SendAsync(request, cancellationToken);
}
}
using System;
using System.Net.Http;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Gateway.Options;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyGatewayDpopHandler : DelegatingHandler
{
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
private readonly PolicyGatewayDpopProofGenerator proofGenerator;
public PolicyGatewayDpopHandler(
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
PolicyGatewayDpopProofGenerator proofGenerator)
{
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.proofGenerator = proofGenerator ?? throw new ArgumentNullException(nameof(proofGenerator));
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
if (options.Enabled &&
proofGenerator.Enabled &&
request.Method == HttpMethod.Post &&
request.RequestUri is { } uri &&
uri.AbsolutePath.Contains("/token", StringComparison.OrdinalIgnoreCase))
{
var proof = proofGenerator.CreateProof(request.Method, uri, accessToken: null);
request.Headers.Remove("DPoP");
request.Headers.TryAddWithoutValidation("DPoP", proof);
}
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -1,235 +1,235 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.IO;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using StellaOps.Policy.Gateway.Options;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyGatewayDpopProofGenerator : IDisposable
{
private readonly IHostEnvironment hostEnvironment;
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<PolicyGatewayDpopProofGenerator> logger;
private DpopKeyMaterial? keyMaterial;
private readonly object sync = new();
public PolicyGatewayDpopProofGenerator(
IHostEnvironment hostEnvironment,
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<PolicyGatewayDpopProofGenerator> logger)
{
this.hostEnvironment = hostEnvironment ?? throw new ArgumentNullException(nameof(hostEnvironment));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool Enabled
{
get
{
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
return options.Enabled;
}
}
public string CreateProof(HttpMethod method, Uri targetUri, string? accessToken)
{
ArgumentNullException.ThrowIfNull(method);
ArgumentNullException.ThrowIfNull(targetUri);
if (!Enabled)
{
throw new InvalidOperationException("DPoP proof requested while DPoP is disabled.");
}
var material = GetOrLoadKeyMaterial();
var header = CreateHeader(material);
var payload = CreatePayload(method, targetUri, accessToken);
var jwt = new JwtSecurityToken(header, payload);
var handler = new JwtSecurityTokenHandler();
return handler.WriteToken(jwt);
}
private JwtHeader CreateHeader(DpopKeyMaterial material)
{
var header = new JwtHeader(new SigningCredentials(material.SecurityKey, material.SigningAlgorithm));
header["typ"] = "dpop+jwt";
header["jwk"] = new Dictionary<string, object>
{
["kty"] = material.Jwk.Kty,
["crv"] = material.Jwk.Crv,
["x"] = material.Jwk.X,
["y"] = material.Jwk.Y,
["kid"] = material.Jwk.Kid
};
return header;
}
private JwtPayload CreatePayload(HttpMethod method, Uri targetUri, string? accessToken)
{
var now = timeProvider.GetUtcNow();
var epochSeconds = (long)Math.Floor((now - DateTimeOffset.UnixEpoch).TotalSeconds);
var payload = new JwtPayload
{
["htm"] = method.Method.ToUpperInvariant(),
["htu"] = NormalizeTarget(targetUri),
["iat"] = epochSeconds,
["jti"] = Guid.NewGuid().ToString("N")
};
if (!string.IsNullOrWhiteSpace(accessToken))
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(accessToken));
payload["ath"] = Base64UrlEncoder.Encode(hash);
}
return payload;
}
private static string NormalizeTarget(Uri uri)
{
if (!uri.IsAbsoluteUri)
{
throw new InvalidOperationException("DPoP proofs require absolute target URIs.");
}
return uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped);
}
private DpopKeyMaterial GetOrLoadKeyMaterial()
{
if (keyMaterial is not null)
{
return keyMaterial;
}
lock (sync)
{
if (keyMaterial is not null)
{
return keyMaterial;
}
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
if (!options.Enabled)
{
throw new InvalidOperationException("DPoP is not enabled in the current configuration.");
}
var resolvedPath = ResolveKeyPath(options.KeyPath);
if (!File.Exists(resolvedPath))
{
throw new FileNotFoundException($"DPoP key file not found at '{resolvedPath}'.", resolvedPath);
}
var pem = File.ReadAllText(resolvedPath);
ECDsa ecdsa;
try
{
ecdsa = ECDsa.Create();
if (!string.IsNullOrWhiteSpace(options.KeyPassphrase))
{
ecdsa.ImportFromEncryptedPem(pem, options.KeyPassphrase);
}
else
{
ecdsa.ImportFromPem(pem);
}
}
catch (Exception ex)
{
throw new InvalidOperationException("Failed to load DPoP private key.", ex);
}
var securityKey = new ECDsaSecurityKey(ecdsa)
{
KeyId = ComputeKeyId(ecdsa)
};
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
jwk.Kid ??= securityKey.KeyId;
keyMaterial = new DpopKeyMaterial(ecdsa, securityKey, jwk, MapAlgorithm(options.Algorithm));
logger.LogInformation("Loaded DPoP key from {Path} (alg: {Algorithm}).", resolvedPath, options.Algorithm);
return keyMaterial;
}
}
private string ResolveKeyPath(string path)
{
if (Path.IsPathRooted(path))
{
return path;
}
return Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, path));
}
private static string ComputeKeyId(ECDsa ecdsa)
{
var parameters = ecdsa.ExportParameters(includePrivateParameters: false);
var buffer = new byte[(parameters.Q.X?.Length ?? 0) + (parameters.Q.Y?.Length ?? 0)];
var offset = 0;
if (parameters.Q.X is not null)
{
Buffer.BlockCopy(parameters.Q.X, 0, buffer, offset, parameters.Q.X.Length);
offset += parameters.Q.X.Length;
}
if (parameters.Q.Y is not null)
{
Buffer.BlockCopy(parameters.Q.Y, 0, buffer, offset, parameters.Q.Y.Length);
}
var hash = SHA256.HashData(buffer);
return Base64UrlEncoder.Encode(hash);
}
private static string MapAlgorithm(string algorithm)
=> algorithm switch
{
"ES256" => SecurityAlgorithms.EcdsaSha256,
"ES384" => SecurityAlgorithms.EcdsaSha384,
_ => throw new InvalidOperationException($"Unsupported DPoP signing algorithm '{algorithm}'.")
};
public void Dispose()
{
if (keyMaterial is { } material)
{
material.Dispose();
}
}
private sealed class DpopKeyMaterial : IDisposable
{
public DpopKeyMaterial(ECDsa ecdsa, ECDsaSecurityKey securityKey, JsonWebKey jwk, string signingAlgorithm)
{
Ecdsa = ecdsa;
SecurityKey = securityKey;
Jwk = jwk;
SigningAlgorithm = signingAlgorithm;
}
public ECDsa Ecdsa { get; }
public ECDsaSecurityKey SecurityKey { get; }
public JsonWebKey Jwk { get; }
public string SigningAlgorithm { get; }
public void Dispose()
{
Ecdsa.Dispose();
}
}
}
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.IO;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using StellaOps.Policy.Gateway.Options;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyGatewayDpopProofGenerator : IDisposable
{
private readonly IHostEnvironment hostEnvironment;
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<PolicyGatewayDpopProofGenerator> logger;
private DpopKeyMaterial? keyMaterial;
private readonly object sync = new();
public PolicyGatewayDpopProofGenerator(
IHostEnvironment hostEnvironment,
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<PolicyGatewayDpopProofGenerator> logger)
{
this.hostEnvironment = hostEnvironment ?? throw new ArgumentNullException(nameof(hostEnvironment));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool Enabled
{
get
{
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
return options.Enabled;
}
}
public string CreateProof(HttpMethod method, Uri targetUri, string? accessToken)
{
ArgumentNullException.ThrowIfNull(method);
ArgumentNullException.ThrowIfNull(targetUri);
if (!Enabled)
{
throw new InvalidOperationException("DPoP proof requested while DPoP is disabled.");
}
var material = GetOrLoadKeyMaterial();
var header = CreateHeader(material);
var payload = CreatePayload(method, targetUri, accessToken);
var jwt = new JwtSecurityToken(header, payload);
var handler = new JwtSecurityTokenHandler();
return handler.WriteToken(jwt);
}
private JwtHeader CreateHeader(DpopKeyMaterial material)
{
var header = new JwtHeader(new SigningCredentials(material.SecurityKey, material.SigningAlgorithm));
header["typ"] = "dpop+jwt";
header["jwk"] = new Dictionary<string, object>
{
["kty"] = material.Jwk.Kty,
["crv"] = material.Jwk.Crv,
["x"] = material.Jwk.X,
["y"] = material.Jwk.Y,
["kid"] = material.Jwk.Kid
};
return header;
}
private JwtPayload CreatePayload(HttpMethod method, Uri targetUri, string? accessToken)
{
var now = timeProvider.GetUtcNow();
var epochSeconds = (long)Math.Floor((now - DateTimeOffset.UnixEpoch).TotalSeconds);
var payload = new JwtPayload
{
["htm"] = method.Method.ToUpperInvariant(),
["htu"] = NormalizeTarget(targetUri),
["iat"] = epochSeconds,
["jti"] = Guid.NewGuid().ToString("N")
};
if (!string.IsNullOrWhiteSpace(accessToken))
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(accessToken));
payload["ath"] = Base64UrlEncoder.Encode(hash);
}
return payload;
}
private static string NormalizeTarget(Uri uri)
{
if (!uri.IsAbsoluteUri)
{
throw new InvalidOperationException("DPoP proofs require absolute target URIs.");
}
return uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped);
}
private DpopKeyMaterial GetOrLoadKeyMaterial()
{
if (keyMaterial is not null)
{
return keyMaterial;
}
lock (sync)
{
if (keyMaterial is not null)
{
return keyMaterial;
}
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
if (!options.Enabled)
{
throw new InvalidOperationException("DPoP is not enabled in the current configuration.");
}
var resolvedPath = ResolveKeyPath(options.KeyPath);
if (!File.Exists(resolvedPath))
{
throw new FileNotFoundException($"DPoP key file not found at '{resolvedPath}'.", resolvedPath);
}
var pem = File.ReadAllText(resolvedPath);
ECDsa ecdsa;
try
{
ecdsa = ECDsa.Create();
if (!string.IsNullOrWhiteSpace(options.KeyPassphrase))
{
ecdsa.ImportFromEncryptedPem(pem, options.KeyPassphrase);
}
else
{
ecdsa.ImportFromPem(pem);
}
}
catch (Exception ex)
{
throw new InvalidOperationException("Failed to load DPoP private key.", ex);
}
var securityKey = new ECDsaSecurityKey(ecdsa)
{
KeyId = ComputeKeyId(ecdsa)
};
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
jwk.Kid ??= securityKey.KeyId;
keyMaterial = new DpopKeyMaterial(ecdsa, securityKey, jwk, MapAlgorithm(options.Algorithm));
logger.LogInformation("Loaded DPoP key from {Path} (alg: {Algorithm}).", resolvedPath, options.Algorithm);
return keyMaterial;
}
}
private string ResolveKeyPath(string path)
{
if (Path.IsPathRooted(path))
{
return path;
}
return Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, path));
}
private static string ComputeKeyId(ECDsa ecdsa)
{
var parameters = ecdsa.ExportParameters(includePrivateParameters: false);
var buffer = new byte[(parameters.Q.X?.Length ?? 0) + (parameters.Q.Y?.Length ?? 0)];
var offset = 0;
if (parameters.Q.X is not null)
{
Buffer.BlockCopy(parameters.Q.X, 0, buffer, offset, parameters.Q.X.Length);
offset += parameters.Q.X.Length;
}
if (parameters.Q.Y is not null)
{
Buffer.BlockCopy(parameters.Q.Y, 0, buffer, offset, parameters.Q.Y.Length);
}
var hash = SHA256.HashData(buffer);
return Base64UrlEncoder.Encode(hash);
}
private static string MapAlgorithm(string algorithm)
=> algorithm switch
{
"ES256" => SecurityAlgorithms.EcdsaSha256,
"ES384" => SecurityAlgorithms.EcdsaSha384,
_ => throw new InvalidOperationException($"Unsupported DPoP signing algorithm '{algorithm}'.")
};
public void Dispose()
{
if (keyMaterial is { } material)
{
material.Dispose();
}
}
private sealed class DpopKeyMaterial : IDisposable
{
public DpopKeyMaterial(ECDsa ecdsa, ECDsaSecurityKey securityKey, JsonWebKey jwk, string signingAlgorithm)
{
Ecdsa = ecdsa;
SecurityKey = securityKey;
Jwk = jwk;
SigningAlgorithm = signingAlgorithm;
}
public ECDsa Ecdsa { get; }
public ECDsaSecurityKey SecurityKey { get; }
public JsonWebKey Jwk { get; }
public string SigningAlgorithm { get; }
public void Dispose()
{
Ecdsa.Dispose();
}
}
}

View File

@@ -1,51 +1,51 @@
using System;
using System.Diagnostics.Metrics;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyGatewayMetrics : IDisposable
{
private static readonly KeyValuePair<string, object?>[] EmptyTags = Array.Empty<KeyValuePair<string, object?>>();
private readonly Meter meter;
public PolicyGatewayMetrics()
{
meter = new Meter("StellaOps.Policy.Gateway", "1.0.0");
ActivationRequests = meter.CreateCounter<long>(
"policy_gateway_activation_requests_total",
unit: "count",
description: "Total policy activation proxy requests processed by the gateway.");
ActivationLatencyMs = meter.CreateHistogram<double>(
"policy_gateway_activation_latency_ms",
unit: "ms",
description: "Latency distribution for policy activation proxy calls.");
}
public Counter<long> ActivationRequests { get; }
public Histogram<double> ActivationLatencyMs { get; }
public void RecordActivation(string outcome, string source, double elapsedMilliseconds)
{
var tags = BuildTags(outcome, source);
ActivationRequests.Add(1, tags);
ActivationLatencyMs.Record(elapsedMilliseconds, tags);
}
private static KeyValuePair<string, object?>[] BuildTags(string outcome, string source)
{
outcome = string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome;
source = string.IsNullOrWhiteSpace(source) ? "unspecified" : source;
return new[]
{
new KeyValuePair<string, object?>("outcome", outcome),
new KeyValuePair<string, object?>("source", source)
};
}
public void Dispose()
{
meter.Dispose();
}
}
using System;
using System.Diagnostics.Metrics;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyGatewayMetrics : IDisposable
{
private static readonly KeyValuePair<string, object?>[] EmptyTags = Array.Empty<KeyValuePair<string, object?>>();
private readonly Meter meter;
public PolicyGatewayMetrics()
{
meter = new Meter("StellaOps.Policy.Gateway", "1.0.0");
ActivationRequests = meter.CreateCounter<long>(
"policy_gateway_activation_requests_total",
unit: "count",
description: "Total policy activation proxy requests processed by the gateway.");
ActivationLatencyMs = meter.CreateHistogram<double>(
"policy_gateway_activation_latency_ms",
unit: "ms",
description: "Latency distribution for policy activation proxy calls.");
}
public Counter<long> ActivationRequests { get; }
public Histogram<double> ActivationLatencyMs { get; }
public void RecordActivation(string outcome, string source, double elapsedMilliseconds)
{
var tags = BuildTags(outcome, source);
ActivationRequests.Add(1, tags);
ActivationLatencyMs.Record(elapsedMilliseconds, tags);
}
private static KeyValuePair<string, object?>[] BuildTags(string outcome, string source)
{
outcome = string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome;
source = string.IsNullOrWhiteSpace(source) ? "unspecified" : source;
return new[]
{
new KeyValuePair<string, object?>("outcome", outcome),
new KeyValuePair<string, object?>("source", source)
};
}
public void Dispose()
{
meter.Dispose();
}
}

View File

@@ -3,15 +3,15 @@ using System.Text.Json;
namespace StellaOps.Policy.Storage.Postgres.Migration;
/// <summary>
/// Converts MongoDB policy documents (as JSON) to migration data transfer objects.
/// Converts legacy policy documents (as JSON) to migration data transfer objects.
/// Task reference: PG-T4.9
/// </summary>
/// <remarks>
/// This converter handles the transformation of MongoDB document JSON exports
/// This converter handles the transformation of legacy document JSON exports
/// into DTOs suitable for PostgreSQL import. The caller is responsible for
/// exporting MongoDB documents as JSON before passing them to this converter.
/// exporting legacy documents as JSON before passing them to this converter.
/// </remarks>
public static class MongoDocumentConverter
public static class LegacyDocumentConverter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -19,9 +19,9 @@ public static class MongoDocumentConverter
};
/// <summary>
/// Converts a MongoDB PolicyDocument (as JSON) to PackMigrationData.
/// Converts a legacy PolicyDocument (as JSON) to PackMigrationData.
/// </summary>
/// <param name="json">The JSON representation of the MongoDB document.</param>
/// <param name="json">The JSON representation of the legacy document.</param>
/// <returns>Migration data transfer object.</returns>
public static PackMigrationData ConvertPackFromJson(string json)
{
@@ -48,9 +48,9 @@ public static class MongoDocumentConverter
}
/// <summary>
/// Converts a MongoDB PolicyRevisionDocument (as JSON) to PackVersionMigrationData.
/// Converts a legacy PolicyRevisionDocument (as JSON) to PackVersionMigrationData.
/// </summary>
/// <param name="json">The JSON representation of the MongoDB document.</param>
/// <param name="json">The JSON representation of the legacy document.</param>
/// <returns>Migration data transfer object.</returns>
public static PackVersionMigrationData ConvertVersionFromJson(string json)
{
@@ -253,7 +253,7 @@ public static class MongoDocumentConverter
return result;
}
// Handle MongoDB extended JSON date format {"$date": ...}
// Handle legacy extended JSON date format {"$date": ...}
if (prop.ValueKind == JsonValueKind.Object && prop.TryGetProperty("$date", out var dateProp))
{
if (dateProp.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(dateProp.GetString(), out var dateResult))
@@ -287,7 +287,7 @@ public static class MongoDocumentConverter
return result;
}
// Handle MongoDB extended JSON date format
// Handle legacy extended JSON date format
if (prop.ValueKind == JsonValueKind.Object && prop.TryGetProperty("$date", out var dateProp))
{
if (dateProp.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(dateProp.GetString(), out var dateResult))

View File

@@ -1,12 +1,12 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public interface IPolicyAuditRepository
{
Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default);
Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default);
}
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public interface IPolicyAuditRepository
{
Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default);
Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default);
}

View File

@@ -1,52 +1,52 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public sealed class InMemoryPolicyAuditRepository : IPolicyAuditRepository
{
private readonly List<PolicyAuditEntry> _entries = new();
private readonly SemaphoreSlim _mutex = new(1, 1);
public async Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default)
{
if (entry is null)
{
throw new ArgumentNullException(nameof(entry));
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_entries.Add(entry);
_entries.Sort(static (left, right) => left.CreatedAt.CompareTo(right.CreatedAt));
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default)
{
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
IEnumerable<PolicyAuditEntry> query = _entries;
if (limit > 0)
{
query = query.TakeLast(limit);
}
return query.ToImmutableArray();
}
finally
{
_mutex.Release();
}
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public sealed class InMemoryPolicyAuditRepository : IPolicyAuditRepository
{
private readonly List<PolicyAuditEntry> _entries = new();
private readonly SemaphoreSlim _mutex = new(1, 1);
public async Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default)
{
if (entry is null)
{
throw new ArgumentNullException(nameof(entry));
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_entries.Add(entry);
_entries.Sort(static (left, right) => left.CreatedAt.CompareTo(right.CreatedAt));
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default)
{
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
IEnumerable<PolicyAuditEntry> query = _entries;
if (limit > 0)
{
query = query.TakeLast(limit);
}
return query.ToImmutableArray();
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -1,12 +1,12 @@
using System;
namespace StellaOps.Policy;
public sealed record PolicyAuditEntry(
Guid Id,
DateTimeOffset CreatedAt,
string Action,
string RevisionId,
string Digest,
string? Actor,
string Message);
using System;
namespace StellaOps.Policy;
public sealed record PolicyAuditEntry(
Guid Id,
DateTimeOffset CreatedAt,
string Action,
string RevisionId,
string Digest,
string? Actor,
string Message);

File diff suppressed because it is too large Load Diff

View File

@@ -1,77 +1,77 @@
using System;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Policy;
public sealed record PolicyDiagnosticsReport(
string Version,
int RuleCount,
int ErrorCount,
int WarningCount,
DateTimeOffset GeneratedAt,
ImmutableArray<PolicyIssue> Issues,
ImmutableArray<string> Recommendations);
public static class PolicyDiagnostics
{
public static PolicyDiagnosticsReport Create(PolicyBindingResult bindingResult, TimeProvider? timeProvider = null)
{
if (bindingResult is null)
{
throw new ArgumentNullException(nameof(bindingResult));
}
var time = (timeProvider ?? TimeProvider.System).GetUtcNow();
var errorCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Error);
var warningCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Warning);
var recommendations = BuildRecommendations(bindingResult.Document, errorCount, warningCount);
return new PolicyDiagnosticsReport(
bindingResult.Document.Version,
bindingResult.Document.Rules.Length,
errorCount,
warningCount,
time,
bindingResult.Issues,
recommendations);
}
private static ImmutableArray<string> BuildRecommendations(PolicyDocument document, int errorCount, int warningCount)
{
var messages = ImmutableArray.CreateBuilder<string>();
if (errorCount > 0)
{
messages.Add("Resolve policy errors before promoting the revision; fallback rules may be applied while errors remain.");
}
if (warningCount > 0)
{
messages.Add("Review policy warnings and ensure intentional overrides are documented.");
}
if (document.Rules.Length == 0)
{
messages.Add("Add at least one policy rule to enforce gating logic.");
}
var quietRules = document.Rules
.Where(static rule => rule.Action.Quiet)
.Select(static rule => rule.Name)
.ToArray();
if (quietRules.Length > 0)
{
messages.Add($"Quiet rules detected ({string.Join(", ", quietRules)}); verify scoring behaviour aligns with expectations.");
}
if (messages.Count == 0)
{
messages.Add("Policy validated successfully; no additional action required.");
}
return messages.ToImmutable();
}
}
using System;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Policy;
public sealed record PolicyDiagnosticsReport(
string Version,
int RuleCount,
int ErrorCount,
int WarningCount,
DateTimeOffset GeneratedAt,
ImmutableArray<PolicyIssue> Issues,
ImmutableArray<string> Recommendations);
public static class PolicyDiagnostics
{
public static PolicyDiagnosticsReport Create(PolicyBindingResult bindingResult, TimeProvider? timeProvider = null)
{
if (bindingResult is null)
{
throw new ArgumentNullException(nameof(bindingResult));
}
var time = (timeProvider ?? TimeProvider.System).GetUtcNow();
var errorCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Error);
var warningCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Warning);
var recommendations = BuildRecommendations(bindingResult.Document, errorCount, warningCount);
return new PolicyDiagnosticsReport(
bindingResult.Document.Version,
bindingResult.Document.Rules.Length,
errorCount,
warningCount,
time,
bindingResult.Issues,
recommendations);
}
private static ImmutableArray<string> BuildRecommendations(PolicyDocument document, int errorCount, int warningCount)
{
var messages = ImmutableArray.CreateBuilder<string>();
if (errorCount > 0)
{
messages.Add("Resolve policy errors before promoting the revision; fallback rules may be applied while errors remain.");
}
if (warningCount > 0)
{
messages.Add("Review policy warnings and ensure intentional overrides are documented.");
}
if (document.Rules.Length == 0)
{
messages.Add("Add at least one policy rule to enforce gating logic.");
}
var quietRules = document.Rules
.Where(static rule => rule.Action.Quiet)
.Select(static rule => rule.Name)
.ToArray();
if (quietRules.Length > 0)
{
messages.Add($"Quiet rules detected ({string.Join(", ", quietRules)}); verify scoring behaviour aligns with expectations.");
}
if (messages.Count == 0)
{
messages.Add("Policy validated successfully; no additional action required.");
}
return messages.ToImmutable();
}
}

View File

@@ -1,51 +1,51 @@
using System;
using System.Buffers;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Policy;
public static class PolicyDigest
{
public static string Compute(PolicyDocument document)
{
if (document is null)
{
throw new ArgumentNullException(nameof(document));
}
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
SkipValidation = true,
}))
{
WriteDocument(writer, document);
}
var hash = SHA256.HashData(buffer.WrittenSpan);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static void WriteDocument(Utf8JsonWriter writer, PolicyDocument document)
{
writer.WriteStartObject();
writer.WriteString("version", document.Version);
if (!document.Metadata.IsEmpty)
{
writer.WritePropertyName("metadata");
writer.WriteStartObject();
foreach (var pair in document.Metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
{
writer.WriteString(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
writer.WritePropertyName("rules");
using System;
using System.Buffers;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Policy;
public static class PolicyDigest
{
public static string Compute(PolicyDocument document)
{
if (document is null)
{
throw new ArgumentNullException(nameof(document));
}
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
SkipValidation = true,
}))
{
WriteDocument(writer, document);
}
var hash = SHA256.HashData(buffer.WrittenSpan);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static void WriteDocument(Utf8JsonWriter writer, PolicyDocument document)
{
writer.WriteStartObject();
writer.WriteString("version", document.Version);
if (!document.Metadata.IsEmpty)
{
writer.WritePropertyName("metadata");
writer.WriteStartObject();
foreach (var pair in document.Metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
{
writer.WriteString(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
writer.WritePropertyName("rules");
writer.WriteStartArray();
foreach (var rule in document.Rules)
{
@@ -90,143 +90,143 @@ public static class PolicyDigest
writer.WriteEndObject();
writer.Flush();
}
private static void WriteRule(Utf8JsonWriter writer, PolicyRule rule)
{
writer.WriteStartObject();
writer.WriteString("name", rule.Name);
if (!string.IsNullOrWhiteSpace(rule.Identifier))
{
writer.WriteString("id", rule.Identifier);
}
if (!string.IsNullOrWhiteSpace(rule.Description))
{
writer.WriteString("description", rule.Description);
}
WriteMetadata(writer, rule.Metadata);
WriteSeverities(writer, rule.Severities);
WriteStringArray(writer, "environments", rule.Environments);
WriteStringArray(writer, "sources", rule.Sources);
WriteStringArray(writer, "vendors", rule.Vendors);
WriteStringArray(writer, "licenses", rule.Licenses);
WriteStringArray(writer, "tags", rule.Tags);
if (!rule.Match.IsEmpty)
{
writer.WritePropertyName("match");
writer.WriteStartObject();
WriteStringArray(writer, "images", rule.Match.Images);
WriteStringArray(writer, "repositories", rule.Match.Repositories);
WriteStringArray(writer, "packages", rule.Match.Packages);
WriteStringArray(writer, "purls", rule.Match.Purls);
WriteStringArray(writer, "cves", rule.Match.Cves);
WriteStringArray(writer, "paths", rule.Match.Paths);
WriteStringArray(writer, "layerDigests", rule.Match.LayerDigests);
WriteStringArray(writer, "usedByEntrypoint", rule.Match.UsedByEntrypoint);
writer.WriteEndObject();
}
WriteAction(writer, rule.Action);
if (rule.Expires is DateTimeOffset expires)
{
writer.WriteString("expires", expires.ToUniversalTime().ToString("O"));
}
if (!string.IsNullOrWhiteSpace(rule.Justification))
{
writer.WriteString("justification", rule.Justification);
}
writer.WriteEndObject();
}
private static void WriteAction(Utf8JsonWriter writer, PolicyAction action)
{
writer.WritePropertyName("action");
writer.WriteStartObject();
writer.WriteString("type", action.Type.ToString().ToLowerInvariant());
if (action.Quiet)
{
writer.WriteBoolean("quiet", true);
}
if (action.Ignore is { } ignore)
{
if (ignore.Until is DateTimeOffset until)
{
writer.WriteString("until", until.ToUniversalTime().ToString("O"));
}
if (!string.IsNullOrWhiteSpace(ignore.Justification))
{
writer.WriteString("justification", ignore.Justification);
}
}
if (action.Escalate is { } escalate)
{
if (escalate.MinimumSeverity is { } severity)
{
writer.WriteString("severity", severity.ToString());
}
if (escalate.RequireKev)
{
writer.WriteBoolean("kev", true);
}
if (escalate.MinimumEpss is double epss)
{
writer.WriteNumber("epss", epss);
}
}
if (action.RequireVex is { } requireVex)
{
WriteStringArray(writer, "vendors", requireVex.Vendors);
WriteStringArray(writer, "justifications", requireVex.Justifications);
}
writer.WriteEndObject();
}
private static void WriteMetadata(Utf8JsonWriter writer, ImmutableDictionary<string, string> metadata)
{
if (metadata.IsEmpty)
{
return;
}
writer.WritePropertyName("metadata");
writer.WriteStartObject();
foreach (var pair in metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
{
writer.WriteString(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
private static void WriteSeverities(Utf8JsonWriter writer, ImmutableArray<PolicySeverity> severities)
{
if (severities.IsDefaultOrEmpty)
{
return;
}
writer.WritePropertyName("severity");
writer.WriteStartArray();
foreach (var severity in severities)
{
writer.WriteStringValue(severity.ToString());
}
writer.WriteEndArray();
}
private static void WriteRule(Utf8JsonWriter writer, PolicyRule rule)
{
writer.WriteStartObject();
writer.WriteString("name", rule.Name);
if (!string.IsNullOrWhiteSpace(rule.Identifier))
{
writer.WriteString("id", rule.Identifier);
}
if (!string.IsNullOrWhiteSpace(rule.Description))
{
writer.WriteString("description", rule.Description);
}
WriteMetadata(writer, rule.Metadata);
WriteSeverities(writer, rule.Severities);
WriteStringArray(writer, "environments", rule.Environments);
WriteStringArray(writer, "sources", rule.Sources);
WriteStringArray(writer, "vendors", rule.Vendors);
WriteStringArray(writer, "licenses", rule.Licenses);
WriteStringArray(writer, "tags", rule.Tags);
if (!rule.Match.IsEmpty)
{
writer.WritePropertyName("match");
writer.WriteStartObject();
WriteStringArray(writer, "images", rule.Match.Images);
WriteStringArray(writer, "repositories", rule.Match.Repositories);
WriteStringArray(writer, "packages", rule.Match.Packages);
WriteStringArray(writer, "purls", rule.Match.Purls);
WriteStringArray(writer, "cves", rule.Match.Cves);
WriteStringArray(writer, "paths", rule.Match.Paths);
WriteStringArray(writer, "layerDigests", rule.Match.LayerDigests);
WriteStringArray(writer, "usedByEntrypoint", rule.Match.UsedByEntrypoint);
writer.WriteEndObject();
}
WriteAction(writer, rule.Action);
if (rule.Expires is DateTimeOffset expires)
{
writer.WriteString("expires", expires.ToUniversalTime().ToString("O"));
}
if (!string.IsNullOrWhiteSpace(rule.Justification))
{
writer.WriteString("justification", rule.Justification);
}
writer.WriteEndObject();
}
private static void WriteAction(Utf8JsonWriter writer, PolicyAction action)
{
writer.WritePropertyName("action");
writer.WriteStartObject();
writer.WriteString("type", action.Type.ToString().ToLowerInvariant());
if (action.Quiet)
{
writer.WriteBoolean("quiet", true);
}
if (action.Ignore is { } ignore)
{
if (ignore.Until is DateTimeOffset until)
{
writer.WriteString("until", until.ToUniversalTime().ToString("O"));
}
if (!string.IsNullOrWhiteSpace(ignore.Justification))
{
writer.WriteString("justification", ignore.Justification);
}
}
if (action.Escalate is { } escalate)
{
if (escalate.MinimumSeverity is { } severity)
{
writer.WriteString("severity", severity.ToString());
}
if (escalate.RequireKev)
{
writer.WriteBoolean("kev", true);
}
if (escalate.MinimumEpss is double epss)
{
writer.WriteNumber("epss", epss);
}
}
if (action.RequireVex is { } requireVex)
{
WriteStringArray(writer, "vendors", requireVex.Vendors);
WriteStringArray(writer, "justifications", requireVex.Justifications);
}
writer.WriteEndObject();
}
private static void WriteMetadata(Utf8JsonWriter writer, ImmutableDictionary<string, string> metadata)
{
if (metadata.IsEmpty)
{
return;
}
writer.WritePropertyName("metadata");
writer.WriteStartObject();
foreach (var pair in metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
{
writer.WriteString(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
private static void WriteSeverities(Utf8JsonWriter writer, ImmutableArray<PolicySeverity> severities)
{
if (severities.IsDefaultOrEmpty)
{
return;
}
writer.WritePropertyName("severity");
writer.WriteStartArray();
foreach (var severity in severities)
{
writer.WriteStringValue(severity.ToString());
}
writer.WriteEndArray();
}
private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values)
{
if (values.IsDefaultOrEmpty)

View File

@@ -23,164 +23,164 @@ public sealed record PolicyDocument(
public static class PolicySchema
{
public const string SchemaId = "https://schemas.stella-ops.org/policy/policy-schema@1.json";
public const string CurrentVersion = "1.0";
public static PolicyDocumentFormat DetectFormat(string fileName)
{
if (fileName is null)
{
throw new ArgumentNullException(nameof(fileName));
}
var lower = fileName.Trim().ToLowerInvariant();
public const string CurrentVersion = "1.0";
public static PolicyDocumentFormat DetectFormat(string fileName)
{
if (fileName is null)
{
throw new ArgumentNullException(nameof(fileName));
}
var lower = fileName.Trim().ToLowerInvariant();
if (lower.EndsWith(".yaml", StringComparison.Ordinal)
|| lower.EndsWith(".yml", StringComparison.Ordinal)
|| lower.EndsWith(".stella", StringComparison.Ordinal))
{
return PolicyDocumentFormat.Yaml;
}
return PolicyDocumentFormat.Json;
}
}
public sealed record PolicyRule(
string Name,
string? Identifier,
string? Description,
PolicyAction Action,
ImmutableArray<PolicySeverity> Severities,
ImmutableArray<string> Environments,
ImmutableArray<string> Sources,
ImmutableArray<string> Vendors,
ImmutableArray<string> Licenses,
ImmutableArray<string> Tags,
PolicyRuleMatchCriteria Match,
DateTimeOffset? Expires,
string? Justification,
ImmutableDictionary<string, string> Metadata)
{
public static PolicyRuleMatchCriteria EmptyMatch { get; } = new(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty);
public static PolicyRule Create(
string name,
PolicyAction action,
ImmutableArray<PolicySeverity> severities,
ImmutableArray<string> environments,
ImmutableArray<string> sources,
ImmutableArray<string> vendors,
ImmutableArray<string> licenses,
ImmutableArray<string> tags,
PolicyRuleMatchCriteria match,
DateTimeOffset? expires,
string? justification,
string? identifier = null,
string? description = null,
ImmutableDictionary<string, string>? metadata = null)
{
metadata ??= ImmutableDictionary<string, string>.Empty;
return new PolicyRule(
name,
identifier,
description,
action,
severities,
environments,
sources,
vendors,
licenses,
tags,
match,
expires,
justification,
metadata);
}
public bool MatchesAnyEnvironment => Environments.IsDefaultOrEmpty;
}
public sealed record PolicyRuleMatchCriteria(
ImmutableArray<string> Images,
ImmutableArray<string> Repositories,
ImmutableArray<string> Packages,
ImmutableArray<string> Purls,
ImmutableArray<string> Cves,
ImmutableArray<string> Paths,
ImmutableArray<string> LayerDigests,
ImmutableArray<string> UsedByEntrypoint)
{
public static PolicyRuleMatchCriteria Create(
ImmutableArray<string> images,
ImmutableArray<string> repositories,
ImmutableArray<string> packages,
ImmutableArray<string> purls,
ImmutableArray<string> cves,
ImmutableArray<string> paths,
ImmutableArray<string> layerDigests,
ImmutableArray<string> usedByEntrypoint)
=> new(
images,
repositories,
packages,
purls,
cves,
paths,
layerDigests,
usedByEntrypoint);
public static PolicyRuleMatchCriteria Empty { get; } = new(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty);
public bool IsEmpty =>
Images.IsDefaultOrEmpty &&
Repositories.IsDefaultOrEmpty &&
Packages.IsDefaultOrEmpty &&
Purls.IsDefaultOrEmpty &&
Cves.IsDefaultOrEmpty &&
Paths.IsDefaultOrEmpty &&
LayerDigests.IsDefaultOrEmpty &&
UsedByEntrypoint.IsDefaultOrEmpty;
}
return PolicyDocumentFormat.Json;
}
}
public sealed record PolicyRule(
string Name,
string? Identifier,
string? Description,
PolicyAction Action,
ImmutableArray<PolicySeverity> Severities,
ImmutableArray<string> Environments,
ImmutableArray<string> Sources,
ImmutableArray<string> Vendors,
ImmutableArray<string> Licenses,
ImmutableArray<string> Tags,
PolicyRuleMatchCriteria Match,
DateTimeOffset? Expires,
string? Justification,
ImmutableDictionary<string, string> Metadata)
{
public static PolicyRuleMatchCriteria EmptyMatch { get; } = new(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty);
public static PolicyRule Create(
string name,
PolicyAction action,
ImmutableArray<PolicySeverity> severities,
ImmutableArray<string> environments,
ImmutableArray<string> sources,
ImmutableArray<string> vendors,
ImmutableArray<string> licenses,
ImmutableArray<string> tags,
PolicyRuleMatchCriteria match,
DateTimeOffset? expires,
string? justification,
string? identifier = null,
string? description = null,
ImmutableDictionary<string, string>? metadata = null)
{
metadata ??= ImmutableDictionary<string, string>.Empty;
return new PolicyRule(
name,
identifier,
description,
action,
severities,
environments,
sources,
vendors,
licenses,
tags,
match,
expires,
justification,
metadata);
}
public bool MatchesAnyEnvironment => Environments.IsDefaultOrEmpty;
}
public sealed record PolicyRuleMatchCriteria(
ImmutableArray<string> Images,
ImmutableArray<string> Repositories,
ImmutableArray<string> Packages,
ImmutableArray<string> Purls,
ImmutableArray<string> Cves,
ImmutableArray<string> Paths,
ImmutableArray<string> LayerDigests,
ImmutableArray<string> UsedByEntrypoint)
{
public static PolicyRuleMatchCriteria Create(
ImmutableArray<string> images,
ImmutableArray<string> repositories,
ImmutableArray<string> packages,
ImmutableArray<string> purls,
ImmutableArray<string> cves,
ImmutableArray<string> paths,
ImmutableArray<string> layerDigests,
ImmutableArray<string> usedByEntrypoint)
=> new(
images,
repositories,
packages,
purls,
cves,
paths,
layerDigests,
usedByEntrypoint);
public static PolicyRuleMatchCriteria Empty { get; } = new(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty);
public bool IsEmpty =>
Images.IsDefaultOrEmpty &&
Repositories.IsDefaultOrEmpty &&
Packages.IsDefaultOrEmpty &&
Purls.IsDefaultOrEmpty &&
Cves.IsDefaultOrEmpty &&
Paths.IsDefaultOrEmpty &&
LayerDigests.IsDefaultOrEmpty &&
UsedByEntrypoint.IsDefaultOrEmpty;
}
public sealed record PolicyAction(
PolicyActionType Type,
PolicyIgnoreOptions? Ignore,
PolicyEscalateOptions? Escalate,
PolicyRequireVexOptions? RequireVex,
bool Quiet);
public enum PolicyActionType
{
Block,
Ignore,
Warn,
Defer,
Escalate,
RequireVex,
}
public sealed record PolicyIgnoreOptions(DateTimeOffset? Until, string? Justification);
public sealed record PolicyEscalateOptions(
PolicySeverity? MinimumSeverity,
bool RequireKev,
double? MinimumEpss);
public enum PolicyActionType
{
Block,
Ignore,
Warn,
Defer,
Escalate,
RequireVex,
}
public sealed record PolicyIgnoreOptions(DateTimeOffset? Until, string? Justification);
public sealed record PolicyEscalateOptions(
PolicySeverity? MinimumSeverity,
bool RequireKev,
double? MinimumEpss);
public sealed record PolicyRequireVexOptions(
ImmutableArray<string> Vendors,
ImmutableArray<string> Justifications);

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +1,51 @@
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyFinding(
string FindingId,
PolicySeverity Severity,
string? Environment,
string? Source,
string? Vendor,
string? License,
string? Image,
string? Repository,
string? Package,
string? Purl,
string? Cve,
string? Path,
string? LayerDigest,
ImmutableArray<string> Tags)
{
public static PolicyFinding Create(
string findingId,
PolicySeverity severity,
string? environment = null,
string? source = null,
string? vendor = null,
string? license = null,
string? image = null,
string? repository = null,
string? package = null,
string? purl = null,
string? cve = null,
string? path = null,
string? layerDigest = null,
ImmutableArray<string>? tags = null)
=> new(
findingId,
severity,
environment,
source,
vendor,
license,
image,
repository,
package,
purl,
cve,
path,
layerDigest,
tags ?? ImmutableArray<string>.Empty);
}
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyFinding(
string FindingId,
PolicySeverity Severity,
string? Environment,
string? Source,
string? Vendor,
string? License,
string? Image,
string? Repository,
string? Package,
string? Purl,
string? Cve,
string? Path,
string? LayerDigest,
ImmutableArray<string> Tags)
{
public static PolicyFinding Create(
string findingId,
PolicySeverity severity,
string? environment = null,
string? source = null,
string? vendor = null,
string? license = null,
string? image = null,
string? repository = null,
string? package = null,
string? purl = null,
string? cve = null,
string? path = null,
string? layerDigest = null,
ImmutableArray<string>? tags = null)
=> new(
findingId,
severity,
environment,
source,
vendor,
license,
image,
repository,
package,
purl,
cve,
path,
layerDigest,
tags ?? ImmutableArray<string>.Empty);
}

View File

@@ -1,28 +1,28 @@
using System;
namespace StellaOps.Policy;
/// <summary>
/// Represents a validation or normalization issue discovered while processing a policy document.
/// </summary>
public sealed record PolicyIssue(string Code, string Message, PolicyIssueSeverity Severity, string Path)
{
public static PolicyIssue Error(string code, string message, string path)
=> new(code, message, PolicyIssueSeverity.Error, path);
public static PolicyIssue Warning(string code, string message, string path)
=> new(code, message, PolicyIssueSeverity.Warning, path);
public static PolicyIssue Info(string code, string message, string path)
=> new(code, message, PolicyIssueSeverity.Info, path);
public PolicyIssue EnsurePath(string fallbackPath)
=> string.IsNullOrWhiteSpace(Path) ? this with { Path = fallbackPath } : this;
}
public enum PolicyIssueSeverity
{
Error,
Warning,
Info,
}
using System;
namespace StellaOps.Policy;
/// <summary>
/// Represents a validation or normalization issue discovered while processing a policy document.
/// </summary>
public sealed record PolicyIssue(string Code, string Message, PolicyIssueSeverity Severity, string Path)
{
public static PolicyIssue Error(string code, string message, string path)
=> new(code, message, PolicyIssueSeverity.Error, path);
public static PolicyIssue Warning(string code, string message, string path)
=> new(code, message, PolicyIssueSeverity.Warning, path);
public static PolicyIssue Info(string code, string message, string path)
=> new(code, message, PolicyIssueSeverity.Info, path);
public PolicyIssue EnsurePath(string fallbackPath)
=> string.IsNullOrWhiteSpace(Path) ? this with { Path = fallbackPath } : this;
}
public enum PolicyIssueSeverity
{
Error,
Warning,
Info,
}

View File

@@ -1,18 +1,18 @@
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyPreviewRequest(
string ImageDigest,
ImmutableArray<PolicyFinding> Findings,
ImmutableArray<PolicyVerdict> BaselineVerdicts,
PolicySnapshot? SnapshotOverride = null,
PolicySnapshotContent? ProposedPolicy = null);
public sealed record PolicyPreviewResponse(
bool Success,
string PolicyDigest,
string? RevisionId,
ImmutableArray<PolicyIssue> Issues,
ImmutableArray<PolicyVerdictDiff> Diffs,
int ChangedCount);
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyPreviewRequest(
string ImageDigest,
ImmutableArray<PolicyFinding> Findings,
ImmutableArray<PolicyVerdict> BaselineVerdicts,
PolicySnapshot? SnapshotOverride = null,
PolicySnapshotContent? ProposedPolicy = null);
public sealed record PolicyPreviewResponse(
bool Success,
string PolicyDigest,
string? RevisionId,
ImmutableArray<PolicyIssue> Issues,
ImmutableArray<PolicyVerdictDiff> Diffs,
int ChangedCount);

View File

@@ -1,142 +1,142 @@
using System;
using System.Collections.Generic;
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy;
public sealed class PolicyPreviewService
{
private readonly PolicySnapshotStore _snapshotStore;
private readonly ILogger<PolicyPreviewService> _logger;
public PolicyPreviewService(PolicySnapshotStore snapshotStore, ILogger<PolicyPreviewService> logger)
{
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PolicyPreviewResponse> PreviewAsync(PolicyPreviewRequest request, CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var (snapshot, bindingIssues) = await ResolveSnapshotAsync(request, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
_logger.LogWarning("Policy preview failed: snapshot unavailable or validation errors. Issues={Count}", bindingIssues.Length);
return new PolicyPreviewResponse(false, string.Empty, null, bindingIssues, ImmutableArray<PolicyVerdictDiff>.Empty, 0);
}
var projected = Evaluate(snapshot.Document, snapshot.ScoringConfig, request.Findings);
var baseline = BuildBaseline(request.BaselineVerdicts, projected, snapshot.ScoringConfig);
var diffs = BuildDiffs(baseline, projected);
var changed = diffs.Count(static diff => diff.Changed);
_logger.LogDebug("Policy preview computed for {ImageDigest}. Changed={Changed}", request.ImageDigest, changed);
return new PolicyPreviewResponse(true, snapshot.Digest, snapshot.RevisionId, bindingIssues, diffs, changed);
}
private async Task<(PolicySnapshot? Snapshot, ImmutableArray<PolicyIssue> Issues)> ResolveSnapshotAsync(PolicyPreviewRequest request, CancellationToken cancellationToken)
{
if (request.ProposedPolicy is not null)
{
var binding = PolicyBinder.Bind(request.ProposedPolicy.Content, request.ProposedPolicy.Format);
if (!binding.Success)
{
return (null, binding.Issues);
}
var digest = PolicyDigest.Compute(binding.Document);
var snapshot = new PolicySnapshot(
request.SnapshotOverride?.RevisionNumber + 1 ?? 0,
request.SnapshotOverride?.RevisionId ?? "preview",
digest,
DateTimeOffset.UtcNow,
request.ProposedPolicy.Actor,
request.ProposedPolicy.Format,
binding.Document,
binding.Issues,
PolicyScoringConfig.Default);
return (snapshot, binding.Issues);
}
if (request.SnapshotOverride is not null)
{
return (request.SnapshotOverride, ImmutableArray<PolicyIssue>.Empty);
}
var latest = await _snapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
if (latest is not null)
{
return (latest, ImmutableArray<PolicyIssue>.Empty);
}
return (null, ImmutableArray.Create(PolicyIssue.Error("policy.preview.snapshot_missing", "No policy snapshot is available for preview.", "$")));
}
private static ImmutableArray<PolicyVerdict> Evaluate(PolicyDocument document, PolicyScoringConfig scoringConfig, ImmutableArray<PolicyFinding> findings)
{
if (findings.IsDefaultOrEmpty)
{
return ImmutableArray<PolicyVerdict>.Empty;
}
var results = ImmutableArray.CreateBuilder<PolicyVerdict>(findings.Length);
foreach (var finding in findings)
{
using System;
using System.Collections.Generic;
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy;
public sealed class PolicyPreviewService
{
private readonly PolicySnapshotStore _snapshotStore;
private readonly ILogger<PolicyPreviewService> _logger;
public PolicyPreviewService(PolicySnapshotStore snapshotStore, ILogger<PolicyPreviewService> logger)
{
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PolicyPreviewResponse> PreviewAsync(PolicyPreviewRequest request, CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var (snapshot, bindingIssues) = await ResolveSnapshotAsync(request, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
_logger.LogWarning("Policy preview failed: snapshot unavailable or validation errors. Issues={Count}", bindingIssues.Length);
return new PolicyPreviewResponse(false, string.Empty, null, bindingIssues, ImmutableArray<PolicyVerdictDiff>.Empty, 0);
}
var projected = Evaluate(snapshot.Document, snapshot.ScoringConfig, request.Findings);
var baseline = BuildBaseline(request.BaselineVerdicts, projected, snapshot.ScoringConfig);
var diffs = BuildDiffs(baseline, projected);
var changed = diffs.Count(static diff => diff.Changed);
_logger.LogDebug("Policy preview computed for {ImageDigest}. Changed={Changed}", request.ImageDigest, changed);
return new PolicyPreviewResponse(true, snapshot.Digest, snapshot.RevisionId, bindingIssues, diffs, changed);
}
private async Task<(PolicySnapshot? Snapshot, ImmutableArray<PolicyIssue> Issues)> ResolveSnapshotAsync(PolicyPreviewRequest request, CancellationToken cancellationToken)
{
if (request.ProposedPolicy is not null)
{
var binding = PolicyBinder.Bind(request.ProposedPolicy.Content, request.ProposedPolicy.Format);
if (!binding.Success)
{
return (null, binding.Issues);
}
var digest = PolicyDigest.Compute(binding.Document);
var snapshot = new PolicySnapshot(
request.SnapshotOverride?.RevisionNumber + 1 ?? 0,
request.SnapshotOverride?.RevisionId ?? "preview",
digest,
DateTimeOffset.UtcNow,
request.ProposedPolicy.Actor,
request.ProposedPolicy.Format,
binding.Document,
binding.Issues,
PolicyScoringConfig.Default);
return (snapshot, binding.Issues);
}
if (request.SnapshotOverride is not null)
{
return (request.SnapshotOverride, ImmutableArray<PolicyIssue>.Empty);
}
var latest = await _snapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
if (latest is not null)
{
return (latest, ImmutableArray<PolicyIssue>.Empty);
}
return (null, ImmutableArray.Create(PolicyIssue.Error("policy.preview.snapshot_missing", "No policy snapshot is available for preview.", "$")));
}
private static ImmutableArray<PolicyVerdict> Evaluate(PolicyDocument document, PolicyScoringConfig scoringConfig, ImmutableArray<PolicyFinding> findings)
{
if (findings.IsDefaultOrEmpty)
{
return ImmutableArray<PolicyVerdict>.Empty;
}
var results = ImmutableArray.CreateBuilder<PolicyVerdict>(findings.Length);
foreach (var finding in findings)
{
var verdict = PolicyEvaluation.EvaluateFinding(document, scoringConfig, finding, out _);
results.Add(verdict);
}
return results.ToImmutable();
}
private static ImmutableDictionary<string, PolicyVerdict> BuildBaseline(ImmutableArray<PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected, PolicyScoringConfig scoringConfig)
{
var builder = ImmutableDictionary.CreateBuilder<string, PolicyVerdict>(StringComparer.Ordinal);
if (!baseline.IsDefaultOrEmpty)
{
foreach (var verdict in baseline)
{
if (!string.IsNullOrEmpty(verdict.FindingId) && !builder.ContainsKey(verdict.FindingId))
{
builder.Add(verdict.FindingId, verdict);
}
}
}
foreach (var verdict in projected)
{
if (!builder.ContainsKey(verdict.FindingId))
{
builder.Add(verdict.FindingId, PolicyVerdict.CreateBaseline(verdict.FindingId, scoringConfig));
}
}
return builder.ToImmutable();
}
private static ImmutableArray<PolicyVerdictDiff> BuildDiffs(ImmutableDictionary<string, PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected)
{
var diffs = ImmutableArray.CreateBuilder<PolicyVerdictDiff>(projected.Length);
foreach (var verdict in projected.OrderBy(static v => v.FindingId, StringComparer.Ordinal))
{
var baseVerdict = baseline.TryGetValue(verdict.FindingId, out var existing)
? existing
: new PolicyVerdict(verdict.FindingId, PolicyVerdictStatus.Pass);
diffs.Add(new PolicyVerdictDiff(baseVerdict, verdict));
}
return diffs.ToImmutable();
}
}
results.Add(verdict);
}
return results.ToImmutable();
}
private static ImmutableDictionary<string, PolicyVerdict> BuildBaseline(ImmutableArray<PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected, PolicyScoringConfig scoringConfig)
{
var builder = ImmutableDictionary.CreateBuilder<string, PolicyVerdict>(StringComparer.Ordinal);
if (!baseline.IsDefaultOrEmpty)
{
foreach (var verdict in baseline)
{
if (!string.IsNullOrEmpty(verdict.FindingId) && !builder.ContainsKey(verdict.FindingId))
{
builder.Add(verdict.FindingId, verdict);
}
}
}
foreach (var verdict in projected)
{
if (!builder.ContainsKey(verdict.FindingId))
{
builder.Add(verdict.FindingId, PolicyVerdict.CreateBaseline(verdict.FindingId, scoringConfig));
}
}
return builder.ToImmutable();
}
private static ImmutableArray<PolicyVerdictDiff> BuildDiffs(ImmutableDictionary<string, PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected)
{
var diffs = ImmutableArray.CreateBuilder<PolicyVerdictDiff>(projected.Length);
foreach (var verdict in projected.OrderBy(static v => v.FindingId, StringComparer.Ordinal))
{
var baseVerdict = baseline.TryGetValue(verdict.FindingId, out var existing)
? existing
: new PolicyVerdict(verdict.FindingId, PolicyVerdictStatus.Pass);
diffs.Add(new PolicyVerdictDiff(baseVerdict, verdict));
}
return diffs.ToImmutable();
}
}

View File

@@ -1,30 +1,30 @@
using System;
using System.IO;
using System.Reflection;
using System.Text;
namespace StellaOps.Policy;
public static class PolicySchemaResource
{
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-schema@1.json";
public static Stream OpenSchemaStream()
{
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(SchemaResourceName);
if (stream is null)
{
throw new InvalidOperationException($"Unable to locate embedded schema resource '{SchemaResourceName}'.");
}
return stream;
}
public static string ReadSchemaJson()
{
using var stream = OpenSchemaStream();
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
return reader.ReadToEnd();
}
}
using System;
using System.IO;
using System.Reflection;
using System.Text;
namespace StellaOps.Policy;
public static class PolicySchemaResource
{
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-schema@1.json";
public static Stream OpenSchemaStream()
{
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(SchemaResourceName);
if (stream is null)
{
throw new InvalidOperationException($"Unable to locate embedded schema resource '{SchemaResourceName}'.");
}
return stream;
}
public static string ReadSchemaJson()
{
using var stream = OpenSchemaStream();
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
return reader.ReadToEnd();
}
}

View File

@@ -1,18 +1,18 @@
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyScoringConfig(
string Version,
ImmutableDictionary<PolicySeverity, double> SeverityWeights,
double QuietPenalty,
double WarnPenalty,
double IgnorePenalty,
ImmutableDictionary<string, double> TrustOverrides,
ImmutableDictionary<string, double> ReachabilityBuckets,
PolicyUnknownConfidenceConfig UnknownConfidence)
{
public static string BaselineVersion => "1.0";
public static PolicyScoringConfig Default { get; } = PolicyScoringConfigBinder.LoadDefault();
}
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyScoringConfig(
string Version,
ImmutableDictionary<PolicySeverity, double> SeverityWeights,
double QuietPenalty,
double WarnPenalty,
double IgnorePenalty,
ImmutableDictionary<string, double> TrustOverrides,
ImmutableDictionary<string, double> ReachabilityBuckets,
PolicyUnknownConfidenceConfig UnknownConfidence)
{
public static string BaselineVersion => "1.0";
public static PolicyScoringConfig Default { get; } = PolicyScoringConfigBinder.LoadDefault();
}

View File

@@ -1,100 +1,100 @@
using System;
using System.Buffers;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Policy;
public static class PolicyScoringConfigDigest
{
public static string Compute(PolicyScoringConfig config)
{
ArgumentNullException.ThrowIfNull(config);
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
SkipValidation = true,
}))
{
WriteConfig(writer, config);
}
var hash = SHA256.HashData(buffer.WrittenSpan);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static void WriteConfig(Utf8JsonWriter writer, PolicyScoringConfig config)
{
writer.WriteStartObject();
writer.WriteString("version", config.Version);
writer.WritePropertyName("severityWeights");
writer.WriteStartObject();
foreach (var severity in Enum.GetValues<PolicySeverity>())
{
var key = severity.ToString();
var value = config.SeverityWeights.TryGetValue(severity, out var weight) ? weight : 0;
writer.WriteNumber(key, value);
}
writer.WriteEndObject();
writer.WriteNumber("quietPenalty", config.QuietPenalty);
writer.WriteNumber("warnPenalty", config.WarnPenalty);
writer.WriteNumber("ignorePenalty", config.IgnorePenalty);
if (!config.TrustOverrides.IsEmpty)
{
writer.WritePropertyName("trustOverrides");
writer.WriteStartObject();
foreach (var pair in config.TrustOverrides.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
writer.WriteNumber(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
if (!config.ReachabilityBuckets.IsEmpty)
{
writer.WritePropertyName("reachabilityBuckets");
writer.WriteStartObject();
foreach (var pair in config.ReachabilityBuckets.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
writer.WriteNumber(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
writer.WritePropertyName("unknownConfidence");
writer.WriteStartObject();
writer.WriteNumber("initial", config.UnknownConfidence.Initial);
writer.WriteNumber("decayPerDay", config.UnknownConfidence.DecayPerDay);
writer.WriteNumber("floor", config.UnknownConfidence.Floor);
if (!config.UnknownConfidence.Bands.IsDefaultOrEmpty)
{
writer.WritePropertyName("bands");
writer.WriteStartArray();
foreach (var band in config.UnknownConfidence.Bands
.OrderByDescending(static b => b.Min)
.ThenBy(static b => b.Name, StringComparer.OrdinalIgnoreCase))
{
writer.WriteStartObject();
writer.WriteString("name", band.Name);
writer.WriteNumber("min", band.Min);
if (!string.IsNullOrWhiteSpace(band.Description))
{
writer.WriteString("description", band.Description);
}
writer.WriteEndObject();
}
writer.WriteEndArray();
}
writer.WriteEndObject();
writer.WriteEndObject();
writer.Flush();
}
}
using System;
using System.Buffers;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Policy;
public static class PolicyScoringConfigDigest
{
public static string Compute(PolicyScoringConfig config)
{
ArgumentNullException.ThrowIfNull(config);
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
SkipValidation = true,
}))
{
WriteConfig(writer, config);
}
var hash = SHA256.HashData(buffer.WrittenSpan);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static void WriteConfig(Utf8JsonWriter writer, PolicyScoringConfig config)
{
writer.WriteStartObject();
writer.WriteString("version", config.Version);
writer.WritePropertyName("severityWeights");
writer.WriteStartObject();
foreach (var severity in Enum.GetValues<PolicySeverity>())
{
var key = severity.ToString();
var value = config.SeverityWeights.TryGetValue(severity, out var weight) ? weight : 0;
writer.WriteNumber(key, value);
}
writer.WriteEndObject();
writer.WriteNumber("quietPenalty", config.QuietPenalty);
writer.WriteNumber("warnPenalty", config.WarnPenalty);
writer.WriteNumber("ignorePenalty", config.IgnorePenalty);
if (!config.TrustOverrides.IsEmpty)
{
writer.WritePropertyName("trustOverrides");
writer.WriteStartObject();
foreach (var pair in config.TrustOverrides.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
writer.WriteNumber(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
if (!config.ReachabilityBuckets.IsEmpty)
{
writer.WritePropertyName("reachabilityBuckets");
writer.WriteStartObject();
foreach (var pair in config.ReachabilityBuckets.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
writer.WriteNumber(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
writer.WritePropertyName("unknownConfidence");
writer.WriteStartObject();
writer.WriteNumber("initial", config.UnknownConfidence.Initial);
writer.WriteNumber("decayPerDay", config.UnknownConfidence.DecayPerDay);
writer.WriteNumber("floor", config.UnknownConfidence.Floor);
if (!config.UnknownConfidence.Bands.IsDefaultOrEmpty)
{
writer.WritePropertyName("bands");
writer.WriteStartArray();
foreach (var band in config.UnknownConfidence.Bands
.OrderByDescending(static b => b.Min)
.ThenBy(static b => b.Name, StringComparer.OrdinalIgnoreCase))
{
writer.WriteStartObject();
writer.WriteString("name", band.Name);
writer.WriteNumber("min", band.Min);
if (!string.IsNullOrWhiteSpace(band.Description))
{
writer.WriteString("description", band.Description);
}
writer.WriteEndObject();
}
writer.WriteEndArray();
}
writer.WriteEndObject();
writer.WriteEndObject();
writer.Flush();
}
}

View File

@@ -1,27 +1,27 @@
using System;
using System.IO;
using System.Reflection;
using System.Text;
using System.Threading;
using Json.Schema;
namespace StellaOps.Policy;
public static class PolicyScoringSchema
{
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-scoring-schema@1.json";
private static readonly Lazy<JsonSchema> CachedSchema = new(LoadSchema, LazyThreadSafetyMode.ExecutionAndPublication);
public static JsonSchema Schema => CachedSchema.Value;
private static JsonSchema LoadSchema()
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream(SchemaResourceName)
?? throw new InvalidOperationException($"Embedded resource '{SchemaResourceName}' was not found.");
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var schemaJson = reader.ReadToEnd();
return JsonSchema.FromText(schemaJson);
}
}
using System;
using System.IO;
using System.Reflection;
using System.Text;
using System.Threading;
using Json.Schema;
namespace StellaOps.Policy;
public static class PolicyScoringSchema
{
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-scoring-schema@1.json";
private static readonly Lazy<JsonSchema> CachedSchema = new(LoadSchema, LazyThreadSafetyMode.ExecutionAndPublication);
public static JsonSchema Schema => CachedSchema.Value;
private static JsonSchema LoadSchema()
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream(SchemaResourceName)
?? throw new InvalidOperationException($"Embedded resource '{SchemaResourceName}' was not found.");
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var schemaJson = reader.ReadToEnd();
return JsonSchema.FromText(schemaJson);
}
}

View File

@@ -1,29 +1,29 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicySnapshot(
long RevisionNumber,
string RevisionId,
string Digest,
DateTimeOffset CreatedAt,
string? CreatedBy,
PolicyDocumentFormat Format,
PolicyDocument Document,
ImmutableArray<PolicyIssue> Issues,
PolicyScoringConfig ScoringConfig);
public sealed record PolicySnapshotContent(
string Content,
PolicyDocumentFormat Format,
string? Actor,
string? Source,
string? Description);
public sealed record PolicySnapshotSaveResult(
bool Success,
bool Created,
string Digest,
PolicySnapshot? Snapshot,
PolicyBindingResult BindingResult);
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicySnapshot(
long RevisionNumber,
string RevisionId,
string Digest,
DateTimeOffset CreatedAt,
string? CreatedBy,
PolicyDocumentFormat Format,
PolicyDocument Document,
ImmutableArray<PolicyIssue> Issues,
PolicyScoringConfig ScoringConfig);
public sealed record PolicySnapshotContent(
string Content,
PolicyDocumentFormat Format,
string? Actor,
string? Source,
string? Description);
public sealed record PolicySnapshotSaveResult(
bool Success,
bool Created,
string Digest,
PolicySnapshot? Snapshot,
PolicyBindingResult BindingResult);

View File

@@ -1,101 +1,101 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy;
public sealed class PolicySnapshotStore
{
private readonly IPolicySnapshotRepository _snapshotRepository;
private readonly IPolicyAuditRepository _auditRepository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PolicySnapshotStore> _logger;
private readonly SemaphoreSlim _mutex = new(1, 1);
public PolicySnapshotStore(
IPolicySnapshotRepository snapshotRepository,
IPolicyAuditRepository auditRepository,
TimeProvider? timeProvider,
ILogger<PolicySnapshotStore> logger)
{
_snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository));
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PolicySnapshotSaveResult> SaveAsync(PolicySnapshotContent content, CancellationToken cancellationToken = default)
{
if (content is null)
{
throw new ArgumentNullException(nameof(content));
}
var bindingResult = PolicyBinder.Bind(content.Content, content.Format);
if (!bindingResult.Success)
{
_logger.LogWarning("Policy snapshot rejected due to validation errors (Format: {Format})", content.Format);
return new PolicySnapshotSaveResult(false, false, string.Empty, null, bindingResult);
}
var digest = PolicyDigest.Compute(bindingResult.Document);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var latest = await _snapshotRepository.GetLatestAsync(cancellationToken).ConfigureAwait(false);
if (latest is not null && string.Equals(latest.Digest, digest, StringComparison.Ordinal))
{
_logger.LogInformation("Policy snapshot unchanged; digest {Digest} matches revision {RevisionId}", digest, latest.RevisionId);
return new PolicySnapshotSaveResult(true, false, digest, latest, bindingResult);
}
var revisionNumber = (latest?.RevisionNumber ?? 0) + 1;
var revisionId = $"rev-{revisionNumber}";
var createdAt = _timeProvider.GetUtcNow();
var scoringConfig = PolicyScoringConfig.Default;
var snapshot = new PolicySnapshot(
revisionNumber,
revisionId,
digest,
createdAt,
content.Actor,
content.Format,
bindingResult.Document,
bindingResult.Issues,
scoringConfig);
await _snapshotRepository.AddAsync(snapshot, cancellationToken).ConfigureAwait(false);
var auditMessage = content.Description ?? "Policy snapshot created";
var auditEntry = new PolicyAuditEntry(
Guid.NewGuid(),
createdAt,
"snapshot.created",
revisionId,
digest,
content.Actor,
auditMessage);
await _auditRepository.AddAsync(auditEntry, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Policy snapshot saved. Revision {RevisionId}, digest {Digest}, issues {IssueCount}",
revisionId,
digest,
bindingResult.Issues.Length);
return new PolicySnapshotSaveResult(true, true, digest, snapshot, bindingResult);
}
finally
{
_mutex.Release();
}
}
public Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
=> _snapshotRepository.GetLatestAsync(cancellationToken);
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy;
public sealed class PolicySnapshotStore
{
private readonly IPolicySnapshotRepository _snapshotRepository;
private readonly IPolicyAuditRepository _auditRepository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PolicySnapshotStore> _logger;
private readonly SemaphoreSlim _mutex = new(1, 1);
public PolicySnapshotStore(
IPolicySnapshotRepository snapshotRepository,
IPolicyAuditRepository auditRepository,
TimeProvider? timeProvider,
ILogger<PolicySnapshotStore> logger)
{
_snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository));
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PolicySnapshotSaveResult> SaveAsync(PolicySnapshotContent content, CancellationToken cancellationToken = default)
{
if (content is null)
{
throw new ArgumentNullException(nameof(content));
}
var bindingResult = PolicyBinder.Bind(content.Content, content.Format);
if (!bindingResult.Success)
{
_logger.LogWarning("Policy snapshot rejected due to validation errors (Format: {Format})", content.Format);
return new PolicySnapshotSaveResult(false, false, string.Empty, null, bindingResult);
}
var digest = PolicyDigest.Compute(bindingResult.Document);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var latest = await _snapshotRepository.GetLatestAsync(cancellationToken).ConfigureAwait(false);
if (latest is not null && string.Equals(latest.Digest, digest, StringComparison.Ordinal))
{
_logger.LogInformation("Policy snapshot unchanged; digest {Digest} matches revision {RevisionId}", digest, latest.RevisionId);
return new PolicySnapshotSaveResult(true, false, digest, latest, bindingResult);
}
var revisionNumber = (latest?.RevisionNumber ?? 0) + 1;
var revisionId = $"rev-{revisionNumber}";
var createdAt = _timeProvider.GetUtcNow();
var scoringConfig = PolicyScoringConfig.Default;
var snapshot = new PolicySnapshot(
revisionNumber,
revisionId,
digest,
createdAt,
content.Actor,
content.Format,
bindingResult.Document,
bindingResult.Issues,
scoringConfig);
await _snapshotRepository.AddAsync(snapshot, cancellationToken).ConfigureAwait(false);
var auditMessage = content.Description ?? "Policy snapshot created";
var auditEntry = new PolicyAuditEntry(
Guid.NewGuid(),
createdAt,
"snapshot.created",
revisionId,
digest,
content.Actor,
auditMessage);
await _auditRepository.AddAsync(auditEntry, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Policy snapshot saved. Revision {RevisionId}, digest {Digest}, issues {IssueCount}",
revisionId,
digest,
bindingResult.Issues.Length);
return new PolicySnapshotSaveResult(true, true, digest, snapshot, bindingResult);
}
finally
{
_mutex.Release();
}
}
public Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
=> _snapshotRepository.GetLatestAsync(cancellationToken);
}

View File

@@ -1,37 +1,37 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyUnknownConfidenceConfig(
double Initial,
double DecayPerDay,
double Floor,
ImmutableArray<PolicyUnknownConfidenceBand> Bands)
{
public double Clamp(double value)
=> Math.Clamp(value, Floor, 1.0);
public PolicyUnknownConfidenceBand ResolveBand(double value)
{
if (Bands.IsDefaultOrEmpty)
{
return PolicyUnknownConfidenceBand.Default;
}
foreach (var band in Bands)
{
if (value >= band.Min)
{
return band;
}
}
return Bands[Bands.Length - 1];
}
}
public sealed record PolicyUnknownConfidenceBand(string Name, double Min, string? Description = null)
{
public static PolicyUnknownConfidenceBand Default { get; } = new("unspecified", 0, null);
}
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyUnknownConfidenceConfig(
double Initial,
double DecayPerDay,
double Floor,
ImmutableArray<PolicyUnknownConfidenceBand> Bands)
{
public double Clamp(double value)
=> Math.Clamp(value, Floor, 1.0);
public PolicyUnknownConfidenceBand ResolveBand(double value)
{
if (Bands.IsDefaultOrEmpty)
{
return PolicyUnknownConfidenceBand.Default;
}
foreach (var band in Bands)
{
if (value >= band.Min)
{
return band;
}
}
return Bands[Bands.Length - 1];
}
}
public sealed record PolicyUnknownConfidenceBand(string Name, double Min, string? Description = null)
{
public static PolicyUnknownConfidenceBand Default { get; } = new("unspecified", 0, null);
}

View File

@@ -1,76 +1,76 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public sealed record PolicyValidationCliOptions
{
public IReadOnlyList<string> Inputs { get; init; } = Array.Empty<string>();
/// <summary>
/// Writes machine-readable JSON instead of human-formatted text.
/// </summary>
public bool OutputJson { get; init; }
/// <summary>
/// When enabled, warnings cause a non-zero exit code.
/// </summary>
public bool Strict { get; init; }
}
public sealed record PolicyValidationFileResult(
string Path,
PolicyBindingResult BindingResult,
PolicyDiagnosticsReport Diagnostics);
public sealed class PolicyValidationCli
{
private readonly TextWriter _output;
private readonly TextWriter _error;
public PolicyValidationCli(TextWriter? output = null, TextWriter? error = null)
{
_output = output ?? Console.Out;
_error = error ?? Console.Error;
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public sealed record PolicyValidationCliOptions
{
public IReadOnlyList<string> Inputs { get; init; } = Array.Empty<string>();
/// <summary>
/// Writes machine-readable JSON instead of human-formatted text.
/// </summary>
public bool OutputJson { get; init; }
/// <summary>
/// When enabled, warnings cause a non-zero exit code.
/// </summary>
public bool Strict { get; init; }
}
public sealed record PolicyValidationFileResult(
string Path,
PolicyBindingResult BindingResult,
PolicyDiagnosticsReport Diagnostics);
public sealed class PolicyValidationCli
{
private readonly TextWriter _output;
private readonly TextWriter _error;
public PolicyValidationCli(TextWriter? output = null, TextWriter? error = null)
{
_output = output ?? Console.Out;
_error = error ?? Console.Error;
}
public async Task<int> RunAsync(PolicyValidationCliOptions options, CancellationToken cancellationToken = default)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
if (options.Inputs.Count == 0)
{
await _error.WriteLineAsync("No input files provided. Supply one or more policy file paths.");
return 64; // EX_USAGE
}
var results = new List<PolicyValidationFileResult>();
foreach (var input in options.Inputs)
{
cancellationToken.ThrowIfCancellationRequested();
var resolvedPaths = ResolveInput(input);
if (resolvedPaths.Count == 0)
{
await _error.WriteLineAsync($"No files matched '{input}'.");
continue;
}
foreach (var path in resolvedPaths)
{
cancellationToken.ThrowIfCancellationRequested();
var format = PolicySchema.DetectFormat(path);
var content = await File.ReadAllTextAsync(path, cancellationToken);
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
if (options.Inputs.Count == 0)
{
await _error.WriteLineAsync("No input files provided. Supply one or more policy file paths.");
return 64; // EX_USAGE
}
var results = new List<PolicyValidationFileResult>();
foreach (var input in options.Inputs)
{
cancellationToken.ThrowIfCancellationRequested();
var resolvedPaths = ResolveInput(input);
if (resolvedPaths.Count == 0)
{
await _error.WriteLineAsync($"No files matched '{input}'.");
continue;
}
foreach (var path in resolvedPaths)
{
cancellationToken.ThrowIfCancellationRequested();
var format = PolicySchema.DetectFormat(path);
var content = await File.ReadAllTextAsync(path, cancellationToken);
var bindingResult = PolicyBinder.Bind(content, format);
var diagnostics = PolicyDiagnostics.Create(bindingResult);
@@ -83,170 +83,170 @@ public sealed class PolicyValidationCli
Recommendations = diagnostics.Recommendations.Add($"canonical.spl.digest:{splHash}"),
};
}
results.Add(new PolicyValidationFileResult(path, bindingResult, diagnostics));
}
}
if (results.Count == 0)
{
await _error.WriteLineAsync("No files were processed.");
return 65; // EX_DATAERR
}
if (options.OutputJson)
{
WriteJson(results);
}
else
{
await WriteTextAsync(results, cancellationToken);
}
var hasErrors = results.Any(static result => !result.BindingResult.Success);
var hasWarnings = results.Any(static result => result.BindingResult.Issues.Any(static issue => issue.Severity == PolicyIssueSeverity.Warning));
if (hasErrors)
{
return 1;
}
if (options.Strict && hasWarnings)
{
return 2;
}
return 0;
}
private async Task WriteTextAsync(IReadOnlyList<PolicyValidationFileResult> results, CancellationToken cancellationToken)
{
foreach (var result in results)
{
cancellationToken.ThrowIfCancellationRequested();
var relativePath = MakeRelative(result.Path);
await _output.WriteLineAsync($"{relativePath} [{result.BindingResult.Format}]");
if (result.BindingResult.Issues.Length == 0)
{
await _output.WriteLineAsync(" OK");
continue;
}
foreach (var issue in result.BindingResult.Issues)
{
var severity = issue.Severity.ToString().ToUpperInvariant().PadRight(7);
await _output.WriteLineAsync($" {severity} {issue.Path} :: {issue.Message} ({issue.Code})");
}
}
}
private void WriteJson(IReadOnlyList<PolicyValidationFileResult> results)
{
var payload = results.Select(static result => new
{
path = result.Path,
format = result.BindingResult.Format.ToString().ToLowerInvariant(),
success = result.BindingResult.Success,
issues = result.BindingResult.Issues.Select(static issue => new
{
code = issue.Code,
message = issue.Message,
severity = issue.Severity.ToString().ToLowerInvariant(),
path = issue.Path,
}),
diagnostics = new
{
version = result.Diagnostics.Version,
ruleCount = result.Diagnostics.RuleCount,
errorCount = result.Diagnostics.ErrorCount,
warningCount = result.Diagnostics.WarningCount,
generatedAt = result.Diagnostics.GeneratedAt,
recommendations = result.Diagnostics.Recommendations,
},
})
.ToArray();
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
WriteIndented = true,
});
_output.WriteLine(json);
}
private static IReadOnlyList<string> ResolveInput(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
return Array.Empty<string>();
}
var expanded = Environment.ExpandEnvironmentVariables(input.Trim());
if (File.Exists(expanded))
{
return new[] { Path.GetFullPath(expanded) };
}
if (Directory.Exists(expanded))
{
return Directory.EnumerateFiles(expanded, "*.*", SearchOption.TopDirectoryOnly)
.Where(static path => MatchesPolicyExtension(path))
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.Select(Path.GetFullPath)
.ToArray();
}
var directory = Path.GetDirectoryName(expanded);
var searchPattern = Path.GetFileName(expanded);
if (string.IsNullOrEmpty(searchPattern))
{
return Array.Empty<string>();
}
if (string.IsNullOrEmpty(directory))
{
directory = ".";
}
if (!Directory.Exists(directory))
{
return Array.Empty<string>();
}
return Directory.EnumerateFiles(directory, searchPattern, SearchOption.TopDirectoryOnly)
.Where(static path => MatchesPolicyExtension(path))
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.Select(Path.GetFullPath)
.ToArray();
}
private static bool MatchesPolicyExtension(string path)
{
var extension = Path.GetExtension(path);
results.Add(new PolicyValidationFileResult(path, bindingResult, diagnostics));
}
}
if (results.Count == 0)
{
await _error.WriteLineAsync("No files were processed.");
return 65; // EX_DATAERR
}
if (options.OutputJson)
{
WriteJson(results);
}
else
{
await WriteTextAsync(results, cancellationToken);
}
var hasErrors = results.Any(static result => !result.BindingResult.Success);
var hasWarnings = results.Any(static result => result.BindingResult.Issues.Any(static issue => issue.Severity == PolicyIssueSeverity.Warning));
if (hasErrors)
{
return 1;
}
if (options.Strict && hasWarnings)
{
return 2;
}
return 0;
}
private async Task WriteTextAsync(IReadOnlyList<PolicyValidationFileResult> results, CancellationToken cancellationToken)
{
foreach (var result in results)
{
cancellationToken.ThrowIfCancellationRequested();
var relativePath = MakeRelative(result.Path);
await _output.WriteLineAsync($"{relativePath} [{result.BindingResult.Format}]");
if (result.BindingResult.Issues.Length == 0)
{
await _output.WriteLineAsync(" OK");
continue;
}
foreach (var issue in result.BindingResult.Issues)
{
var severity = issue.Severity.ToString().ToUpperInvariant().PadRight(7);
await _output.WriteLineAsync($" {severity} {issue.Path} :: {issue.Message} ({issue.Code})");
}
}
}
private void WriteJson(IReadOnlyList<PolicyValidationFileResult> results)
{
var payload = results.Select(static result => new
{
path = result.Path,
format = result.BindingResult.Format.ToString().ToLowerInvariant(),
success = result.BindingResult.Success,
issues = result.BindingResult.Issues.Select(static issue => new
{
code = issue.Code,
message = issue.Message,
severity = issue.Severity.ToString().ToLowerInvariant(),
path = issue.Path,
}),
diagnostics = new
{
version = result.Diagnostics.Version,
ruleCount = result.Diagnostics.RuleCount,
errorCount = result.Diagnostics.ErrorCount,
warningCount = result.Diagnostics.WarningCount,
generatedAt = result.Diagnostics.GeneratedAt,
recommendations = result.Diagnostics.Recommendations,
},
})
.ToArray();
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
WriteIndented = true,
});
_output.WriteLine(json);
}
private static IReadOnlyList<string> ResolveInput(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
return Array.Empty<string>();
}
var expanded = Environment.ExpandEnvironmentVariables(input.Trim());
if (File.Exists(expanded))
{
return new[] { Path.GetFullPath(expanded) };
}
if (Directory.Exists(expanded))
{
return Directory.EnumerateFiles(expanded, "*.*", SearchOption.TopDirectoryOnly)
.Where(static path => MatchesPolicyExtension(path))
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.Select(Path.GetFullPath)
.ToArray();
}
var directory = Path.GetDirectoryName(expanded);
var searchPattern = Path.GetFileName(expanded);
if (string.IsNullOrEmpty(searchPattern))
{
return Array.Empty<string>();
}
if (string.IsNullOrEmpty(directory))
{
directory = ".";
}
if (!Directory.Exists(directory))
{
return Array.Empty<string>();
}
return Directory.EnumerateFiles(directory, searchPattern, SearchOption.TopDirectoryOnly)
.Where(static path => MatchesPolicyExtension(path))
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.Select(Path.GetFullPath)
.ToArray();
}
private static bool MatchesPolicyExtension(string path)
{
var extension = Path.GetExtension(path);
return extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".json", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".stella", StringComparison.OrdinalIgnoreCase);
}
private static string MakeRelative(string path)
{
try
{
var fullPath = Path.GetFullPath(path);
var current = Directory.GetCurrentDirectory();
if (fullPath.StartsWith(current, StringComparison.OrdinalIgnoreCase))
{
return fullPath[current.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
return fullPath;
}
catch
{
return path;
}
}
}
}
private static string MakeRelative(string path)
{
try
{
var fullPath = Path.GetFullPath(path);
var current = Directory.GetCurrentDirectory();
if (fullPath.StartsWith(current, StringComparison.OrdinalIgnoreCase))
{
return fullPath[current.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
return fullPath;
}
catch
{
return path;
}
}
}

View File

@@ -1,112 +1,112 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
public enum PolicyVerdictStatus
{
Pass,
Blocked,
Ignored,
Warned,
Deferred,
Escalated,
RequiresVex,
}
public sealed record PolicyVerdict(
string FindingId,
PolicyVerdictStatus Status,
string? RuleName = null,
string? RuleAction = null,
string? Notes = null,
double Score = 0,
string ConfigVersion = "1.0",
ImmutableDictionary<string, double>? Inputs = null,
string? QuietedBy = null,
bool Quiet = false,
double? UnknownConfidence = null,
string? ConfidenceBand = null,
double? UnknownAgeDays = null,
string? SourceTrust = null,
string? Reachability = null)
{
public static PolicyVerdict CreateBaseline(string findingId, PolicyScoringConfig scoringConfig)
{
var inputs = ImmutableDictionary<string, double>.Empty;
return new PolicyVerdict(
findingId,
PolicyVerdictStatus.Pass,
RuleName: null,
RuleAction: null,
Notes: null,
Score: 0,
ConfigVersion: scoringConfig.Version,
Inputs: inputs,
QuietedBy: null,
Quiet: false,
UnknownConfidence: null,
ConfidenceBand: null,
UnknownAgeDays: null,
SourceTrust: null,
Reachability: null);
}
public ImmutableDictionary<string, double> GetInputs()
=> Inputs ?? ImmutableDictionary<string, double>.Empty;
}
public sealed record PolicyVerdictDiff(
PolicyVerdict Baseline,
PolicyVerdict Projected)
{
public bool Changed
{
get
{
if (Baseline.Status != Projected.Status)
{
return true;
}
if (!string.Equals(Baseline.RuleName, Projected.RuleName, StringComparison.Ordinal))
{
return true;
}
if (Math.Abs(Baseline.Score - Projected.Score) > 0.0001)
{
return true;
}
if (!string.Equals(Baseline.QuietedBy, Projected.QuietedBy, StringComparison.Ordinal))
{
return true;
}
var baselineConfidence = Baseline.UnknownConfidence ?? 0;
var projectedConfidence = Projected.UnknownConfidence ?? 0;
if (Math.Abs(baselineConfidence - projectedConfidence) > 0.0001)
{
return true;
}
if (!string.Equals(Baseline.ConfidenceBand, Projected.ConfidenceBand, StringComparison.Ordinal))
{
return true;
}
if (!string.Equals(Baseline.SourceTrust, Projected.SourceTrust, StringComparison.Ordinal))
{
return true;
}
if (!string.Equals(Baseline.Reachability, Projected.Reachability, StringComparison.Ordinal))
{
return true;
}
return false;
}
}
}
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
public enum PolicyVerdictStatus
{
Pass,
Blocked,
Ignored,
Warned,
Deferred,
Escalated,
RequiresVex,
}
public sealed record PolicyVerdict(
string FindingId,
PolicyVerdictStatus Status,
string? RuleName = null,
string? RuleAction = null,
string? Notes = null,
double Score = 0,
string ConfigVersion = "1.0",
ImmutableDictionary<string, double>? Inputs = null,
string? QuietedBy = null,
bool Quiet = false,
double? UnknownConfidence = null,
string? ConfidenceBand = null,
double? UnknownAgeDays = null,
string? SourceTrust = null,
string? Reachability = null)
{
public static PolicyVerdict CreateBaseline(string findingId, PolicyScoringConfig scoringConfig)
{
var inputs = ImmutableDictionary<string, double>.Empty;
return new PolicyVerdict(
findingId,
PolicyVerdictStatus.Pass,
RuleName: null,
RuleAction: null,
Notes: null,
Score: 0,
ConfigVersion: scoringConfig.Version,
Inputs: inputs,
QuietedBy: null,
Quiet: false,
UnknownConfidence: null,
ConfidenceBand: null,
UnknownAgeDays: null,
SourceTrust: null,
Reachability: null);
}
public ImmutableDictionary<string, double> GetInputs()
=> Inputs ?? ImmutableDictionary<string, double>.Empty;
}
public sealed record PolicyVerdictDiff(
PolicyVerdict Baseline,
PolicyVerdict Projected)
{
public bool Changed
{
get
{
if (Baseline.Status != Projected.Status)
{
return true;
}
if (!string.Equals(Baseline.RuleName, Projected.RuleName, StringComparison.Ordinal))
{
return true;
}
if (Math.Abs(Baseline.Score - Projected.Score) > 0.0001)
{
return true;
}
if (!string.Equals(Baseline.QuietedBy, Projected.QuietedBy, StringComparison.Ordinal))
{
return true;
}
var baselineConfidence = Baseline.UnknownConfidence ?? 0;
var projectedConfidence = Projected.UnknownConfidence ?? 0;
if (Math.Abs(baselineConfidence - projectedConfidence) > 0.0001)
{
return true;
}
if (!string.Equals(Baseline.ConfidenceBand, Projected.ConfidenceBand, StringComparison.Ordinal))
{
return true;
}
if (!string.Equals(Baseline.SourceTrust, Projected.SourceTrust, StringComparison.Ordinal))
{
return true;
}
if (!string.Equals(Baseline.Reachability, Projected.Reachability, StringComparison.Ordinal))
{
return true;
}
return false;
}
}
}

View File

@@ -1,14 +1,14 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public interface IPolicySnapshotRepository
{
Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default);
Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default);
}
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public interface IPolicySnapshotRepository
{
Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default);
Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default);
}

View File

@@ -1,65 +1,65 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public sealed class InMemoryPolicySnapshotRepository : IPolicySnapshotRepository
{
private readonly List<PolicySnapshot> _snapshots = new();
private readonly SemaphoreSlim _mutex = new(1, 1);
public async Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default)
{
if (snapshot is null)
{
throw new ArgumentNullException(nameof(snapshot));
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_snapshots.Add(snapshot);
_snapshots.Sort(static (left, right) => left.RevisionNumber.CompareTo(right.RevisionNumber));
}
finally
{
_mutex.Release();
}
}
public async Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
{
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return _snapshots.Count == 0 ? null : _snapshots[^1];
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default)
{
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
IEnumerable<PolicySnapshot> query = _snapshots;
if (limit > 0)
{
query = query.TakeLast(limit);
}
return query.ToImmutableArray();
}
finally
{
_mutex.Release();
}
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public sealed class InMemoryPolicySnapshotRepository : IPolicySnapshotRepository
{
private readonly List<PolicySnapshot> _snapshots = new();
private readonly SemaphoreSlim _mutex = new(1, 1);
public async Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default)
{
if (snapshot is null)
{
throw new ArgumentNullException(nameof(snapshot));
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_snapshots.Add(snapshot);
_snapshots.Sort(static (left, right) => left.RevisionNumber.CompareTo(right.RevisionNumber));
}
finally
{
_mutex.Release();
}
}
public async Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
{
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return _snapshots.Count == 0 ? null : _snapshots[^1];
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default)
{
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
IEnumerable<PolicySnapshot> query = _snapshots;
if (limit > 0)
{
query = query.TakeLast(limit);
}
return query.ToImmutableArray();
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -1,104 +1,104 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.PolicyDsl;
using Xunit;
using Xunit.Sdk;
namespace StellaOps.Policy.Engine.Tests;
public sealed class PolicyCompilerTests
{
[Fact]
public void Compile_BaselinePolicy_Succeeds()
{
const string source = """
policy "Baseline Production Policy" syntax "stella-dsl@1" {
metadata {
description = "Block critical, escalate high, enforce VEX justifications."
tags = ["baseline","production"]
}
profile severity {
map vendor_weight {
source "GHSA" => +0.5
source "OSV" => +0.0
}
env exposure_adjustments {
if env.exposure == "internet" then +0.5
}
}
rule block_critical priority 5 {
when severity.normalized >= "Critical"
then status := "blocked"
because "Critical severity must be remediated before deploy."
}
rule escalate_high_internet {
when severity.normalized == "High"
and env.exposure == "internet"
then escalate to severity_band("Critical")
because "High severity on internet-exposed asset escalates to critical."
}
rule require_vex_justification {
when vex.any(status in ["not_affected","fixed"])
and vex.justification in ["component_not_present","vulnerable_code_not_present"]
then status := vex.status
annotate winning_statement := vex.latest().statementId
because "Respect strong vendor VEX claims."
}
rule alert_warn_eol_runtime priority 1 {
when severity.normalized <= "Medium"
and sbom.has_tag("runtime:eol")
then warn message "Runtime marked as EOL; upgrade recommended."
because "Deprecated runtime should be upgraded."
}
}
""";
var compiler = new PolicyCompiler();
var result = compiler.Compile(source);
if (!result.Success)
{
throw new Xunit.Sdk.XunitException($"Compilation failed: {Describe(result.Diagnostics)}");
}
Assert.False(string.IsNullOrWhiteSpace(result.Checksum));
Assert.NotEmpty(result.CanonicalRepresentation);
Assert.All(result.Diagnostics, issue => Assert.NotEqual(PolicyIssueSeverity.Error, issue.Severity));
var document = Assert.IsType<PolicyIrDocument>(result.Document);
Assert.Equal("Baseline Production Policy", document.Name);
Assert.Equal("stella-dsl@1", document.Syntax);
Assert.Equal(4, document.Rules.Length);
Assert.Single(document.Profiles);
var firstAction = Assert.IsType<PolicyIrAssignmentAction>(document.Rules[0].ThenActions[0]);
Assert.Equal("status", firstAction.Target[0]);
}
[Fact]
public void Compile_MissingBecause_ReportsDiagnostic()
{
const string source = """
policy "Incomplete" syntax "stella-dsl@1" {
rule missing_because {
when true
then status := "suppressed"
}
}
""";
var compiler = new PolicyCompiler();
var result = compiler.Compile(source);
Assert.False(result.Success);
PolicyIssue diagnostic = result.Diagnostics.First(issue => issue.Code == "POLICY-DSL-PARSE-006");
Assert.Equal(PolicyIssueSeverity.Error, diagnostic.Severity);
}
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
}
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.PolicyDsl;
using Xunit;
using Xunit.Sdk;
namespace StellaOps.Policy.Engine.Tests;
public sealed class PolicyCompilerTests
{
[Fact]
public void Compile_BaselinePolicy_Succeeds()
{
const string source = """
policy "Baseline Production Policy" syntax "stella-dsl@1" {
metadata {
description = "Block critical, escalate high, enforce VEX justifications."
tags = ["baseline","production"]
}
profile severity {
map vendor_weight {
source "GHSA" => +0.5
source "OSV" => +0.0
}
env exposure_adjustments {
if env.exposure == "internet" then +0.5
}
}
rule block_critical priority 5 {
when severity.normalized >= "Critical"
then status := "blocked"
because "Critical severity must be remediated before deploy."
}
rule escalate_high_internet {
when severity.normalized == "High"
and env.exposure == "internet"
then escalate to severity_band("Critical")
because "High severity on internet-exposed asset escalates to critical."
}
rule require_vex_justification {
when vex.any(status in ["not_affected","fixed"])
and vex.justification in ["component_not_present","vulnerable_code_not_present"]
then status := vex.status
annotate winning_statement := vex.latest().statementId
because "Respect strong vendor VEX claims."
}
rule alert_warn_eol_runtime priority 1 {
when severity.normalized <= "Medium"
and sbom.has_tag("runtime:eol")
then warn message "Runtime marked as EOL; upgrade recommended."
because "Deprecated runtime should be upgraded."
}
}
""";
var compiler = new PolicyCompiler();
var result = compiler.Compile(source);
if (!result.Success)
{
throw new Xunit.Sdk.XunitException($"Compilation failed: {Describe(result.Diagnostics)}");
}
Assert.False(string.IsNullOrWhiteSpace(result.Checksum));
Assert.NotEmpty(result.CanonicalRepresentation);
Assert.All(result.Diagnostics, issue => Assert.NotEqual(PolicyIssueSeverity.Error, issue.Severity));
var document = Assert.IsType<PolicyIrDocument>(result.Document);
Assert.Equal("Baseline Production Policy", document.Name);
Assert.Equal("stella-dsl@1", document.Syntax);
Assert.Equal(4, document.Rules.Length);
Assert.Single(document.Profiles);
var firstAction = Assert.IsType<PolicyIrAssignmentAction>(document.Rules[0].ThenActions[0]);
Assert.Equal("status", firstAction.Target[0]);
}
[Fact]
public void Compile_MissingBecause_ReportsDiagnostic()
{
const string source = """
policy "Incomplete" syntax "stella-dsl@1" {
rule missing_because {
when true
then status := "suppressed"
}
}
""";
var compiler = new PolicyCompiler();
var result = compiler.Compile(source);
Assert.False(result.Success);
PolicyIssue diagnostic = result.Diagnostics.First(issue => issue.Code == "POLICY-DSL-PARSE-006");
Assert.Equal(PolicyIssueSeverity.Error, diagnostic.Severity);
}
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
}

View File

@@ -1,44 +1,44 @@
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Services;
using Xunit;
namespace StellaOps.Policy.Engine.Tests;
public class PolicyPackRepositoryTests
{
private readonly InMemoryPolicyPackRepository repository = new();
[Fact]
public async Task ActivateRevision_WithSingleApprover_ActivatesImmediately()
{
await repository.CreateAsync("pack-1", "Pack", CancellationToken.None);
await repository.UpsertRevisionAsync("pack-1", 1, requiresTwoPersonApproval: false, PolicyRevisionStatus.Approved, CancellationToken.None);
var result = await repository.RecordActivationAsync("pack-1", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
Assert.Equal(PolicyActivationResultStatus.Activated, result.Status);
Assert.NotNull(result.Revision);
Assert.Equal(PolicyRevisionStatus.Active, result.Revision!.Status);
Assert.Single(result.Revision.Approvals);
}
[Fact]
public async Task ActivateRevision_WithTwoPersonRequirement_ReturnsPendingUntilSecondApproval()
{
await repository.CreateAsync("pack-2", "Pack", CancellationToken.None);
await repository.UpsertRevisionAsync("pack-2", 1, requiresTwoPersonApproval: true, PolicyRevisionStatus.Approved, CancellationToken.None);
var first = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
Assert.Equal(PolicyActivationResultStatus.PendingSecondApproval, first.Status);
Assert.Equal(PolicyRevisionStatus.Approved, first.Revision!.Status);
Assert.Single(first.Revision.Approvals);
var duplicate = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
Assert.Equal(PolicyActivationResultStatus.DuplicateApproval, duplicate.Status);
var second = await repository.RecordActivationAsync("pack-2", 1, "bob", DateTimeOffset.UtcNow, null, CancellationToken.None);
Assert.Equal(PolicyActivationResultStatus.Activated, second.Status);
Assert.Equal(PolicyRevisionStatus.Active, second.Revision!.Status);
Assert.Equal(2, second.Revision.Approvals.Length);
}
}
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Services;
using Xunit;
namespace StellaOps.Policy.Engine.Tests;
public class PolicyPackRepositoryTests
{
private readonly InMemoryPolicyPackRepository repository = new();
[Fact]
public async Task ActivateRevision_WithSingleApprover_ActivatesImmediately()
{
await repository.CreateAsync("pack-1", "Pack", CancellationToken.None);
await repository.UpsertRevisionAsync("pack-1", 1, requiresTwoPersonApproval: false, PolicyRevisionStatus.Approved, CancellationToken.None);
var result = await repository.RecordActivationAsync("pack-1", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
Assert.Equal(PolicyActivationResultStatus.Activated, result.Status);
Assert.NotNull(result.Revision);
Assert.Equal(PolicyRevisionStatus.Active, result.Revision!.Status);
Assert.Single(result.Revision.Approvals);
}
[Fact]
public async Task ActivateRevision_WithTwoPersonRequirement_ReturnsPendingUntilSecondApproval()
{
await repository.CreateAsync("pack-2", "Pack", CancellationToken.None);
await repository.UpsertRevisionAsync("pack-2", 1, requiresTwoPersonApproval: true, PolicyRevisionStatus.Approved, CancellationToken.None);
var first = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
Assert.Equal(PolicyActivationResultStatus.PendingSecondApproval, first.Status);
Assert.Equal(PolicyRevisionStatus.Approved, first.Revision!.Status);
Assert.Single(first.Revision.Approvals);
var duplicate = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
Assert.Equal(PolicyActivationResultStatus.DuplicateApproval, duplicate.Status);
var second = await repository.RecordActivationAsync("pack-2", 1, "bob", DateTimeOffset.UtcNow, null, CancellationToken.None);
Assert.Equal(PolicyActivationResultStatus.Activated, second.Status);
Assert.Equal(PolicyRevisionStatus.Active, second.Revision!.Status);
Assert.Equal(2, second.Revision.Approvals.Length);
}
}

View File

@@ -1,212 +1,212 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Net;
using System.Net.Http;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Client;
using StellaOps.Policy.Gateway.Clients;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests;
public class PolicyEngineClientTests
{
[Fact]
public async Task ActivateRevision_UsesServiceTokenWhenForwardingContextMissing()
{
var options = CreateGatewayOptions();
options.PolicyEngine.ClientCredentials.Enabled = true;
options.PolicyEngine.ClientCredentials.ClientId = "policy-gateway";
options.PolicyEngine.ClientCredentials.ClientSecret = "secret";
options.PolicyEngine.ClientCredentials.Scopes.Clear();
options.PolicyEngine.ClientCredentials.Scopes.Add("policy:activate");
options.PolicyEngine.BaseAddress = "https://policy-engine.test/";
var optionsMonitor = new TestOptionsMonitor(options);
var tokenClient = new StubTokenClient();
var dpopGenerator = new PolicyGatewayDpopProofGenerator(new StubHostEnvironment(), optionsMonitor, TimeProvider.System, NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
var tokenProvider = new PolicyEngineTokenProvider(tokenClient, optionsMonitor, dpopGenerator, TimeProvider.System, NullLogger<PolicyEngineTokenProvider>.Instance);
using var recordingHandler = new RecordingHandler();
using var httpClient = new HttpClient(recordingHandler)
{
BaseAddress = new Uri(options.PolicyEngine.BaseAddress)
};
var client = new PolicyEngineClient(httpClient, Microsoft.Extensions.Options.Options.Create(options), tokenProvider, NullLogger<PolicyEngineClient>.Instance);
var request = new ActivatePolicyRevisionRequest("comment");
var result = await client.ActivatePolicyRevisionAsync(null, "pack-123", 7, request, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.NotNull(recordingHandler.LastRequest);
var authorization = recordingHandler.LastRequest!.Headers.Authorization;
Assert.NotNull(authorization);
Assert.Equal("Bearer", authorization!.Scheme);
Assert.Equal("service-token", authorization.Parameter);
Assert.Equal(1, tokenClient.RequestCount);
}
[Fact]
public void Metrics_RecordActivation_EmitsExpectedTags()
{
using var metrics = new PolicyGatewayMetrics();
using var listener = new MeterListener();
var measurements = new List<(long Value, string Outcome, string Source)>();
var latencies = new List<(double Value, string Outcome, string Source)>();
listener.InstrumentPublished += (instrument, meterListener) =>
{
if (!string.Equals(instrument.Meter.Name, "StellaOps.Policy.Gateway", StringComparison.Ordinal))
{
return;
}
meterListener.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
{
if (instrument.Name != "policy_gateway_activation_requests_total")
{
return;
}
measurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.SetMeasurementEventCallback<double>((instrument, value, tags, state) =>
{
if (instrument.Name != "policy_gateway_activation_latency_ms")
{
return;
}
latencies.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.Start();
metrics.RecordActivation("activated", "service", 42.5);
listener.Dispose();
Assert.Contains(measurements, entry => entry.Value == 1 && entry.Outcome == "activated" && entry.Source == "service");
Assert.Contains(latencies, entry => entry.Outcome == "activated" && entry.Source == "service" && entry.Value == 42.5);
}
private static string GetTag(ReadOnlySpan<KeyValuePair<string, object?>> tags, string key)
{
foreach (var tag in tags)
{
if (string.Equals(tag.Key, key, StringComparison.Ordinal))
{
return tag.Value?.ToString() ?? string.Empty;
}
}
return string.Empty;
}
private static PolicyGatewayOptions CreateGatewayOptions()
{
return new PolicyGatewayOptions
{
PolicyEngine =
{
BaseAddress = "https://policy-engine.test/"
}
};
}
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
{
public TestOptionsMonitor(PolicyGatewayOptions current)
{
CurrentValue = current;
}
public PolicyGatewayOptions CurrentValue { get; }
public PolicyGatewayOptions Get(string? name) => CurrentValue;
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
private sealed class EmptyDisposable : IDisposable
{
public static readonly EmptyDisposable Instance = new();
public void Dispose()
{
}
}
}
private sealed class StubTokenClient : IStellaOpsTokenClient
{
public int RequestCount { get; private set; }
public IReadOnlyDictionary<string, string>? LastAdditionalParameters { get; private set; }
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
RequestCount++;
LastAdditionalParameters = additionalParameters;
return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", DateTimeOffset.UtcNow.AddMinutes(5), Array.Empty<string>()));
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
private sealed class RecordingHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
var payload = JsonSerializer.Serialize(new PolicyRevisionActivationDto("activated", new PolicyRevisionDto(7, "Activated", false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, Array.Empty<PolicyActivationApprovalDto>())));
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
}
private sealed class StubHostEnvironment : IHostEnvironment
{
public string EnvironmentName { get; set; } = "Development";
public string ApplicationName { get; set; } = "PolicyGatewayTests";
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
}
}
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Net;
using System.Net.Http;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Client;
using StellaOps.Policy.Gateway.Clients;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests;
public class PolicyEngineClientTests
{
[Fact]
public async Task ActivateRevision_UsesServiceTokenWhenForwardingContextMissing()
{
var options = CreateGatewayOptions();
options.PolicyEngine.ClientCredentials.Enabled = true;
options.PolicyEngine.ClientCredentials.ClientId = "policy-gateway";
options.PolicyEngine.ClientCredentials.ClientSecret = "secret";
options.PolicyEngine.ClientCredentials.Scopes.Clear();
options.PolicyEngine.ClientCredentials.Scopes.Add("policy:activate");
options.PolicyEngine.BaseAddress = "https://policy-engine.test/";
var optionsMonitor = new TestOptionsMonitor(options);
var tokenClient = new StubTokenClient();
var dpopGenerator = new PolicyGatewayDpopProofGenerator(new StubHostEnvironment(), optionsMonitor, TimeProvider.System, NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
var tokenProvider = new PolicyEngineTokenProvider(tokenClient, optionsMonitor, dpopGenerator, TimeProvider.System, NullLogger<PolicyEngineTokenProvider>.Instance);
using var recordingHandler = new RecordingHandler();
using var httpClient = new HttpClient(recordingHandler)
{
BaseAddress = new Uri(options.PolicyEngine.BaseAddress)
};
var client = new PolicyEngineClient(httpClient, Microsoft.Extensions.Options.Options.Create(options), tokenProvider, NullLogger<PolicyEngineClient>.Instance);
var request = new ActivatePolicyRevisionRequest("comment");
var result = await client.ActivatePolicyRevisionAsync(null, "pack-123", 7, request, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.NotNull(recordingHandler.LastRequest);
var authorization = recordingHandler.LastRequest!.Headers.Authorization;
Assert.NotNull(authorization);
Assert.Equal("Bearer", authorization!.Scheme);
Assert.Equal("service-token", authorization.Parameter);
Assert.Equal(1, tokenClient.RequestCount);
}
[Fact]
public void Metrics_RecordActivation_EmitsExpectedTags()
{
using var metrics = new PolicyGatewayMetrics();
using var listener = new MeterListener();
var measurements = new List<(long Value, string Outcome, string Source)>();
var latencies = new List<(double Value, string Outcome, string Source)>();
listener.InstrumentPublished += (instrument, meterListener) =>
{
if (!string.Equals(instrument.Meter.Name, "StellaOps.Policy.Gateway", StringComparison.Ordinal))
{
return;
}
meterListener.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
{
if (instrument.Name != "policy_gateway_activation_requests_total")
{
return;
}
measurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.SetMeasurementEventCallback<double>((instrument, value, tags, state) =>
{
if (instrument.Name != "policy_gateway_activation_latency_ms")
{
return;
}
latencies.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.Start();
metrics.RecordActivation("activated", "service", 42.5);
listener.Dispose();
Assert.Contains(measurements, entry => entry.Value == 1 && entry.Outcome == "activated" && entry.Source == "service");
Assert.Contains(latencies, entry => entry.Outcome == "activated" && entry.Source == "service" && entry.Value == 42.5);
}
private static string GetTag(ReadOnlySpan<KeyValuePair<string, object?>> tags, string key)
{
foreach (var tag in tags)
{
if (string.Equals(tag.Key, key, StringComparison.Ordinal))
{
return tag.Value?.ToString() ?? string.Empty;
}
}
return string.Empty;
}
private static PolicyGatewayOptions CreateGatewayOptions()
{
return new PolicyGatewayOptions
{
PolicyEngine =
{
BaseAddress = "https://policy-engine.test/"
}
};
}
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
{
public TestOptionsMonitor(PolicyGatewayOptions current)
{
CurrentValue = current;
}
public PolicyGatewayOptions CurrentValue { get; }
public PolicyGatewayOptions Get(string? name) => CurrentValue;
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
private sealed class EmptyDisposable : IDisposable
{
public static readonly EmptyDisposable Instance = new();
public void Dispose()
{
}
}
}
private sealed class StubTokenClient : IStellaOpsTokenClient
{
public int RequestCount { get; private set; }
public IReadOnlyDictionary<string, string>? LastAdditionalParameters { get; private set; }
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
RequestCount++;
LastAdditionalParameters = additionalParameters;
return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", DateTimeOffset.UtcNow.AddMinutes(5), Array.Empty<string>()));
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
private sealed class RecordingHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
var payload = JsonSerializer.Serialize(new PolicyRevisionActivationDto("activated", new PolicyRevisionDto(7, "Activated", false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, Array.Empty<PolicyActivationApprovalDto>())));
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
}
private sealed class StubHostEnvironment : IHostEnvironment
{
public string EnvironmentName { get; set; } = "Development";
public string ApplicationName { get; set; } = "PolicyGatewayTests";
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
}
}

View File

@@ -1,167 +1,167 @@
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests;
public sealed class PolicyGatewayDpopProofGeneratorTests
{
[Fact]
public void CreateProof_Throws_WhenDpopDisabled()
{
var options = CreateGatewayOptions();
options.PolicyEngine.Dpop.Enabled = false;
using var generator = new PolicyGatewayDpopProofGenerator(
new StubHostEnvironment(AppContext.BaseDirectory),
new TestOptionsMonitor(options),
TimeProvider.System,
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
var exception = Assert.Throws<InvalidOperationException>(() =>
generator.CreateProof(HttpMethod.Get, new Uri("https://policy-engine.example/api"), null));
Assert.Equal("DPoP proof requested while DPoP is disabled.", exception.Message);
}
[Fact]
public void CreateProof_Throws_WhenKeyFileMissing()
{
var tempRoot = Directory.CreateTempSubdirectory();
try
{
var options = CreateGatewayOptions();
options.PolicyEngine.Dpop.Enabled = true;
options.PolicyEngine.Dpop.KeyPath = "missing-key.pem";
using var generator = new PolicyGatewayDpopProofGenerator(
new StubHostEnvironment(tempRoot.FullName),
new TestOptionsMonitor(options),
TimeProvider.System,
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
var exception = Assert.Throws<FileNotFoundException>(() =>
generator.CreateProof(HttpMethod.Post, new Uri("https://policy-engine.example/token"), null));
Assert.Contains("missing-key.pem", exception.FileName, StringComparison.Ordinal);
}
finally
{
tempRoot.Delete(recursive: true);
}
}
[Fact]
public void CreateProof_UsesConfiguredAlgorithmAndEmbedsTokenHash()
{
var tempRoot = Directory.CreateTempSubdirectory();
try
{
var keyPath = CreateEcKey(tempRoot, ECCurve.NamedCurves.nistP384);
var options = CreateGatewayOptions();
options.PolicyEngine.Dpop.Enabled = true;
options.PolicyEngine.Dpop.KeyPath = keyPath;
options.PolicyEngine.Dpop.Algorithm = "ES384";
using var generator = new PolicyGatewayDpopProofGenerator(
new StubHostEnvironment(tempRoot.FullName),
new TestOptionsMonitor(options),
TimeProvider.System,
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
const string accessToken = "sample-access-token";
var proof = generator.CreateProof(HttpMethod.Delete, new Uri("https://policy-engine.example/api/resource"), accessToken);
var token = new JwtSecurityTokenHandler().ReadJwtToken(proof);
Assert.Equal("dpop+jwt", token.Header.Typ);
Assert.Equal("ES384", token.Header.Alg);
Assert.Equal("DELETE", token.Payload.TryGetValue("htm", out var method) ? method?.ToString() : null);
Assert.Equal("https://policy-engine.example/api/resource", token.Payload.TryGetValue("htu", out var uri) ? uri?.ToString() : null);
Assert.True(token.Payload.TryGetValue("iat", out var issuedAt));
Assert.True(long.TryParse(Convert.ToString(issuedAt, CultureInfo.InvariantCulture), out var epoch));
Assert.True(epoch > 0);
Assert.True(token.Payload.TryGetValue("jti", out var jti));
Assert.False(string.IsNullOrWhiteSpace(Convert.ToString(jti, CultureInfo.InvariantCulture)));
Assert.True(token.Payload.TryGetValue("ath", out var ath));
var expectedHash = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(accessToken)));
Assert.Equal(expectedHash, ath?.ToString());
}
finally
{
tempRoot.Delete(recursive: true);
}
}
private static PolicyGatewayOptions CreateGatewayOptions()
{
return new PolicyGatewayOptions
{
PolicyEngine =
{
BaseAddress = "https://policy-engine.example"
}
};
}
private static string CreateEcKey(DirectoryInfo directory, ECCurve curve)
{
using var ecdsa = ECDsa.Create(curve);
var privateKey = ecdsa.ExportPkcs8PrivateKey();
var pem = PemEncoding.Write("PRIVATE KEY", privateKey);
var path = Path.Combine(directory.FullName, "policy-gateway-dpop.pem");
File.WriteAllText(path, pem);
return path;
}
private sealed class StubHostEnvironment : IHostEnvironment
{
public StubHostEnvironment(string contentRootPath)
{
ContentRootPath = contentRootPath;
}
public string ApplicationName { get; set; } = "PolicyGatewayTests";
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
public string ContentRootPath { get; set; }
public string EnvironmentName { get; set; } = Environments.Development;
}
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
{
public TestOptionsMonitor(PolicyGatewayOptions current)
{
CurrentValue = current;
}
public PolicyGatewayOptions CurrentValue { get; }
public PolicyGatewayOptions Get(string? name) => CurrentValue;
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
private sealed class EmptyDisposable : IDisposable
{
public static readonly EmptyDisposable Instance = new();
public void Dispose()
{
}
}
}
}
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests;
public sealed class PolicyGatewayDpopProofGeneratorTests
{
[Fact]
public void CreateProof_Throws_WhenDpopDisabled()
{
var options = CreateGatewayOptions();
options.PolicyEngine.Dpop.Enabled = false;
using var generator = new PolicyGatewayDpopProofGenerator(
new StubHostEnvironment(AppContext.BaseDirectory),
new TestOptionsMonitor(options),
TimeProvider.System,
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
var exception = Assert.Throws<InvalidOperationException>(() =>
generator.CreateProof(HttpMethod.Get, new Uri("https://policy-engine.example/api"), null));
Assert.Equal("DPoP proof requested while DPoP is disabled.", exception.Message);
}
[Fact]
public void CreateProof_Throws_WhenKeyFileMissing()
{
var tempRoot = Directory.CreateTempSubdirectory();
try
{
var options = CreateGatewayOptions();
options.PolicyEngine.Dpop.Enabled = true;
options.PolicyEngine.Dpop.KeyPath = "missing-key.pem";
using var generator = new PolicyGatewayDpopProofGenerator(
new StubHostEnvironment(tempRoot.FullName),
new TestOptionsMonitor(options),
TimeProvider.System,
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
var exception = Assert.Throws<FileNotFoundException>(() =>
generator.CreateProof(HttpMethod.Post, new Uri("https://policy-engine.example/token"), null));
Assert.Contains("missing-key.pem", exception.FileName, StringComparison.Ordinal);
}
finally
{
tempRoot.Delete(recursive: true);
}
}
[Fact]
public void CreateProof_UsesConfiguredAlgorithmAndEmbedsTokenHash()
{
var tempRoot = Directory.CreateTempSubdirectory();
try
{
var keyPath = CreateEcKey(tempRoot, ECCurve.NamedCurves.nistP384);
var options = CreateGatewayOptions();
options.PolicyEngine.Dpop.Enabled = true;
options.PolicyEngine.Dpop.KeyPath = keyPath;
options.PolicyEngine.Dpop.Algorithm = "ES384";
using var generator = new PolicyGatewayDpopProofGenerator(
new StubHostEnvironment(tempRoot.FullName),
new TestOptionsMonitor(options),
TimeProvider.System,
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
const string accessToken = "sample-access-token";
var proof = generator.CreateProof(HttpMethod.Delete, new Uri("https://policy-engine.example/api/resource"), accessToken);
var token = new JwtSecurityTokenHandler().ReadJwtToken(proof);
Assert.Equal("dpop+jwt", token.Header.Typ);
Assert.Equal("ES384", token.Header.Alg);
Assert.Equal("DELETE", token.Payload.TryGetValue("htm", out var method) ? method?.ToString() : null);
Assert.Equal("https://policy-engine.example/api/resource", token.Payload.TryGetValue("htu", out var uri) ? uri?.ToString() : null);
Assert.True(token.Payload.TryGetValue("iat", out var issuedAt));
Assert.True(long.TryParse(Convert.ToString(issuedAt, CultureInfo.InvariantCulture), out var epoch));
Assert.True(epoch > 0);
Assert.True(token.Payload.TryGetValue("jti", out var jti));
Assert.False(string.IsNullOrWhiteSpace(Convert.ToString(jti, CultureInfo.InvariantCulture)));
Assert.True(token.Payload.TryGetValue("ath", out var ath));
var expectedHash = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(accessToken)));
Assert.Equal(expectedHash, ath?.ToString());
}
finally
{
tempRoot.Delete(recursive: true);
}
}
private static PolicyGatewayOptions CreateGatewayOptions()
{
return new PolicyGatewayOptions
{
PolicyEngine =
{
BaseAddress = "https://policy-engine.example"
}
};
}
private static string CreateEcKey(DirectoryInfo directory, ECCurve curve)
{
using var ecdsa = ECDsa.Create(curve);
var privateKey = ecdsa.ExportPkcs8PrivateKey();
var pem = PemEncoding.Write("PRIVATE KEY", privateKey);
var path = Path.Combine(directory.FullName, "policy-gateway-dpop.pem");
File.WriteAllText(path, pem);
return path;
}
private sealed class StubHostEnvironment : IHostEnvironment
{
public StubHostEnvironment(string contentRootPath)
{
ContentRootPath = contentRootPath;
}
public string ApplicationName { get; set; } = "PolicyGatewayTests";
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
public string ContentRootPath { get; set; }
public string EnvironmentName { get; set; } = Environments.Development;
}
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
{
public TestOptionsMonitor(PolicyGatewayOptions current)
{
CurrentValue = current;
}
public PolicyGatewayOptions CurrentValue { get; }
public PolicyGatewayOptions Get(string? name) => CurrentValue;
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
private sealed class EmptyDisposable : IDisposable
{
public static readonly EmptyDisposable Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -1,32 +1,32 @@
using System;
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace StellaOps.Policy.Tests;
public sealed class PolicyBinderTests
{
[Fact]
public void Bind_ValidYaml_ReturnsSuccess()
{
const string yaml = """
version: "1.0"
rules:
- name: Block Critical
severity: [Critical]
sources: [NVD]
action: block
""";
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
Assert.True(result.Success);
Assert.Equal("1.0", result.Document.Version);
Assert.Single(result.Document.Rules);
Assert.Empty(result.Issues);
namespace StellaOps.Policy.Tests;
public sealed class PolicyBinderTests
{
[Fact]
public void Bind_ValidYaml_ReturnsSuccess()
{
const string yaml = """
version: "1.0"
rules:
- name: Block Critical
severity: [Critical]
sources: [NVD]
action: block
""";
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
Assert.True(result.Success);
Assert.Equal("1.0", result.Document.Version);
Assert.Single(result.Document.Rules);
Assert.Empty(result.Issues);
}
[Fact]
@@ -99,59 +99,59 @@ public sealed class PolicyBinderTests
Assert.Contains(result.Issues, issue => issue.Code == "policy.exceptions.effect.downgrade.missingSeverity");
}
[Fact]
public void Bind_InvalidSeverity_ReturnsError()
{
const string yaml = """
version: "1.0"
rules:
- name: Invalid Severity
severity: [Nope]
action: block
""";
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
Assert.False(result.Success);
Assert.Contains(result.Issues, issue => issue.Code == "policy.severity.invalid");
}
[Fact]
public async Task Cli_StrictMode_FailsOnWarnings()
{
const string yaml = """
version: "1.0"
rules:
- name: Quiet Warning
sources: ["", "NVD"]
action: ignore
""";
var path = Path.Combine(Path.GetTempPath(), $"policy-{Guid.NewGuid():N}.yaml");
await File.WriteAllTextAsync(path, yaml);
try
{
using var output = new StringWriter();
using var error = new StringWriter();
var cli = new PolicyValidationCli(output, error);
var options = new PolicyValidationCliOptions
{
Inputs = new[] { path },
Strict = true,
};
var exitCode = await cli.RunAsync(options, CancellationToken.None);
Assert.Equal(2, exitCode);
Assert.Contains("WARNING", output.ToString());
}
finally
{
if (File.Exists(path))
{
File.Delete(path);
}
}
}
}
[Fact]
public void Bind_InvalidSeverity_ReturnsError()
{
const string yaml = """
version: "1.0"
rules:
- name: Invalid Severity
severity: [Nope]
action: block
""";
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
Assert.False(result.Success);
Assert.Contains(result.Issues, issue => issue.Code == "policy.severity.invalid");
}
[Fact]
public async Task Cli_StrictMode_FailsOnWarnings()
{
const string yaml = """
version: "1.0"
rules:
- name: Quiet Warning
sources: ["", "NVD"]
action: ignore
""";
var path = Path.Combine(Path.GetTempPath(), $"policy-{Guid.NewGuid():N}.yaml");
await File.WriteAllTextAsync(path, yaml);
try
{
using var output = new StringWriter();
using var error = new StringWriter();
var cli = new PolicyValidationCli(output, error);
var options = new PolicyValidationCliOptions
{
Inputs = new[] { path },
Strict = true,
};
var exitCode = await cli.RunAsync(options, CancellationToken.None);
Assert.Equal(2, exitCode);
Assert.Contains("WARNING", output.ToString());
}
finally
{
if (File.Exists(path))
{
File.Delete(path);
}
}
}
}

View File

@@ -1,41 +1,41 @@
using System.Collections.Immutable;
using Xunit;
namespace StellaOps.Policy.Tests;
public sealed class PolicyEvaluationTests
{
[Fact]
public void EvaluateFinding_AppliesTrustAndReachabilityWeights()
{
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
var rule = PolicyRule.Create(
"BlockMedium",
action,
ImmutableArray.Create(PolicySeverity.Medium),
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
PolicyRuleMatchCriteria.Empty,
expires: null,
justification: null);
using System.Collections.Immutable;
using Xunit;
namespace StellaOps.Policy.Tests;
public sealed class PolicyEvaluationTests
{
[Fact]
public void EvaluateFinding_AppliesTrustAndReachabilityWeights()
{
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
var rule = PolicyRule.Create(
"BlockMedium",
action,
ImmutableArray.Create(PolicySeverity.Medium),
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
PolicyRuleMatchCriteria.Empty,
expires: null,
justification: null);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(
"finding-medium",
PolicySeverity.Medium,
source: "community",
tags: ImmutableArray.Create("reachability:indirect"));
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(
"finding-medium",
PolicySeverity.Medium,
source: "community",
tags: ImmutableArray.Create("reachability:indirect"));
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding, out var explanation);
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
Assert.Equal(19.5, verdict.Score, 3);
@@ -48,43 +48,43 @@ public sealed class PolicyEvaluationTests
Assert.NotNull(explanation);
Assert.Equal(PolicyVerdictStatus.Blocked, explanation!.Decision);
Assert.Equal("BlockMedium", explanation.RuleName);
}
[Fact]
public void EvaluateFinding_QuietWithRequireVexAppliesQuietPenalty()
{
var ignoreOptions = new PolicyIgnoreOptions(null, null);
var requireVexOptions = new PolicyRequireVexOptions(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty);
var action = new PolicyAction(PolicyActionType.Ignore, ignoreOptions, null, requireVexOptions, true);
var rule = PolicyRule.Create(
"QuietIgnore",
action,
ImmutableArray.Create(PolicySeverity.Critical),
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
PolicyRuleMatchCriteria.Empty,
expires: null,
justification: null);
}
[Fact]
public void EvaluateFinding_QuietWithRequireVexAppliesQuietPenalty()
{
var ignoreOptions = new PolicyIgnoreOptions(null, null);
var requireVexOptions = new PolicyRequireVexOptions(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty);
var action = new PolicyAction(PolicyActionType.Ignore, ignoreOptions, null, requireVexOptions, true);
var rule = PolicyRule.Create(
"QuietIgnore",
action,
ImmutableArray.Create(PolicySeverity.Critical),
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
PolicyRuleMatchCriteria.Empty,
expires: null,
justification: null);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(
"finding-critical",
PolicySeverity.Critical,
tags: ImmutableArray.Create("reachability:entrypoint"));
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(
"finding-critical",
PolicySeverity.Critical,
tags: ImmutableArray.Create("reachability:entrypoint"));
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding, out var explanation);
Assert.Equal(PolicyVerdictStatus.Ignored, verdict.Status);
Assert.True(verdict.Quiet);
Assert.Equal("QuietIgnore", verdict.QuietedBy);
@@ -97,39 +97,39 @@ public sealed class PolicyEvaluationTests
Assert.NotNull(explanation);
Assert.Equal(PolicyVerdictStatus.Ignored, explanation!.Decision);
}
[Fact]
public void EvaluateFinding_UnknownSeverityComputesConfidence()
{
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
var rule = PolicyRule.Create(
"BlockUnknown",
action,
ImmutableArray.Create(PolicySeverity.Unknown),
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
PolicyRuleMatchCriteria.Empty,
expires: null,
justification: null);
}
[Fact]
public void EvaluateFinding_UnknownSeverityComputesConfidence()
{
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
var rule = PolicyRule.Create(
"BlockUnknown",
action,
ImmutableArray.Create(PolicySeverity.Unknown),
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
PolicyRuleMatchCriteria.Empty,
expires: null,
justification: null);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(
"finding-unknown",
PolicySeverity.Unknown,
tags: ImmutableArray.Create("reachability:unknown", "unknown-age-days:5"));
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(
"finding-unknown",
PolicySeverity.Unknown,
tags: ImmutableArray.Create("reachability:unknown", "unknown-age-days:5"));
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding, out var explanation);
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
Assert.Equal(30, verdict.Score, 3); // 60 * 1 * 0.5
Assert.Equal(0.55, verdict.UnknownConfidence ?? 0, 3);

View File

@@ -1,185 +1,185 @@
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Policy.Tests;
public sealed class PolicyPreviewServiceTests
{
private readonly ITestOutputHelper _output;
public PolicyPreviewServiceTests(ITestOutputHelper output)
{
_output = output ?? throw new ArgumentNullException(nameof(output));
}
[Fact]
public async Task PreviewAsync_ComputesDiffs_ForBlockingRule()
{
const string yaml = """
version: "1.0"
rules:
- name: Block Critical
severity: [Critical]
action: block
""";
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var timeProvider = new FakeTimeProvider();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, null), CancellationToken.None);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
var findings = ImmutableArray.Create(
PolicyFinding.Create("finding-1", PolicySeverity.Critical, environment: "prod", source: "NVD"),
PolicyFinding.Create("finding-2", PolicySeverity.Low));
var baseline = ImmutableArray.Create(
new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass),
new PolicyVerdict("finding-2", PolicyVerdictStatus.Pass));
var response = await service.PreviewAsync(new PolicyPreviewRequest(
"sha256:abc",
findings,
baseline),
CancellationToken.None);
Assert.True(response.Success);
Assert.Equal(1, response.ChangedCount);
var diff1 = Assert.Single(response.Diffs.Where(diff => diff.Projected.FindingId == "finding-1"));
Assert.Equal(PolicyVerdictStatus.Pass, diff1.Baseline.Status);
Assert.Equal(PolicyVerdictStatus.Blocked, diff1.Projected.Status);
Assert.Equal("Block Critical", diff1.Projected.RuleName);
Assert.True(diff1.Projected.Score > 0);
Assert.Equal(PolicyScoringConfig.Default.Version, diff1.Projected.ConfigVersion);
Assert.Equal(PolicyVerdictStatus.Pass, response.Diffs.First(diff => diff.Projected.FindingId == "finding-2").Projected.Status);
}
[Fact]
public async Task PreviewAsync_UsesProposedPolicy_WhenProvided()
{
const string yaml = """
version: "1.0"
rules:
- name: Ignore Dev
environments: [dev]
action:
type: ignore
justification: dev waiver
""";
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
var findings = ImmutableArray.Create(
PolicyFinding.Create("finding-1", PolicySeverity.Medium, environment: "dev"));
var baseline = ImmutableArray.Create(new PolicyVerdict("finding-1", PolicyVerdictStatus.Blocked));
var response = await service.PreviewAsync(new PolicyPreviewRequest(
"sha256:def",
findings,
baseline,
SnapshotOverride: null,
ProposedPolicy: new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "dev override")),
CancellationToken.None);
Assert.True(response.Success);
var diff = Assert.Single(response.Diffs);
Assert.Equal(PolicyVerdictStatus.Blocked, diff.Baseline.Status);
Assert.Equal(PolicyVerdictStatus.Ignored, diff.Projected.Status);
Assert.Equal("Ignore Dev", diff.Projected.RuleName);
Assert.True(diff.Projected.Score >= 0);
Assert.Equal(1, response.ChangedCount);
}
[Fact]
public async Task PreviewAsync_ReturnsIssues_WhenPolicyInvalid()
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
const string invalid = "version: 1.0";
var request = new PolicyPreviewRequest(
"sha256:ghi",
ImmutableArray<PolicyFinding>.Empty,
ImmutableArray<PolicyVerdict>.Empty,
SnapshotOverride: null,
ProposedPolicy: new PolicySnapshotContent(invalid, PolicyDocumentFormat.Yaml, null, null, null));
var response = await service.PreviewAsync(request, CancellationToken.None);
Assert.False(response.Success);
Assert.NotEmpty(response.Issues);
}
[Fact]
public async Task PreviewAsync_QuietWithoutVexDowngradesToWarn()
{
const string yaml = """
version: "1.0"
rules:
- name: Quiet Without VEX
severity: [Low]
quiet: true
action:
type: ignore
""";
var binding = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
if (!binding.Success)
{
foreach (var issue in binding.Issues)
{
_output.WriteLine($"{issue.Severity} {issue.Code} {issue.Path} :: {issue.Message}");
}
var parseMethod = typeof(PolicyBinder).GetMethod("ParseToNode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
var node = (System.Text.Json.Nodes.JsonNode?)parseMethod?.Invoke(null, new object[] { yaml, PolicyDocumentFormat.Yaml });
_output.WriteLine(node?.ToJsonString() ?? "<null>");
}
Assert.True(binding.Success);
Assert.Empty(binding.Issues);
Assert.False(binding.Document.Rules[0].Metadata.ContainsKey("quiet"));
Assert.True(binding.Document.Rules[0].Action.Quiet);
var store = new PolicySnapshotStore(new InMemoryPolicySnapshotRepository(), new InMemoryPolicyAuditRepository(), TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "quiet test"), CancellationToken.None);
var snapshot = await store.GetLatestAsync();
Assert.NotNull(snapshot);
Assert.True(snapshot!.Document.Rules[0].Action.Quiet);
Assert.Null(snapshot.Document.Rules[0].Action.RequireVex);
Assert.Equal(PolicyActionType.Ignore, snapshot.Document.Rules[0].Action.Type);
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Policy.Tests;
public sealed class PolicyPreviewServiceTests
{
private readonly ITestOutputHelper _output;
public PolicyPreviewServiceTests(ITestOutputHelper output)
{
_output = output ?? throw new ArgumentNullException(nameof(output));
}
[Fact]
public async Task PreviewAsync_ComputesDiffs_ForBlockingRule()
{
const string yaml = """
version: "1.0"
rules:
- name: Block Critical
severity: [Critical]
action: block
""";
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var timeProvider = new FakeTimeProvider();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, null), CancellationToken.None);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
var findings = ImmutableArray.Create(
PolicyFinding.Create("finding-1", PolicySeverity.Critical, environment: "prod", source: "NVD"),
PolicyFinding.Create("finding-2", PolicySeverity.Low));
var baseline = ImmutableArray.Create(
new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass),
new PolicyVerdict("finding-2", PolicyVerdictStatus.Pass));
var response = await service.PreviewAsync(new PolicyPreviewRequest(
"sha256:abc",
findings,
baseline),
CancellationToken.None);
Assert.True(response.Success);
Assert.Equal(1, response.ChangedCount);
var diff1 = Assert.Single(response.Diffs.Where(diff => diff.Projected.FindingId == "finding-1"));
Assert.Equal(PolicyVerdictStatus.Pass, diff1.Baseline.Status);
Assert.Equal(PolicyVerdictStatus.Blocked, diff1.Projected.Status);
Assert.Equal("Block Critical", diff1.Projected.RuleName);
Assert.True(diff1.Projected.Score > 0);
Assert.Equal(PolicyScoringConfig.Default.Version, diff1.Projected.ConfigVersion);
Assert.Equal(PolicyVerdictStatus.Pass, response.Diffs.First(diff => diff.Projected.FindingId == "finding-2").Projected.Status);
}
[Fact]
public async Task PreviewAsync_UsesProposedPolicy_WhenProvided()
{
const string yaml = """
version: "1.0"
rules:
- name: Ignore Dev
environments: [dev]
action:
type: ignore
justification: dev waiver
""";
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
var findings = ImmutableArray.Create(
PolicyFinding.Create("finding-1", PolicySeverity.Medium, environment: "dev"));
var baseline = ImmutableArray.Create(new PolicyVerdict("finding-1", PolicyVerdictStatus.Blocked));
var response = await service.PreviewAsync(new PolicyPreviewRequest(
"sha256:def",
findings,
baseline,
SnapshotOverride: null,
ProposedPolicy: new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "dev override")),
CancellationToken.None);
Assert.True(response.Success);
var diff = Assert.Single(response.Diffs);
Assert.Equal(PolicyVerdictStatus.Blocked, diff.Baseline.Status);
Assert.Equal(PolicyVerdictStatus.Ignored, diff.Projected.Status);
Assert.Equal("Ignore Dev", diff.Projected.RuleName);
Assert.True(diff.Projected.Score >= 0);
Assert.Equal(1, response.ChangedCount);
}
[Fact]
public async Task PreviewAsync_ReturnsIssues_WhenPolicyInvalid()
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
const string invalid = "version: 1.0";
var request = new PolicyPreviewRequest(
"sha256:ghi",
ImmutableArray<PolicyFinding>.Empty,
ImmutableArray<PolicyVerdict>.Empty,
SnapshotOverride: null,
ProposedPolicy: new PolicySnapshotContent(invalid, PolicyDocumentFormat.Yaml, null, null, null));
var response = await service.PreviewAsync(request, CancellationToken.None);
Assert.False(response.Success);
Assert.NotEmpty(response.Issues);
}
[Fact]
public async Task PreviewAsync_QuietWithoutVexDowngradesToWarn()
{
const string yaml = """
version: "1.0"
rules:
- name: Quiet Without VEX
severity: [Low]
quiet: true
action:
type: ignore
""";
var binding = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
if (!binding.Success)
{
foreach (var issue in binding.Issues)
{
_output.WriteLine($"{issue.Severity} {issue.Code} {issue.Path} :: {issue.Message}");
}
var parseMethod = typeof(PolicyBinder).GetMethod("ParseToNode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
var node = (System.Text.Json.Nodes.JsonNode?)parseMethod?.Invoke(null, new object[] { yaml, PolicyDocumentFormat.Yaml });
_output.WriteLine(node?.ToJsonString() ?? "<null>");
}
Assert.True(binding.Success);
Assert.Empty(binding.Issues);
Assert.False(binding.Document.Rules[0].Metadata.ContainsKey("quiet"));
Assert.True(binding.Document.Rules[0].Action.Quiet);
var store = new PolicySnapshotStore(new InMemoryPolicySnapshotRepository(), new InMemoryPolicyAuditRepository(), TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "quiet test"), CancellationToken.None);
var snapshot = await store.GetLatestAsync();
Assert.NotNull(snapshot);
Assert.True(snapshot!.Document.Rules[0].Action.Quiet);
Assert.Null(snapshot.Document.Rules[0].Action.RequireVex);
Assert.Equal(PolicyActionType.Ignore, snapshot.Document.Rules[0].Action.Type);
var manualVerdict = PolicyEvaluation.EvaluateFinding(snapshot.Document, snapshot.ScoringConfig, PolicyFinding.Create("finding-quiet", PolicySeverity.Low), out _);
Assert.Equal(PolicyVerdictStatus.Warned, manualVerdict.Status);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
var findings = ImmutableArray.Create(PolicyFinding.Create("finding-quiet", PolicySeverity.Low));
var baseline = ImmutableArray<PolicyVerdict>.Empty;
var response = await service.PreviewAsync(new PolicyPreviewRequest(
"sha256:quiet",
findings,
baseline),
CancellationToken.None);
Assert.True(response.Success);
var verdict = Assert.Single(response.Diffs).Projected;
Assert.Equal(PolicyVerdictStatus.Warned, verdict.Status);
Assert.Contains("requireVex", verdict.Notes, System.StringComparison.OrdinalIgnoreCase);
Assert.True(verdict.Score >= 0);
}
}
Assert.Equal(PolicyVerdictStatus.Warned, manualVerdict.Status);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
var findings = ImmutableArray.Create(PolicyFinding.Create("finding-quiet", PolicySeverity.Low));
var baseline = ImmutableArray<PolicyVerdict>.Empty;
var response = await service.PreviewAsync(new PolicyPreviewRequest(
"sha256:quiet",
findings,
baseline),
CancellationToken.None);
Assert.True(response.Success);
var verdict = Assert.Single(response.Diffs).Projected;
Assert.Equal(PolicyVerdictStatus.Warned, verdict.Status);
Assert.Contains("requireVex", verdict.Notes, System.StringComparison.OrdinalIgnoreCase);
Assert.True(verdict.Score >= 0);
}
}

View File

@@ -1,66 +1,66 @@
using System;
using System.IO;
using Xunit;
namespace StellaOps.Policy.Tests;
public sealed class PolicyScoringConfigTests
{
[Fact]
public void LoadDefaultReturnsConfig()
{
var config = PolicyScoringConfigBinder.LoadDefault();
Assert.NotNull(config);
Assert.Equal("1.0", config.Version);
Assert.NotEmpty(config.SeverityWeights);
Assert.True(config.SeverityWeights.ContainsKey(PolicySeverity.Critical));
Assert.True(config.QuietPenalty > 0);
Assert.NotEmpty(config.ReachabilityBuckets);
Assert.Contains("entrypoint", config.ReachabilityBuckets.Keys);
Assert.False(config.UnknownConfidence.Bands.IsDefaultOrEmpty);
Assert.Equal("high", config.UnknownConfidence.Bands[0].Name);
}
[Fact]
public void BindRejectsEmptyContent()
{
var result = PolicyScoringConfigBinder.Bind(string.Empty, PolicyDocumentFormat.Json);
Assert.False(result.Success);
Assert.NotEmpty(result.Issues);
}
[Fact]
public void BindRejectsInvalidSchema()
{
const string json = """
{
"version": "1.0",
"severityWeights": {
"Critical": 90.0
}
}
""";
var result = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
Assert.False(result.Success);
Assert.Contains(result.Issues, issue => issue.Code.StartsWith("scoring.schema", StringComparison.OrdinalIgnoreCase));
Assert.Null(result.Config);
}
[Fact]
public void DefaultResourceDigestMatchesGolden()
{
var assembly = typeof(PolicyScoringConfig).Assembly;
using var stream = assembly.GetManifestResourceStream("StellaOps.Policy.Schemas.policy-scoring-default.json")
?? throw new InvalidOperationException("Unable to locate embedded scoring default resource.");
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var binding = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
Assert.True(binding.Success);
Assert.NotNull(binding.Config);
var digest = PolicyScoringConfigDigest.Compute(binding.Config!);
Assert.Equal("5ef2e43a112cb00753beb7811dd2e1720f2385e2289d0fb6abcf7bbbb8cda2d2", digest);
}
}
using System;
using System.IO;
using Xunit;
namespace StellaOps.Policy.Tests;
public sealed class PolicyScoringConfigTests
{
[Fact]
public void LoadDefaultReturnsConfig()
{
var config = PolicyScoringConfigBinder.LoadDefault();
Assert.NotNull(config);
Assert.Equal("1.0", config.Version);
Assert.NotEmpty(config.SeverityWeights);
Assert.True(config.SeverityWeights.ContainsKey(PolicySeverity.Critical));
Assert.True(config.QuietPenalty > 0);
Assert.NotEmpty(config.ReachabilityBuckets);
Assert.Contains("entrypoint", config.ReachabilityBuckets.Keys);
Assert.False(config.UnknownConfidence.Bands.IsDefaultOrEmpty);
Assert.Equal("high", config.UnknownConfidence.Bands[0].Name);
}
[Fact]
public void BindRejectsEmptyContent()
{
var result = PolicyScoringConfigBinder.Bind(string.Empty, PolicyDocumentFormat.Json);
Assert.False(result.Success);
Assert.NotEmpty(result.Issues);
}
[Fact]
public void BindRejectsInvalidSchema()
{
const string json = """
{
"version": "1.0",
"severityWeights": {
"Critical": 90.0
}
}
""";
var result = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
Assert.False(result.Success);
Assert.Contains(result.Issues, issue => issue.Code.StartsWith("scoring.schema", StringComparison.OrdinalIgnoreCase));
Assert.Null(result.Config);
}
[Fact]
public void DefaultResourceDigestMatchesGolden()
{
var assembly = typeof(PolicyScoringConfig).Assembly;
using var stream = assembly.GetManifestResourceStream("StellaOps.Policy.Schemas.policy-scoring-default.json")
?? throw new InvalidOperationException("Unable to locate embedded scoring default resource.");
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var binding = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
Assert.True(binding.Success);
Assert.NotNull(binding.Config);
var digest = PolicyScoringConfigDigest.Compute(binding.Config!);
Assert.Equal("5ef2e43a112cb00753beb7811dd2e1720f2385e2289d0fb6abcf7bbbb8cda2d2", digest);
}
}

View File

@@ -1,94 +1,94 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Xunit;
namespace StellaOps.Policy.Tests;
public sealed class PolicySnapshotStoreTests
{
private const string BasePolicyYaml = """
version: "1.0"
rules:
- name: Block Critical
severity: [Critical]
action: block
""";
[Fact]
public async Task SaveAsync_CreatesNewSnapshotAndAuditEntry()
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
var result = await store.SaveAsync(content, CancellationToken.None);
Assert.True(result.Success);
Assert.True(result.Created);
Assert.NotNull(result.Snapshot);
Assert.Equal("rev-1", result.Snapshot!.RevisionId);
Assert.Equal(result.Digest, result.Snapshot.Digest);
Assert.Equal(timeProvider.GetUtcNow(), result.Snapshot.CreatedAt);
Assert.Equal(PolicyScoringConfig.Default.Version, result.Snapshot.ScoringConfig.Version);
var latest = await store.GetLatestAsync();
Assert.Equal(result.Snapshot, latest);
var audits = await auditRepo.ListAsync(10);
Assert.Single(audits);
Assert.Equal(result.Digest, audits[0].Digest);
Assert.Equal("snapshot.created", audits[0].Action);
Assert.Equal("rev-1", audits[0].RevisionId);
}
[Fact]
public async Task SaveAsync_DoesNotCreateNewRevisionWhenDigestUnchanged()
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
var first = await store.SaveAsync(content, CancellationToken.None);
Assert.True(first.Created);
timeProvider.Advance(TimeSpan.FromHours(1));
var second = await store.SaveAsync(content, CancellationToken.None);
Assert.True(second.Success);
Assert.False(second.Created);
Assert.Equal(first.Digest, second.Digest);
Assert.Equal("rev-1", second.Snapshot!.RevisionId);
Assert.Equal(PolicyScoringConfig.Default.Version, second.Snapshot.ScoringConfig.Version);
var audits = await auditRepo.ListAsync(10);
Assert.Single(audits);
}
[Fact]
public async Task SaveAsync_ReturnsFailureWhenValidationFails()
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
const string invalidYaml = "version: '1.0'\nrules: []";
var content = new PolicySnapshotContent(invalidYaml, PolicyDocumentFormat.Yaml, null, null, null);
var result = await store.SaveAsync(content, CancellationToken.None);
Assert.False(result.Success);
Assert.False(result.Created);
Assert.Null(result.Snapshot);
var audits = await auditRepo.ListAsync(5);
Assert.Empty(audits);
}
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Xunit;
namespace StellaOps.Policy.Tests;
public sealed class PolicySnapshotStoreTests
{
private const string BasePolicyYaml = """
version: "1.0"
rules:
- name: Block Critical
severity: [Critical]
action: block
""";
[Fact]
public async Task SaveAsync_CreatesNewSnapshotAndAuditEntry()
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
var result = await store.SaveAsync(content, CancellationToken.None);
Assert.True(result.Success);
Assert.True(result.Created);
Assert.NotNull(result.Snapshot);
Assert.Equal("rev-1", result.Snapshot!.RevisionId);
Assert.Equal(result.Digest, result.Snapshot.Digest);
Assert.Equal(timeProvider.GetUtcNow(), result.Snapshot.CreatedAt);
Assert.Equal(PolicyScoringConfig.Default.Version, result.Snapshot.ScoringConfig.Version);
var latest = await store.GetLatestAsync();
Assert.Equal(result.Snapshot, latest);
var audits = await auditRepo.ListAsync(10);
Assert.Single(audits);
Assert.Equal(result.Digest, audits[0].Digest);
Assert.Equal("snapshot.created", audits[0].Action);
Assert.Equal("rev-1", audits[0].RevisionId);
}
[Fact]
public async Task SaveAsync_DoesNotCreateNewRevisionWhenDigestUnchanged()
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
var first = await store.SaveAsync(content, CancellationToken.None);
Assert.True(first.Created);
timeProvider.Advance(TimeSpan.FromHours(1));
var second = await store.SaveAsync(content, CancellationToken.None);
Assert.True(second.Success);
Assert.False(second.Created);
Assert.Equal(first.Digest, second.Digest);
Assert.Equal("rev-1", second.Snapshot!.RevisionId);
Assert.Equal(PolicyScoringConfig.Default.Version, second.Snapshot.ScoringConfig.Version);
var audits = await auditRepo.ListAsync(10);
Assert.Single(audits);
}
[Fact]
public async Task SaveAsync_ReturnsFailureWhenValidationFails()
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
const string invalidYaml = "version: '1.0'\nrules: []";
var content = new PolicySnapshotContent(invalidYaml, PolicyDocumentFormat.Yaml, null, null, null);
var result = await store.SaveAsync(content, CancellationToken.None);
Assert.False(result.Success);
Assert.False(result.Created);
Assert.Null(result.Snapshot);
var audits = await auditRepo.ListAsync(5);
Assert.Empty(audits);
}
}