Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -1,29 +1,29 @@
|
||||
# StellaOps.Policy.Engine — Agent Charter
|
||||
|
||||
## Mission
|
||||
Stand up the Policy Engine runtime host that evaluates organization policies against SBOM/advisory/VEX inputs with deterministic, replayable results. Deliver the API/worker orchestration, materialization writers, and observability stack described in Epic 2 (Policy Engine v2).
|
||||
|
||||
## Scope
|
||||
- Minimal API host & background workers for policy runs (full, incremental, simulate).
|
||||
- Mongo persistence for `policies`, `policy_runs`, and `effective_finding_*` collections.
|
||||
- Change stream listeners and scheduler integration for incremental re-evaluation.
|
||||
- Authority integration enforcing new `policy:*` and `effective:write` scopes.
|
||||
- Observability: metrics, traces, structured logs, trace sampling.
|
||||
|
||||
## Expectations
|
||||
- Keep endpoints deterministic, cancellation-aware, and tenant-scoped.
|
||||
- Only Policy Engine identity performs writes to effective findings.
|
||||
- Coordinate with Concelier/Excititor/Scheduler guilds for linkset joins and orchestration inputs.
|
||||
- Update `TASKS.md`, `/docs/implplan/SPRINT_*.md` when status changes.
|
||||
- Maintain compliance checklists and schema docs alongside code updates.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/policy/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||
# StellaOps.Policy.Engine — Agent Charter
|
||||
|
||||
## Mission
|
||||
Stand up the Policy Engine runtime host that evaluates organization policies against SBOM/advisory/VEX inputs with deterministic, replayable results. Deliver the API/worker orchestration, materialization writers, and observability stack described in Epic 2 (Policy Engine v2).
|
||||
|
||||
## Scope
|
||||
- Minimal API host & background workers for policy runs (full, incremental, simulate).
|
||||
- Mongo persistence for `policies`, `policy_runs`, and `effective_finding_*` collections.
|
||||
- Change stream listeners and scheduler integration for incremental re-evaluation.
|
||||
- Authority integration enforcing new `policy:*` and `effective:write` scopes.
|
||||
- Observability: metrics, traces, structured logs, trace sampling.
|
||||
|
||||
## Expectations
|
||||
- Keep endpoints deterministic, cancellation-aware, and tenant-scoped.
|
||||
- Only Policy Engine identity performs writes to effective findings.
|
||||
- Coordinate with Concelier/Excititor/Scheduler guilds for linkset joins and orchestration inputs.
|
||||
- Update `TASKS.md`, `/docs/implplan/SPRINT_*.md` when status changes.
|
||||
- Maintain compliance checklists and schema docs alongside code updates.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/policy/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||
|
||||
@@ -1,283 +1,283 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Compilation;
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic complexity metrics for compiled policies.
|
||||
/// </summary>
|
||||
internal sealed class PolicyComplexityAnalyzer
|
||||
{
|
||||
public PolicyComplexityReport Analyze(PolicyIrDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var metrics = new ComplexityMetrics();
|
||||
metrics.RuleCount = document.Rules.IsDefault ? 0 : document.Rules.Length;
|
||||
|
||||
VisitMetadata(document.Metadata.Values, metrics);
|
||||
VisitMetadata(document.Settings.Values, metrics);
|
||||
VisitProfiles(document.Profiles, metrics);
|
||||
|
||||
if (!document.Rules.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
metrics.ConditionCount++;
|
||||
VisitExpression(rule.When, metrics, depth: 0);
|
||||
|
||||
VisitActions(rule.ThenActions, metrics);
|
||||
VisitActions(rule.ElseActions, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
var score = CalculateScore(metrics);
|
||||
var roundedScore = Math.Round(score, 3, MidpointRounding.AwayFromZero);
|
||||
|
||||
return new PolicyComplexityReport(
|
||||
roundedScore,
|
||||
metrics.RuleCount,
|
||||
metrics.ActionCount,
|
||||
metrics.ExpressionCount,
|
||||
metrics.InvocationCount,
|
||||
metrics.MemberAccessCount,
|
||||
metrics.IdentifierCount,
|
||||
metrics.LiteralCount,
|
||||
metrics.MaxDepth,
|
||||
metrics.ProfileCount,
|
||||
metrics.ProfileBindings,
|
||||
metrics.ConditionCount,
|
||||
metrics.ListItems);
|
||||
}
|
||||
|
||||
private static void VisitProfiles(ImmutableArray<PolicyIrProfile> profiles, ComplexityMetrics metrics)
|
||||
{
|
||||
if (profiles.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
metrics.ProfileCount++;
|
||||
|
||||
if (!profile.Maps.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var map in profile.Maps)
|
||||
{
|
||||
if (map.Entries.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in map.Entries)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
metrics.LiteralCount++; // weight values contribute to literal count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!profile.Environments.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var environment in profile.Environments)
|
||||
{
|
||||
if (environment.Entries.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in environment.Entries)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
metrics.ConditionCount++;
|
||||
VisitExpression(entry.Condition, metrics, depth: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!profile.Scalars.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var scalar in profile.Scalars)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
VisitLiteral(scalar.Value, metrics);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitMetadata(IEnumerable<PolicyIrLiteral> literals, ComplexityMetrics metrics)
|
||||
{
|
||||
foreach (var literal in literals)
|
||||
{
|
||||
VisitLiteral(literal, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitLiteral(PolicyIrLiteral literal, ComplexityMetrics metrics)
|
||||
{
|
||||
switch (literal)
|
||||
{
|
||||
case PolicyIrListLiteral list when !list.Items.IsDefaultOrEmpty:
|
||||
foreach (var item in list.Items)
|
||||
{
|
||||
VisitLiteral(item, metrics);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
metrics.LiteralCount++;
|
||||
}
|
||||
|
||||
private static void VisitActions(ImmutableArray<PolicyIrAction> actions, ComplexityMetrics metrics)
|
||||
{
|
||||
if (actions.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
metrics.ActionCount++;
|
||||
switch (action)
|
||||
{
|
||||
case PolicyIrAssignmentAction assignment:
|
||||
VisitExpression(assignment.Value, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrAnnotateAction annotate:
|
||||
VisitExpression(annotate.Value, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrIgnoreAction ignore when ignore.Until is not null:
|
||||
VisitExpression(ignore.Until, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrEscalateAction escalate:
|
||||
VisitExpression(escalate.To, metrics, depth: 0);
|
||||
VisitExpression(escalate.When, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrRequireVexAction require when !require.Conditions.IsEmpty:
|
||||
foreach (var condition in require.Conditions.Values)
|
||||
{
|
||||
VisitExpression(condition, metrics, depth: 0);
|
||||
}
|
||||
break;
|
||||
case PolicyIrWarnAction warn when warn.Message is not null:
|
||||
VisitExpression(warn.Message, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrDeferAction defer when defer.Until is not null:
|
||||
VisitExpression(defer.Until, metrics, depth: 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitExpression(PolicyExpression? expression, ComplexityMetrics metrics, int depth)
|
||||
{
|
||||
if (expression is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.ExpressionCount++;
|
||||
var currentDepth = depth + 1;
|
||||
if (currentDepth > metrics.MaxDepth)
|
||||
{
|
||||
metrics.MaxDepth = currentDepth;
|
||||
}
|
||||
|
||||
switch (expression)
|
||||
{
|
||||
case PolicyLiteralExpression:
|
||||
metrics.LiteralCount++;
|
||||
break;
|
||||
case PolicyListExpression listExpression:
|
||||
if (!listExpression.Items.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var item in listExpression.Items)
|
||||
{
|
||||
metrics.ListItems++;
|
||||
VisitExpression(item, metrics, currentDepth);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case PolicyIdentifierExpression:
|
||||
metrics.IdentifierCount++;
|
||||
break;
|
||||
case PolicyMemberAccessExpression member:
|
||||
metrics.MemberAccessCount++;
|
||||
VisitExpression(member.Target, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyInvocationExpression invocation:
|
||||
metrics.InvocationCount++;
|
||||
VisitExpression(invocation.Target, metrics, currentDepth);
|
||||
if (!invocation.Arguments.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var argument in invocation.Arguments)
|
||||
{
|
||||
VisitExpression(argument, metrics, currentDepth);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case PolicyIndexerExpression indexer:
|
||||
VisitExpression(indexer.Target, metrics, currentDepth);
|
||||
VisitExpression(indexer.Index, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyUnaryExpression unary:
|
||||
VisitExpression(unary.Operand, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyBinaryExpression binary:
|
||||
VisitExpression(binary.Left, metrics, currentDepth);
|
||||
VisitExpression(binary.Right, metrics, currentDepth);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static double CalculateScore(ComplexityMetrics metrics)
|
||||
{
|
||||
return metrics.RuleCount * 5d
|
||||
+ metrics.ActionCount * 1.5d
|
||||
+ metrics.ExpressionCount * 0.75d
|
||||
+ metrics.InvocationCount * 1.5d
|
||||
+ metrics.MemberAccessCount * 1.0d
|
||||
+ metrics.IdentifierCount * 0.5d
|
||||
+ metrics.LiteralCount * 0.25d
|
||||
+ metrics.ProfileBindings * 0.5d
|
||||
+ metrics.ConditionCount * 1.25d
|
||||
+ metrics.MaxDepth * 2d
|
||||
+ metrics.ListItems * 0.25d;
|
||||
}
|
||||
|
||||
private sealed class ComplexityMetrics
|
||||
{
|
||||
public int RuleCount;
|
||||
public int ActionCount;
|
||||
public int ExpressionCount;
|
||||
public int InvocationCount;
|
||||
public int MemberAccessCount;
|
||||
public int IdentifierCount;
|
||||
public int LiteralCount;
|
||||
public int ProfileCount;
|
||||
public int ProfileBindings;
|
||||
public int ConditionCount;
|
||||
public int MaxDepth;
|
||||
public int ListItems;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyComplexityReport(
|
||||
double Score,
|
||||
int RuleCount,
|
||||
int ActionCount,
|
||||
int ExpressionCount,
|
||||
int InvocationCount,
|
||||
int MemberAccessCount,
|
||||
int IdentifierCount,
|
||||
int LiteralCount,
|
||||
int MaxExpressionDepth,
|
||||
int ProfileCount,
|
||||
int ProfileBindingCount,
|
||||
int ConditionCount,
|
||||
int ListItemCount);
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Compilation;
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic complexity metrics for compiled policies.
|
||||
/// </summary>
|
||||
internal sealed class PolicyComplexityAnalyzer
|
||||
{
|
||||
public PolicyComplexityReport Analyze(PolicyIrDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var metrics = new ComplexityMetrics();
|
||||
metrics.RuleCount = document.Rules.IsDefault ? 0 : document.Rules.Length;
|
||||
|
||||
VisitMetadata(document.Metadata.Values, metrics);
|
||||
VisitMetadata(document.Settings.Values, metrics);
|
||||
VisitProfiles(document.Profiles, metrics);
|
||||
|
||||
if (!document.Rules.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
metrics.ConditionCount++;
|
||||
VisitExpression(rule.When, metrics, depth: 0);
|
||||
|
||||
VisitActions(rule.ThenActions, metrics);
|
||||
VisitActions(rule.ElseActions, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
var score = CalculateScore(metrics);
|
||||
var roundedScore = Math.Round(score, 3, MidpointRounding.AwayFromZero);
|
||||
|
||||
return new PolicyComplexityReport(
|
||||
roundedScore,
|
||||
metrics.RuleCount,
|
||||
metrics.ActionCount,
|
||||
metrics.ExpressionCount,
|
||||
metrics.InvocationCount,
|
||||
metrics.MemberAccessCount,
|
||||
metrics.IdentifierCount,
|
||||
metrics.LiteralCount,
|
||||
metrics.MaxDepth,
|
||||
metrics.ProfileCount,
|
||||
metrics.ProfileBindings,
|
||||
metrics.ConditionCount,
|
||||
metrics.ListItems);
|
||||
}
|
||||
|
||||
private static void VisitProfiles(ImmutableArray<PolicyIrProfile> profiles, ComplexityMetrics metrics)
|
||||
{
|
||||
if (profiles.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
metrics.ProfileCount++;
|
||||
|
||||
if (!profile.Maps.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var map in profile.Maps)
|
||||
{
|
||||
if (map.Entries.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in map.Entries)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
metrics.LiteralCount++; // weight values contribute to literal count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!profile.Environments.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var environment in profile.Environments)
|
||||
{
|
||||
if (environment.Entries.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in environment.Entries)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
metrics.ConditionCount++;
|
||||
VisitExpression(entry.Condition, metrics, depth: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!profile.Scalars.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var scalar in profile.Scalars)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
VisitLiteral(scalar.Value, metrics);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitMetadata(IEnumerable<PolicyIrLiteral> literals, ComplexityMetrics metrics)
|
||||
{
|
||||
foreach (var literal in literals)
|
||||
{
|
||||
VisitLiteral(literal, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitLiteral(PolicyIrLiteral literal, ComplexityMetrics metrics)
|
||||
{
|
||||
switch (literal)
|
||||
{
|
||||
case PolicyIrListLiteral list when !list.Items.IsDefaultOrEmpty:
|
||||
foreach (var item in list.Items)
|
||||
{
|
||||
VisitLiteral(item, metrics);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
metrics.LiteralCount++;
|
||||
}
|
||||
|
||||
private static void VisitActions(ImmutableArray<PolicyIrAction> actions, ComplexityMetrics metrics)
|
||||
{
|
||||
if (actions.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
metrics.ActionCount++;
|
||||
switch (action)
|
||||
{
|
||||
case PolicyIrAssignmentAction assignment:
|
||||
VisitExpression(assignment.Value, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrAnnotateAction annotate:
|
||||
VisitExpression(annotate.Value, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrIgnoreAction ignore when ignore.Until is not null:
|
||||
VisitExpression(ignore.Until, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrEscalateAction escalate:
|
||||
VisitExpression(escalate.To, metrics, depth: 0);
|
||||
VisitExpression(escalate.When, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrRequireVexAction require when !require.Conditions.IsEmpty:
|
||||
foreach (var condition in require.Conditions.Values)
|
||||
{
|
||||
VisitExpression(condition, metrics, depth: 0);
|
||||
}
|
||||
break;
|
||||
case PolicyIrWarnAction warn when warn.Message is not null:
|
||||
VisitExpression(warn.Message, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrDeferAction defer when defer.Until is not null:
|
||||
VisitExpression(defer.Until, metrics, depth: 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitExpression(PolicyExpression? expression, ComplexityMetrics metrics, int depth)
|
||||
{
|
||||
if (expression is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.ExpressionCount++;
|
||||
var currentDepth = depth + 1;
|
||||
if (currentDepth > metrics.MaxDepth)
|
||||
{
|
||||
metrics.MaxDepth = currentDepth;
|
||||
}
|
||||
|
||||
switch (expression)
|
||||
{
|
||||
case PolicyLiteralExpression:
|
||||
metrics.LiteralCount++;
|
||||
break;
|
||||
case PolicyListExpression listExpression:
|
||||
if (!listExpression.Items.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var item in listExpression.Items)
|
||||
{
|
||||
metrics.ListItems++;
|
||||
VisitExpression(item, metrics, currentDepth);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case PolicyIdentifierExpression:
|
||||
metrics.IdentifierCount++;
|
||||
break;
|
||||
case PolicyMemberAccessExpression member:
|
||||
metrics.MemberAccessCount++;
|
||||
VisitExpression(member.Target, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyInvocationExpression invocation:
|
||||
metrics.InvocationCount++;
|
||||
VisitExpression(invocation.Target, metrics, currentDepth);
|
||||
if (!invocation.Arguments.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var argument in invocation.Arguments)
|
||||
{
|
||||
VisitExpression(argument, metrics, currentDepth);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case PolicyIndexerExpression indexer:
|
||||
VisitExpression(indexer.Target, metrics, currentDepth);
|
||||
VisitExpression(indexer.Index, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyUnaryExpression unary:
|
||||
VisitExpression(unary.Operand, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyBinaryExpression binary:
|
||||
VisitExpression(binary.Left, metrics, currentDepth);
|
||||
VisitExpression(binary.Right, metrics, currentDepth);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static double CalculateScore(ComplexityMetrics metrics)
|
||||
{
|
||||
return metrics.RuleCount * 5d
|
||||
+ metrics.ActionCount * 1.5d
|
||||
+ metrics.ExpressionCount * 0.75d
|
||||
+ metrics.InvocationCount * 1.5d
|
||||
+ metrics.MemberAccessCount * 1.0d
|
||||
+ metrics.IdentifierCount * 0.5d
|
||||
+ metrics.LiteralCount * 0.25d
|
||||
+ metrics.ProfileBindings * 0.5d
|
||||
+ metrics.ConditionCount * 1.25d
|
||||
+ metrics.MaxDepth * 2d
|
||||
+ metrics.ListItems * 0.25d;
|
||||
}
|
||||
|
||||
private sealed class ComplexityMetrics
|
||||
{
|
||||
public int RuleCount;
|
||||
public int ActionCount;
|
||||
public int ExpressionCount;
|
||||
public int InvocationCount;
|
||||
public int MemberAccessCount;
|
||||
public int IdentifierCount;
|
||||
public int LiteralCount;
|
||||
public int ProfileCount;
|
||||
public int ProfileBindings;
|
||||
public int ConditionCount;
|
||||
public int MaxDepth;
|
||||
public int ListItems;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyComplexityReport(
|
||||
double Score,
|
||||
int RuleCount,
|
||||
int ActionCount,
|
||||
int ExpressionCount,
|
||||
int InvocationCount,
|
||||
int MemberAccessCount,
|
||||
int IdentifierCount,
|
||||
int LiteralCount,
|
||||
int MaxExpressionDepth,
|
||||
int ProfileCount,
|
||||
int ProfileBindingCount,
|
||||
int ConditionCount,
|
||||
int ListItemCount);
|
||||
|
||||
@@ -1,150 +1,150 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class PolicyCompilationEndpoints
|
||||
{
|
||||
private const string CompileRoute = "/api/policy/policies/{policyId}/versions/{version}:compile";
|
||||
|
||||
public static IEndpointRouteBuilder MapPolicyCompilation(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost(CompileRoute, CompilePolicy)
|
||||
.WithName("CompilePolicy")
|
||||
.WithSummary("Compile and lint a policy DSL document.")
|
||||
.WithDescription("Compiles a stella-dsl@1 policy document and returns deterministic digest and statistics.")
|
||||
.Produces<PolicyCompileResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(); // scopes enforced by policy middleware.
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult CompilePolicy(
|
||||
[FromRoute] string policyId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] PolicyCompileRequest request,
|
||||
PolicyCompilationService compilationService)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Request body missing.", policyId, version));
|
||||
}
|
||||
|
||||
var result = compilationService.Compile(request);
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Policy compilation failed.", policyId, version, result.Diagnostics));
|
||||
}
|
||||
|
||||
var response = new PolicyCompileResponse(
|
||||
result.Digest!,
|
||||
result.Statistics ?? new PolicyCompilationStatistics(0, ImmutableDictionary<string, int>.Empty),
|
||||
MapComplexity(result.Complexity),
|
||||
result.DurationMilliseconds,
|
||||
ConvertDiagnostics(result.Diagnostics));
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static PolicyProblemDetails BuildProblem(string code, string message, string policyId, int version, ImmutableArray<PolicyIssue>? diagnostics = null)
|
||||
{
|
||||
var problem = new PolicyProblemDetails
|
||||
{
|
||||
Code = code,
|
||||
Title = "Policy compilation error",
|
||||
Detail = message,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = version
|
||||
};
|
||||
|
||||
if (diagnostics is { Length: > 0 } diag)
|
||||
{
|
||||
problem.Diagnostics = diag;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyDiagnosticDto> ConvertDiagnostics(ImmutableArray<PolicyIssue> issues)
|
||||
{
|
||||
if (issues.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<PolicyDiagnosticDto>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyDiagnosticDto>(issues.Length);
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
if (issue.Severity != PolicyIssueSeverity.Warning)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(new PolicyDiagnosticDto(issue.Code, issue.Message, issue.Path));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyComplexityReportDto? MapComplexity(PolicyComplexityReport? report)
|
||||
{
|
||||
if (report is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PolicyComplexityReportDto(
|
||||
report.Score,
|
||||
report.RuleCount,
|
||||
report.ActionCount,
|
||||
report.ExpressionCount,
|
||||
report.InvocationCount,
|
||||
report.MemberAccessCount,
|
||||
report.IdentifierCount,
|
||||
report.LiteralCount,
|
||||
report.MaxExpressionDepth,
|
||||
report.ProfileCount,
|
||||
report.ProfileBindingCount,
|
||||
report.ConditionCount,
|
||||
report.ListItemCount);
|
||||
}
|
||||
|
||||
private sealed class PolicyProblemDetails : ProblemDetails
|
||||
{
|
||||
public string Code { get; set; } = "ERR_POL_001";
|
||||
|
||||
public string? PolicyId { get; set; }
|
||||
|
||||
public int PolicyVersion { get; set; }
|
||||
|
||||
public ImmutableArray<PolicyIssue> Diagnostics { get; set; } = ImmutableArray<PolicyIssue>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompileResponse(
|
||||
string Digest,
|
||||
PolicyCompilationStatistics Statistics,
|
||||
PolicyComplexityReportDto? Complexity,
|
||||
long DurationMilliseconds,
|
||||
ImmutableArray<PolicyDiagnosticDto> Warnings);
|
||||
|
||||
internal sealed record PolicyDiagnosticDto(string Code, string Message, string Path);
|
||||
|
||||
internal sealed record PolicyComplexityReportDto(
|
||||
double Score,
|
||||
int RuleCount,
|
||||
int ActionCount,
|
||||
int ExpressionCount,
|
||||
int InvocationCount,
|
||||
int MemberAccessCount,
|
||||
int IdentifierCount,
|
||||
int LiteralCount,
|
||||
int MaxExpressionDepth,
|
||||
int ProfileCount,
|
||||
int ProfileBindingCount,
|
||||
int ConditionCount,
|
||||
int ListItemCount);
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class PolicyCompilationEndpoints
|
||||
{
|
||||
private const string CompileRoute = "/api/policy/policies/{policyId}/versions/{version}:compile";
|
||||
|
||||
public static IEndpointRouteBuilder MapPolicyCompilation(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost(CompileRoute, CompilePolicy)
|
||||
.WithName("CompilePolicy")
|
||||
.WithSummary("Compile and lint a policy DSL document.")
|
||||
.WithDescription("Compiles a stella-dsl@1 policy document and returns deterministic digest and statistics.")
|
||||
.Produces<PolicyCompileResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(); // scopes enforced by policy middleware.
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult CompilePolicy(
|
||||
[FromRoute] string policyId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] PolicyCompileRequest request,
|
||||
PolicyCompilationService compilationService)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Request body missing.", policyId, version));
|
||||
}
|
||||
|
||||
var result = compilationService.Compile(request);
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Policy compilation failed.", policyId, version, result.Diagnostics));
|
||||
}
|
||||
|
||||
var response = new PolicyCompileResponse(
|
||||
result.Digest!,
|
||||
result.Statistics ?? new PolicyCompilationStatistics(0, ImmutableDictionary<string, int>.Empty),
|
||||
MapComplexity(result.Complexity),
|
||||
result.DurationMilliseconds,
|
||||
ConvertDiagnostics(result.Diagnostics));
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static PolicyProblemDetails BuildProblem(string code, string message, string policyId, int version, ImmutableArray<PolicyIssue>? diagnostics = null)
|
||||
{
|
||||
var problem = new PolicyProblemDetails
|
||||
{
|
||||
Code = code,
|
||||
Title = "Policy compilation error",
|
||||
Detail = message,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = version
|
||||
};
|
||||
|
||||
if (diagnostics is { Length: > 0 } diag)
|
||||
{
|
||||
problem.Diagnostics = diag;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyDiagnosticDto> ConvertDiagnostics(ImmutableArray<PolicyIssue> issues)
|
||||
{
|
||||
if (issues.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<PolicyDiagnosticDto>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyDiagnosticDto>(issues.Length);
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
if (issue.Severity != PolicyIssueSeverity.Warning)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(new PolicyDiagnosticDto(issue.Code, issue.Message, issue.Path));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyComplexityReportDto? MapComplexity(PolicyComplexityReport? report)
|
||||
{
|
||||
if (report is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PolicyComplexityReportDto(
|
||||
report.Score,
|
||||
report.RuleCount,
|
||||
report.ActionCount,
|
||||
report.ExpressionCount,
|
||||
report.InvocationCount,
|
||||
report.MemberAccessCount,
|
||||
report.IdentifierCount,
|
||||
report.LiteralCount,
|
||||
report.MaxExpressionDepth,
|
||||
report.ProfileCount,
|
||||
report.ProfileBindingCount,
|
||||
report.ConditionCount,
|
||||
report.ListItemCount);
|
||||
}
|
||||
|
||||
private sealed class PolicyProblemDetails : ProblemDetails
|
||||
{
|
||||
public string Code { get; set; } = "ERR_POL_001";
|
||||
|
||||
public string? PolicyId { get; set; }
|
||||
|
||||
public int PolicyVersion { get; set; }
|
||||
|
||||
public ImmutableArray<PolicyIssue> Diagnostics { get; set; } = ImmutableArray<PolicyIssue>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompileResponse(
|
||||
string Digest,
|
||||
PolicyCompilationStatistics Statistics,
|
||||
PolicyComplexityReportDto? Complexity,
|
||||
long DurationMilliseconds,
|
||||
ImmutableArray<PolicyDiagnosticDto> Warnings);
|
||||
|
||||
internal sealed record PolicyDiagnosticDto(string Code, string Message, string Path);
|
||||
|
||||
internal sealed record PolicyComplexityReportDto(
|
||||
double Score,
|
||||
int RuleCount,
|
||||
int ActionCount,
|
||||
int ExpressionCount,
|
||||
int InvocationCount,
|
||||
int MemberAccessCount,
|
||||
int IdentifierCount,
|
||||
int LiteralCount,
|
||||
int MaxExpressionDepth,
|
||||
int ProfileCount,
|
||||
int ProfileBindingCount,
|
||||
int ConditionCount,
|
||||
int ListItemCount);
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class PolicyPackEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPolicyPacks(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/policy/packs")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Policy Packs");
|
||||
|
||||
group.MapPost(string.Empty, CreatePack)
|
||||
.WithName("CreatePolicyPack")
|
||||
.WithSummary("Create a new policy pack container.")
|
||||
.Produces<PolicyPackDto>(StatusCodes.Status201Created);
|
||||
|
||||
group.MapGet(string.Empty, ListPacks)
|
||||
.WithName("ListPolicyPacks")
|
||||
.WithSummary("List policy packs for the current tenant.")
|
||||
.Produces<IReadOnlyList<PolicyPackSummaryDto>>(StatusCodes.Status200OK);
|
||||
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class PolicyPackEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPolicyPacks(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/policy/packs")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Policy Packs");
|
||||
|
||||
group.MapPost(string.Empty, CreatePack)
|
||||
.WithName("CreatePolicyPack")
|
||||
.WithSummary("Create a new policy pack container.")
|
||||
.Produces<PolicyPackDto>(StatusCodes.Status201Created);
|
||||
|
||||
group.MapGet(string.Empty, ListPacks)
|
||||
.WithName("ListPolicyPacks")
|
||||
.WithSummary("List policy packs for the current tenant.")
|
||||
.Produces<IReadOnlyList<PolicyPackSummaryDto>>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/{packId}/revisions", CreateRevision)
|
||||
.WithName("CreatePolicyRevision")
|
||||
.WithSummary("Create or update policy revision metadata.")
|
||||
@@ -49,149 +49,149 @@ internal static class PolicyPackEndpoints
|
||||
.WithSummary("Activate an approved policy revision, enforcing two-person approval when required.")
|
||||
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status202Accepted)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePack(
|
||||
HttpContext context,
|
||||
[FromBody] CreatePolicyPackRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var packId = string.IsNullOrWhiteSpace(request.PackId)
|
||||
? $"pack-{Guid.NewGuid():n}"
|
||||
: request.PackId.Trim();
|
||||
|
||||
var pack = await repository.CreateAsync(packId, request.DisplayName?.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
var dto = PolicyPackMapper.ToDto(pack);
|
||||
return Results.Created($"/api/policy/packs/{dto.PackId}", dto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPacks(
|
||||
HttpContext context,
|
||||
IPolicyPackRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var packs = await repository.ListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var summaries = packs.Select(PolicyPackMapper.ToSummaryDto).ToArray();
|
||||
return Results.Ok(summaries);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromBody] CreatePolicyRevisionRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
IPolicyActivationSettings activationSettings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request.InitialStatus is not (PolicyRevisionStatus.Draft or PolicyRevisionStatus.Approved))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid status",
|
||||
Detail = "Only Draft or Approved statuses are supported for new revisions.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var requiresTwoPersonApproval = activationSettings.ResolveRequirement(request.RequiresTwoPersonApproval);
|
||||
|
||||
var revision = await repository.UpsertRevisionAsync(
|
||||
packId,
|
||||
request.Version ?? 0,
|
||||
requiresTwoPersonApproval,
|
||||
request.InitialStatus,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/policy/packs/{packId}/revisions/{revision.Version}",
|
||||
PolicyPackMapper.ToDto(packId, revision));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ActivateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] ActivatePolicyRevisionRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
IPolicyActivationAuditor auditor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
if (actorId is null)
|
||||
{
|
||||
return Results.Problem("Actor identity required.", statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
var result = await repository.RecordActivationAsync(
|
||||
packId,
|
||||
version,
|
||||
actorId,
|
||||
DateTimeOffset.UtcNow,
|
||||
request.Comment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tenantId = context.User?.FindFirst(StellaOpsClaimTypes.Tenant)?.Value;
|
||||
auditor.RecordActivation(packId, version, actorId, tenantId, result, request.Comment);
|
||||
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePack(
|
||||
HttpContext context,
|
||||
[FromBody] CreatePolicyPackRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var packId = string.IsNullOrWhiteSpace(request.PackId)
|
||||
? $"pack-{Guid.NewGuid():n}"
|
||||
: request.PackId.Trim();
|
||||
|
||||
var pack = await repository.CreateAsync(packId, request.DisplayName?.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
var dto = PolicyPackMapper.ToDto(pack);
|
||||
return Results.Created($"/api/policy/packs/{dto.PackId}", dto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPacks(
|
||||
HttpContext context,
|
||||
IPolicyPackRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var packs = await repository.ListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var summaries = packs.Select(PolicyPackMapper.ToSummaryDto).ToArray();
|
||||
return Results.Ok(summaries);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromBody] CreatePolicyRevisionRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
IPolicyActivationSettings activationSettings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request.InitialStatus is not (PolicyRevisionStatus.Draft or PolicyRevisionStatus.Approved))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid status",
|
||||
Detail = "Only Draft or Approved statuses are supported for new revisions.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var requiresTwoPersonApproval = activationSettings.ResolveRequirement(request.RequiresTwoPersonApproval);
|
||||
|
||||
var revision = await repository.UpsertRevisionAsync(
|
||||
packId,
|
||||
request.Version ?? 0,
|
||||
requiresTwoPersonApproval,
|
||||
request.InitialStatus,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/policy/packs/{packId}/revisions/{revision.Version}",
|
||||
PolicyPackMapper.ToDto(packId, revision));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ActivateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] ActivatePolicyRevisionRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
IPolicyActivationAuditor auditor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
if (actorId is null)
|
||||
{
|
||||
return Results.Problem("Actor identity required.", statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
var result = await repository.RecordActivationAsync(
|
||||
packId,
|
||||
version,
|
||||
actorId,
|
||||
DateTimeOffset.UtcNow,
|
||||
request.Comment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tenantId = context.User?.FindFirst(StellaOpsClaimTypes.Tenant)?.Value;
|
||||
auditor.RecordActivation(packId, version, actorId, tenantId, result, request.Comment);
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
PolicyActivationResultStatus.PackNotFound => Results.NotFound(new ProblemDetails
|
||||
@@ -199,29 +199,29 @@ internal static class PolicyPackEndpoints
|
||||
Title = "Policy pack not found",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
}),
|
||||
PolicyActivationResultStatus.RevisionNotFound => Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Policy revision not found",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
}),
|
||||
PolicyActivationResultStatus.NotApproved => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Revision not approved",
|
||||
Detail = "Only approved revisions may be activated.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
}),
|
||||
PolicyActivationResultStatus.DuplicateApproval => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Approval already recorded",
|
||||
Detail = "This approver has already approved activation.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
}),
|
||||
PolicyActivationResultStatus.PendingSecondApproval => Results.Accepted(
|
||||
$"/api/policy/packs/{packId}/revisions/{version}",
|
||||
new PolicyRevisionActivationResponse("pending_second_approval", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
PolicyActivationResultStatus.Activated => Results.Ok(new PolicyRevisionActivationResponse("activated", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
PolicyActivationResultStatus.AlreadyActive => Results.Ok(new PolicyRevisionActivationResponse("already_active", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
_ => Results.BadRequest(new ProblemDetails
|
||||
PolicyActivationResultStatus.RevisionNotFound => Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Policy revision not found",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
}),
|
||||
PolicyActivationResultStatus.NotApproved => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Revision not approved",
|
||||
Detail = "Only approved revisions may be activated.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
}),
|
||||
PolicyActivationResultStatus.DuplicateApproval => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Approval already recorded",
|
||||
Detail = "This approver has already approved activation.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
}),
|
||||
PolicyActivationResultStatus.PendingSecondApproval => Results.Accepted(
|
||||
$"/api/policy/packs/{packId}/revisions/{version}",
|
||||
new PolicyRevisionActivationResponse("pending_second_approval", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
PolicyActivationResultStatus.Activated => Results.Ok(new PolicyRevisionActivationResponse("activated", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
PolicyActivationResultStatus.AlreadyActive => Results.Ok(new PolicyRevisionActivationResponse("already_active", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
_ => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Activation failed",
|
||||
Detail = "Unknown activation result.",
|
||||
@@ -323,57 +323,57 @@ internal static class PolicyPackEndpoints
|
||||
}
|
||||
|
||||
private static string? ResolveActorId(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user?.FindFirst(ClaimTypes.Upn)?.Value
|
||||
?? user?.FindFirst("sub")?.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PolicyPackMapper
|
||||
{
|
||||
public static PolicyPackDto ToDto(PolicyPackRecord record)
|
||||
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => ToDto(record.PackId, r)).ToArray());
|
||||
|
||||
public static PolicyPackSummaryDto ToSummaryDto(PolicyPackRecord record)
|
||||
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => r.Version).ToArray());
|
||||
|
||||
public static PolicyRevisionDto ToDto(string packId, PolicyRevisionRecord revision)
|
||||
=> new(
|
||||
packId,
|
||||
revision.Version,
|
||||
revision.Status.ToString(),
|
||||
revision.RequiresTwoPersonApproval,
|
||||
revision.CreatedAt,
|
||||
revision.ActivatedAt,
|
||||
revision.Approvals.Select(a => new PolicyActivationApprovalDto(a.ActorId, a.ApprovedAt, a.Comment)).ToArray());
|
||||
}
|
||||
|
||||
internal sealed record CreatePolicyPackRequest(string? PackId, string? DisplayName);
|
||||
|
||||
internal sealed record PolicyPackDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<PolicyRevisionDto> Revisions);
|
||||
|
||||
internal sealed record PolicyPackSummaryDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<int> Versions);
|
||||
|
||||
internal sealed record CreatePolicyRevisionRequest(int? Version, bool? RequiresTwoPersonApproval, PolicyRevisionStatus InitialStatus = PolicyRevisionStatus.Approved);
|
||||
|
||||
internal sealed record PolicyRevisionDto(string PackId, int Version, string Status, bool RequiresTwoPersonApproval, DateTimeOffset CreatedAt, DateTimeOffset? ActivatedAt, IReadOnlyList<PolicyActivationApprovalDto> Approvals);
|
||||
|
||||
internal sealed record PolicyActivationApprovalDto(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
|
||||
|
||||
internal sealed record ActivatePolicyRevisionRequest(string? Comment);
|
||||
|
||||
internal sealed record PolicyRevisionActivationResponse(string Status, PolicyRevisionDto Revision);
|
||||
{
|
||||
var user = context.User;
|
||||
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user?.FindFirst(ClaimTypes.Upn)?.Value
|
||||
?? user?.FindFirst("sub")?.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PolicyPackMapper
|
||||
{
|
||||
public static PolicyPackDto ToDto(PolicyPackRecord record)
|
||||
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => ToDto(record.PackId, r)).ToArray());
|
||||
|
||||
public static PolicyPackSummaryDto ToSummaryDto(PolicyPackRecord record)
|
||||
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => r.Version).ToArray());
|
||||
|
||||
public static PolicyRevisionDto ToDto(string packId, PolicyRevisionRecord revision)
|
||||
=> new(
|
||||
packId,
|
||||
revision.Version,
|
||||
revision.Status.ToString(),
|
||||
revision.RequiresTwoPersonApproval,
|
||||
revision.CreatedAt,
|
||||
revision.ActivatedAt,
|
||||
revision.Approvals.Select(a => new PolicyActivationApprovalDto(a.ActorId, a.ApprovedAt, a.Comment)).ToArray());
|
||||
}
|
||||
|
||||
internal sealed record CreatePolicyPackRequest(string? PackId, string? DisplayName);
|
||||
|
||||
internal sealed record PolicyPackDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<PolicyRevisionDto> Revisions);
|
||||
|
||||
internal sealed record PolicyPackSummaryDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<int> Versions);
|
||||
|
||||
internal sealed record CreatePolicyRevisionRequest(int? Version, bool? RequiresTwoPersonApproval, PolicyRevisionStatus InitialStatus = PolicyRevisionStatus.Approved);
|
||||
|
||||
internal sealed record PolicyRevisionDto(string PackId, int Version, string Status, bool RequiresTwoPersonApproval, DateTimeOffset CreatedAt, DateTimeOffset? ActivatedAt, IReadOnlyList<PolicyActivationApprovalDto> Approvals);
|
||||
|
||||
internal sealed record PolicyActivationApprovalDto(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
|
||||
|
||||
internal sealed record ActivatePolicyRevisionRequest(string? Comment);
|
||||
|
||||
internal sealed record PolicyRevisionActivationResponse(string Status, PolicyRevisionDto Revision);
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.RiskProfile.Lifecycle;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class RiskProfileEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRiskProfiles(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/risk/profiles")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Risk Profiles");
|
||||
|
||||
group.MapGet(string.Empty, ListProfiles)
|
||||
.WithName("ListRiskProfiles")
|
||||
.WithSummary("List all available risk profiles.")
|
||||
.Produces<RiskProfileListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/{profileId}", GetProfile)
|
||||
.WithName("GetRiskProfile")
|
||||
.WithSummary("Get a risk profile by ID.")
|
||||
.Produces<RiskProfileResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{profileId}/versions", ListVersions)
|
||||
.WithName("ListRiskProfileVersions")
|
||||
.WithSummary("List all versions of a risk profile.")
|
||||
.Produces<RiskProfileVersionListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/{profileId}/versions/{version}", GetVersion)
|
||||
.WithName("GetRiskProfileVersion")
|
||||
.WithSummary("Get a specific version of a risk profile.")
|
||||
.Produces<RiskProfileResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost(string.Empty, CreateProfile)
|
||||
.WithName("CreateRiskProfile")
|
||||
.WithSummary("Create a new risk profile version in draft status.")
|
||||
.Produces<RiskProfileResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/{profileId}/versions/{version}:activate", ActivateProfile)
|
||||
.WithName("ActivateRiskProfile")
|
||||
.WithSummary("Activate a draft risk profile, making it available for use.")
|
||||
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{profileId}/versions/{version}:deprecate", DeprecateProfile)
|
||||
.WithName("DeprecateRiskProfile")
|
||||
.WithSummary("Deprecate an active risk profile.")
|
||||
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{profileId}/versions/{version}:archive", ArchiveProfile)
|
||||
.WithName("ArchiveRiskProfile")
|
||||
.WithSummary("Archive a risk profile, removing it from active use.")
|
||||
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{profileId}/events", GetProfileEvents)
|
||||
.WithName("GetRiskProfileEvents")
|
||||
.WithSummary("Get lifecycle events for a risk profile.")
|
||||
.Produces<RiskProfileEventListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/compare", CompareProfiles)
|
||||
.WithName("CompareRiskProfiles")
|
||||
.WithSummary("Compare two risk profile versions and list differences.")
|
||||
.Produces<RiskProfileComparisonResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapGet("/{profileId}/hash", GetProfileHash)
|
||||
.WithName("GetRiskProfileHash")
|
||||
.WithSummary("Get the deterministic hash of a risk profile.")
|
||||
.Produces<RiskProfileHashResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult ListProfiles(
|
||||
HttpContext context,
|
||||
RiskProfileConfigurationService profileService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var ids = profileService.GetProfileIds();
|
||||
var profiles = ids
|
||||
.Select(id => profileService.GetProfile(id))
|
||||
.Where(p => p != null)
|
||||
.Select(p => new RiskProfileSummary(p!.Id, p.Version, p.Description))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new RiskProfileListResponse(profiles));
|
||||
}
|
||||
|
||||
private static IResult GetProfile(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
RiskProfileConfigurationService profileService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var profile = profileService.GetProfile(profileId);
|
||||
if (profile == null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = $"Risk profile '{profileId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var hash = profileService.ComputeHash(profile);
|
||||
return Results.Ok(new RiskProfileResponse(profile, hash));
|
||||
}
|
||||
|
||||
private static IResult ListVersions(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var versions = lifecycleService.GetAllVersions(profileId);
|
||||
return Results.Ok(new RiskProfileVersionListResponse(profileId, versions));
|
||||
}
|
||||
|
||||
private static IResult GetVersion(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
[FromRoute] string version,
|
||||
RiskProfileConfigurationService profileService,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var versionInfo = lifecycleService.GetVersionInfo(profileId, version);
|
||||
if (versionInfo == null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Version not found",
|
||||
Detail = $"Risk profile '{profileId}' version '{version}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var profile = profileService.GetProfile(profileId);
|
||||
if (profile == null || profile.Version != version)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = $"Risk profile '{profileId}' version '{version}' content not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var hash = profileService.ComputeHash(profile);
|
||||
return Results.Ok(new RiskProfileResponse(profile, hash, versionInfo));
|
||||
}
|
||||
|
||||
private static IResult CreateProfile(
|
||||
HttpContext context,
|
||||
[FromBody] CreateRiskProfileRequest request,
|
||||
RiskProfileConfigurationService profileService,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request?.Profile == null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Profile definition is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
|
||||
try
|
||||
{
|
||||
var profile = request.Profile;
|
||||
profileService.RegisterProfile(profile);
|
||||
|
||||
var versionInfo = lifecycleService.CreateVersion(profile, actorId);
|
||||
var hash = profileService.ComputeHash(profile);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/risk/profiles/{profile.Id}/versions/{profile.Version}",
|
||||
new RiskProfileResponse(profile, hash, versionInfo));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Profile creation failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult ActivateProfile(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
[FromRoute] string version,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
|
||||
try
|
||||
{
|
||||
var versionInfo = lifecycleService.Activate(profileId, version, actorId);
|
||||
return Results.Ok(new RiskProfileVersionInfoResponse(versionInfo));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Activation failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult DeprecateProfile(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
[FromRoute] string version,
|
||||
[FromBody] DeprecateRiskProfileRequest? request,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
|
||||
try
|
||||
{
|
||||
var versionInfo = lifecycleService.Deprecate(
|
||||
profileId,
|
||||
version,
|
||||
request?.SuccessorVersion,
|
||||
request?.Reason,
|
||||
actorId);
|
||||
|
||||
return Results.Ok(new RiskProfileVersionInfoResponse(versionInfo));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Deprecation failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult ArchiveProfile(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
[FromRoute] string version,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
|
||||
try
|
||||
{
|
||||
var versionInfo = lifecycleService.Archive(profileId, version, actorId);
|
||||
return Results.Ok(new RiskProfileVersionInfoResponse(versionInfo));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetProfileEvents(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
[FromQuery] int limit,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var effectiveLimit = limit > 0 ? limit : 100;
|
||||
var events = lifecycleService.GetEvents(profileId, effectiveLimit);
|
||||
return Results.Ok(new RiskProfileEventListResponse(profileId, events));
|
||||
}
|
||||
|
||||
private static IResult CompareProfiles(
|
||||
HttpContext context,
|
||||
[FromBody] CompareRiskProfilesRequest request,
|
||||
RiskProfileConfigurationService profileService,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null ||
|
||||
string.IsNullOrWhiteSpace(request.FromProfileId) ||
|
||||
string.IsNullOrWhiteSpace(request.FromVersion) ||
|
||||
string.IsNullOrWhiteSpace(request.ToProfileId) ||
|
||||
string.IsNullOrWhiteSpace(request.ToVersion))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Both from and to profile IDs and versions are required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var fromProfile = profileService.GetProfile(request.FromProfileId);
|
||||
var toProfile = profileService.GetProfile(request.ToProfileId);
|
||||
|
||||
if (fromProfile == null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = $"From profile '{request.FromProfileId}' was not found.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (toProfile == null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = $"To profile '{request.ToProfileId}' was not found.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var comparison = lifecycleService.CompareVersions(fromProfile, toProfile);
|
||||
return Results.Ok(new RiskProfileComparisonResponse(comparison));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Comparison failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetProfileHash(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
[FromQuery] bool contentOnly,
|
||||
RiskProfileConfigurationService profileService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var profile = profileService.GetProfile(profileId);
|
||||
if (profile == null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = $"Risk profile '{profileId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var hash = contentOnly
|
||||
? profileService.ComputeContentHash(profile)
|
||||
: profileService.ComputeHash(profile);
|
||||
|
||||
return Results.Ok(new RiskProfileHashResponse(profile.Id, profile.Version, hash, contentOnly));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
internal sealed record RiskProfileListResponse(IReadOnlyList<RiskProfileSummary> Profiles);
|
||||
|
||||
internal sealed record RiskProfileSummary(string ProfileId, string Version, string? Description);
|
||||
|
||||
internal sealed record RiskProfileResponse(
|
||||
RiskProfileModel Profile,
|
||||
string Hash,
|
||||
RiskProfileVersionInfo? VersionInfo = null);
|
||||
|
||||
internal sealed record RiskProfileVersionListResponse(
|
||||
string ProfileId,
|
||||
IReadOnlyList<RiskProfileVersionInfo> Versions);
|
||||
|
||||
internal sealed record RiskProfileVersionInfoResponse(RiskProfileVersionInfo VersionInfo);
|
||||
|
||||
internal sealed record RiskProfileEventListResponse(
|
||||
string ProfileId,
|
||||
IReadOnlyList<RiskProfileLifecycleEvent> Events);
|
||||
|
||||
internal sealed record RiskProfileComparisonResponse(RiskProfileVersionComparison Comparison);
|
||||
|
||||
internal sealed record RiskProfileHashResponse(
|
||||
string ProfileId,
|
||||
string Version,
|
||||
string Hash,
|
||||
bool ContentOnly);
|
||||
|
||||
internal sealed record CreateRiskProfileRequest(RiskProfileModel Profile);
|
||||
|
||||
internal sealed record DeprecateRiskProfileRequest(string? SuccessorVersion, string? Reason);
|
||||
|
||||
internal sealed record CompareRiskProfilesRequest(
|
||||
string FromProfileId,
|
||||
string FromVersion,
|
||||
string ToProfileId,
|
||||
string ToVersion);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +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);
|
||||
@@ -1,227 +1,362 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration for the Policy Engine host.
|
||||
/// </summary>
|
||||
public sealed class PolicyEngineOptions
|
||||
{
|
||||
public const string SectionName = "PolicyEngine";
|
||||
|
||||
public PolicyEngineAuthorityOptions Authority { get; } = new();
|
||||
|
||||
public PolicyEngineStorageOptions Storage { get; } = new();
|
||||
|
||||
public PolicyEngineWorkerOptions Workers { get; } = new();
|
||||
|
||||
public PolicyEngineResourceServerOptions ResourceServer { get; } = new();
|
||||
|
||||
public PolicyEngineCompilationOptions Compilation { get; } = new();
|
||||
|
||||
public PolicyEngineActivationOptions Activation { get; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
Authority.Validate();
|
||||
Storage.Validate();
|
||||
Workers.Validate();
|
||||
ResourceServer.Validate();
|
||||
Compilation.Validate();
|
||||
Activation.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineAuthorityOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string Issuer { get; set; } = "https://authority.stella-ops.local";
|
||||
|
||||
public string ClientId { get; set; } = "policy-engine";
|
||||
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
public IList<string> Scopes { get; } = new List<string>
|
||||
{
|
||||
StellaOpsScopes.PolicyRun,
|
||||
StellaOpsScopes.FindingsRead,
|
||||
StellaOpsScopes.EffectiveWrite
|
||||
};
|
||||
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Issuer))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority configuration requires an issuer.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Issuer, UriKind.Absolute, out var issuerUri) || !issuerUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority issuer must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (issuerUri.Scheme != Uri.UriSchemeHttps && !issuerUri.IsLoopback)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority issuer must use HTTPS unless targeting loopback.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority configuration requires a clientId.");
|
||||
}
|
||||
|
||||
if (Scopes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority configuration requires at least one scope.");
|
||||
}
|
||||
|
||||
if (BackchannelTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority backchannel timeout must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineStorageOptions
|
||||
{
|
||||
public string ConnectionString { get; set; } = "mongodb://localhost:27017/policy-engine";
|
||||
|
||||
public string DatabaseName { get; set; } = "policy_engine";
|
||||
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine storage configuration requires a MongoDB connection string.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DatabaseName))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine storage configuration requires a database name.");
|
||||
}
|
||||
|
||||
if (CommandTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine storage command timeout must be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan CommandTimeout => TimeSpan.FromSeconds(CommandTimeoutSeconds);
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineWorkerOptions
|
||||
{
|
||||
public int SchedulerIntervalSeconds { get; set; } = 15;
|
||||
|
||||
public int MaxConcurrentEvaluations { get; set; } = 4;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (SchedulerIntervalSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine worker interval must be greater than zero.");
|
||||
}
|
||||
|
||||
if (MaxConcurrentEvaluations <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine worker concurrency must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineResourceServerOptions
|
||||
{
|
||||
public string Authority { get; set; } = "https://authority.stella-ops.local";
|
||||
|
||||
public IList<string> Audiences { get; } = new List<string> { "api://policy-engine" };
|
||||
|
||||
public IList<string> RequiredScopes { get; } = new List<string> { StellaOpsScopes.PolicyRun };
|
||||
|
||||
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 void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Authority))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server configuration requires an Authority URL.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var uri))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server Authority URL must be absolute.");
|
||||
}
|
||||
|
||||
if (RequireHttpsMetadata && !uri.IsLoopback && !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineCompilationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum allowed complexity score for compiled policies. Set to <c><= 0</c> to disable.
|
||||
/// </summary>
|
||||
public double MaxComplexityScore { get; set; } = 750d;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed compilation wall-clock duration in milliseconds. Set to <c><= 0</c> to disable.
|
||||
/// </summary>
|
||||
public int MaxDurationMilliseconds { get; set; } = 1500;
|
||||
|
||||
public bool EnforceComplexity => MaxComplexityScore > 0;
|
||||
|
||||
public bool EnforceDuration => MaxDurationMilliseconds > 0;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (MaxComplexityScore < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Compilation.maxComplexityScore must be greater than or equal to zero.");
|
||||
}
|
||||
|
||||
if (MaxDurationMilliseconds < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Compilation.maxDurationMilliseconds must be greater than or equal to zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public sealed class PolicyEngineActivationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Forces two distinct approvals for every activation regardless of the request payload.
|
||||
/// </summary>
|
||||
public bool ForceTwoPersonApproval { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Default value applied when callers omit <c>requiresTwoPersonApproval</c>.
|
||||
/// </summary>
|
||||
public bool DefaultRequiresTwoPersonApproval { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Emits structured audit logs for every activation attempt.
|
||||
/// </summary>
|
||||
public bool EmitAuditLogs { get; set; } = true;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
using System.Collections.ObjectModel;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration for the Policy Engine host.
|
||||
/// </summary>
|
||||
public sealed class PolicyEngineOptions
|
||||
{
|
||||
public const string SectionName = "PolicyEngine";
|
||||
|
||||
public PolicyEngineAuthorityOptions Authority { get; } = new();
|
||||
|
||||
public PolicyEngineStorageOptions Storage { get; } = new();
|
||||
|
||||
public PolicyEngineWorkerOptions Workers { get; } = new();
|
||||
|
||||
public PolicyEngineResourceServerOptions ResourceServer { get; } = new();
|
||||
|
||||
public PolicyEngineCompilationOptions Compilation { get; } = new();
|
||||
|
||||
public PolicyEngineActivationOptions Activation { get; } = new();
|
||||
|
||||
public PolicyEngineTelemetryOptions Telemetry { get; } = new();
|
||||
|
||||
public PolicyEngineRiskProfileOptions RiskProfile { get; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
Authority.Validate();
|
||||
Storage.Validate();
|
||||
Workers.Validate();
|
||||
ResourceServer.Validate();
|
||||
Compilation.Validate();
|
||||
Activation.Validate();
|
||||
Telemetry.Validate();
|
||||
RiskProfile.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineAuthorityOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string Issuer { get; set; } = "https://authority.stella-ops.local";
|
||||
|
||||
public string ClientId { get; set; } = "policy-engine";
|
||||
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
public IList<string> Scopes { get; } = new List<string>
|
||||
{
|
||||
StellaOpsScopes.PolicyRun,
|
||||
StellaOpsScopes.FindingsRead,
|
||||
StellaOpsScopes.EffectiveWrite
|
||||
};
|
||||
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Issuer))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority configuration requires an issuer.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Issuer, UriKind.Absolute, out var issuerUri) || !issuerUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority issuer must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (issuerUri.Scheme != Uri.UriSchemeHttps && !issuerUri.IsLoopback)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority issuer must use HTTPS unless targeting loopback.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority configuration requires a clientId.");
|
||||
}
|
||||
|
||||
if (Scopes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority configuration requires at least one scope.");
|
||||
}
|
||||
|
||||
if (BackchannelTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority backchannel timeout must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineStorageOptions
|
||||
{
|
||||
public string ConnectionString { get; set; } = "mongodb://localhost:27017/policy-engine";
|
||||
|
||||
public string DatabaseName { get; set; } = "policy_engine";
|
||||
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine storage configuration requires a MongoDB connection string.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DatabaseName))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine storage configuration requires a database name.");
|
||||
}
|
||||
|
||||
if (CommandTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine storage command timeout must be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan CommandTimeout => TimeSpan.FromSeconds(CommandTimeoutSeconds);
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineWorkerOptions
|
||||
{
|
||||
public int SchedulerIntervalSeconds { get; set; } = 15;
|
||||
|
||||
public int MaxConcurrentEvaluations { get; set; } = 4;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (SchedulerIntervalSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine worker interval must be greater than zero.");
|
||||
}
|
||||
|
||||
if (MaxConcurrentEvaluations <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine worker concurrency must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineResourceServerOptions
|
||||
{
|
||||
public string Authority { get; set; } = "https://authority.stella-ops.local";
|
||||
|
||||
public IList<string> Audiences { get; } = new List<string> { "api://policy-engine" };
|
||||
|
||||
public IList<string> RequiredScopes { get; } = new List<string> { StellaOpsScopes.PolicyRun };
|
||||
|
||||
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 void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Authority))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server configuration requires an Authority URL.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var uri))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server Authority URL must be absolute.");
|
||||
}
|
||||
|
||||
if (RequireHttpsMetadata && !uri.IsLoopback && !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineCompilationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum allowed complexity score for compiled policies. Set to <c><= 0</c> to disable.
|
||||
/// </summary>
|
||||
public double MaxComplexityScore { get; set; } = 750d;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed compilation wall-clock duration in milliseconds. Set to <c><= 0</c> to disable.
|
||||
/// </summary>
|
||||
public int MaxDurationMilliseconds { get; set; } = 1500;
|
||||
|
||||
public bool EnforceComplexity => MaxComplexityScore > 0;
|
||||
|
||||
public bool EnforceDuration => MaxDurationMilliseconds > 0;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (MaxComplexityScore < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Compilation.maxComplexityScore must be greater than or equal to zero.");
|
||||
}
|
||||
|
||||
if (MaxDurationMilliseconds < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Compilation.maxDurationMilliseconds must be greater than or equal to zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public sealed class PolicyEngineActivationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Forces two distinct approvals for every activation regardless of the request payload.
|
||||
/// </summary>
|
||||
public bool ForceTwoPersonApproval { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Default value applied when callers omit <c>requiresTwoPersonApproval</c>.
|
||||
/// </summary>
|
||||
public bool DefaultRequiresTwoPersonApproval { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Emits structured audit logs for every activation attempt.
|
||||
/// </summary>
|
||||
public bool EmitAuditLogs { get; set; } = true;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineRiskProfileOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables risk profile integration for policy evaluation.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default profile ID to use when no profile is specified.
|
||||
/// </summary>
|
||||
public string DefaultProfileId { get; set; } = "default";
|
||||
|
||||
/// <summary>
|
||||
/// Directory containing risk profile JSON files.
|
||||
/// </summary>
|
||||
public string? ProfileDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum inheritance depth for profile resolution.
|
||||
/// </summary>
|
||||
public int MaxInheritanceDepth { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate profiles against the JSON schema on load.
|
||||
/// </summary>
|
||||
public bool ValidateOnLoad { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to cache resolved profiles in memory.
|
||||
/// </summary>
|
||||
public bool CacheResolvedProfiles { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Inline profile definitions (for config-based profiles).
|
||||
/// </summary>
|
||||
public List<RiskProfileDefinition> Profiles { get; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (MaxInheritanceDepth <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("RiskProfile.MaxInheritanceDepth must be greater than zero.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DefaultProfileId))
|
||||
{
|
||||
throw new InvalidOperationException("RiskProfile.DefaultProfileId is required.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline risk profile definition in configuration.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile identifier.
|
||||
/// </summary>
|
||||
public required string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile version (SemVer).
|
||||
/// </summary>
|
||||
public required string Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent profile ID for inheritance.
|
||||
/// </summary>
|
||||
public string? Extends { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal definitions for risk scoring.
|
||||
/// </summary>
|
||||
public List<RiskProfileSignalDefinition> Signals { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Weight per signal name.
|
||||
/// </summary>
|
||||
public Dictionary<string, double> Weights { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata.
|
||||
/// </summary>
|
||||
public Dictionary<string, object?>? Metadata { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline signal definition in configuration.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileSignalDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Signal name.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal source.
|
||||
/// </summary>
|
||||
public required string Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal type (boolean, numeric, categorical).
|
||||
/// </summary>
|
||||
public required string Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON Pointer path in evidence.
|
||||
/// </summary>
|
||||
public string? Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional transform expression.
|
||||
/// </summary>
|
||||
public string? Transform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional unit for numeric signals.
|
||||
/// </summary>
|
||||
public string? Unit { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,197 +1,211 @@
|
||||
using System.IO;
|
||||
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.PolicyDsl;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
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.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
||||
|
||||
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(TimeProvider.System);
|
||||
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
|
||||
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
|
||||
builder.Services.AddSingleton<StellaOps.PolicyDsl.PolicyCompiler>();
|
||||
builder.Services.AddSingleton<PolicyCompilationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationService>();
|
||||
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>();
|
||||
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.AddHttpContextAccessor();
|
||||
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: $"{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();
|
||||
|
||||
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.MapTrustWeighting();
|
||||
app.MapAdvisoryAiKnobs();
|
||||
app.MapBatchContext();
|
||||
app.MapOrchestratorJobs();
|
||||
app.MapPolicyWorker();
|
||||
app.MapLedgerExport();
|
||||
app.MapSnapshots();
|
||||
app.MapViolations();
|
||||
|
||||
app.Run();
|
||||
using System.IO;
|
||||
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.PolicyDsl;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
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");
|
||||
|
||||
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(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.Engine.Scoring.IRiskScoringJobStore, StellaOps.Policy.Engine.Scoring.InMemoryRiskScoringJobStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.RiskScoringTriggerService>();
|
||||
builder.Services.AddHostedService<IncidentModeExpirationWorker>();
|
||||
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
|
||||
builder.Services.AddSingleton<StellaOps.PolicyDsl.PolicyCompiler>();
|
||||
builder.Services.AddSingleton<PolicyCompilationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationService>();
|
||||
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>();
|
||||
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.AddHttpContextAccessor();
|
||||
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: $"{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();
|
||||
|
||||
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.MapTrustWeighting();
|
||||
app.MapAdvisoryAiKnobs();
|
||||
app.MapBatchContext();
|
||||
app.MapOrchestratorJobs();
|
||||
app.MapPolicyWorker();
|
||||
app.MapLedgerExport();
|
||||
app.MapSnapshots();
|
||||
app.MapViolations();
|
||||
app.MapRiskProfiles();
|
||||
app.MapRiskProfileSchema();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Policy.Engine.Tests")]
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Policy.Engine.Tests")]
|
||||
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
131
src/Policy/StellaOps.Policy.Engine/Scoring/RiskScoringModels.cs
Normal file
131
src/Policy/StellaOps.Policy.Engine/Scoring/RiskScoringModels.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
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>
|
||||
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);
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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 ConcurrentDictionary<string, DateTimeOffset> _recentTriggers;
|
||||
private readonly TimeSpan _deduplicationWindow;
|
||||
|
||||
public RiskScoringTriggerService(
|
||||
ILogger<RiskScoringTriggerService> logger,
|
||||
TimeProvider timeProvider,
|
||||
RiskProfileConfigurationService profileService,
|
||||
IRiskScoringJobStore jobStore)
|
||||
{
|
||||
_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));
|
||||
_hasher = new RiskProfileHasher();
|
||||
_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 static string GenerateJobId(string tenantId, string contextId, DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{tenantId}|{contextId}|{timestamp:O}|{Guid.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return $"rsj-{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
}
|
||||
@@ -1,100 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal interface IPolicyActivationAuditor
|
||||
{
|
||||
void RecordActivation(
|
||||
string packId,
|
||||
int version,
|
||||
string actorId,
|
||||
string? tenantId,
|
||||
PolicyActivationResult result,
|
||||
string? comment);
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationAuditor : IPolicyActivationAuditor
|
||||
{
|
||||
private const int CommentLimit = 512;
|
||||
|
||||
private readonly PolicyEngineOptions options;
|
||||
private readonly ILogger<PolicyActivationAuditor> logger;
|
||||
|
||||
public PolicyActivationAuditor(
|
||||
PolicyEngineOptions options,
|
||||
ILogger<PolicyActivationAuditor> logger)
|
||||
{
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void RecordActivation(
|
||||
string packId,
|
||||
int version,
|
||||
string actorId,
|
||||
string? tenantId,
|
||||
PolicyActivationResult result,
|
||||
string? comment)
|
||||
{
|
||||
if (!options.Activation.EmitAuditLogs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(packId);
|
||||
ArgumentNullException.ThrowIfNull(actorId);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var normalizedStatus = NormalizeStatus(result.Status);
|
||||
var scope = new Dictionary<string, object?>
|
||||
{
|
||||
["policy.pack_id"] = packId,
|
||||
["policy.revision"] = version,
|
||||
["policy.activation.status"] = normalizedStatus,
|
||||
["policy.activation.actor"] = actorId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
scope["policy.tenant"] = tenantId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(comment))
|
||||
{
|
||||
scope["policy.activation.comment"] = Truncate(comment!, CommentLimit);
|
||||
}
|
||||
|
||||
if (result.Revision is { } revision)
|
||||
{
|
||||
scope["policy.activation.requires_two_person"] = revision.RequiresTwoPersonApproval;
|
||||
scope["policy.activation.approval_count"] = revision.Approvals.Length;
|
||||
if (revision.Approvals.Length > 0)
|
||||
{
|
||||
scope["policy.activation.approvers"] = revision.Approvals
|
||||
.Select(static approval => approval.ActorId)
|
||||
.Where(static actor => !string.IsNullOrWhiteSpace(actor))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Policy activation {PackId}/{Revision} completed with status {Status}.",
|
||||
packId,
|
||||
version,
|
||||
normalizedStatus);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeStatus(PolicyActivationResultStatus status)
|
||||
=> status.ToString().ToLowerInvariant();
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
=> value.Length <= maxLength ? value : value[..maxLength];
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal interface IPolicyActivationAuditor
|
||||
{
|
||||
void RecordActivation(
|
||||
string packId,
|
||||
int version,
|
||||
string actorId,
|
||||
string? tenantId,
|
||||
PolicyActivationResult result,
|
||||
string? comment);
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationAuditor : IPolicyActivationAuditor
|
||||
{
|
||||
private const int CommentLimit = 512;
|
||||
|
||||
private readonly PolicyEngineOptions options;
|
||||
private readonly ILogger<PolicyActivationAuditor> logger;
|
||||
|
||||
public PolicyActivationAuditor(
|
||||
PolicyEngineOptions options,
|
||||
ILogger<PolicyActivationAuditor> logger)
|
||||
{
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void RecordActivation(
|
||||
string packId,
|
||||
int version,
|
||||
string actorId,
|
||||
string? tenantId,
|
||||
PolicyActivationResult result,
|
||||
string? comment)
|
||||
{
|
||||
if (!options.Activation.EmitAuditLogs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(packId);
|
||||
ArgumentNullException.ThrowIfNull(actorId);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var normalizedStatus = NormalizeStatus(result.Status);
|
||||
var scope = new Dictionary<string, object?>
|
||||
{
|
||||
["policy.pack_id"] = packId,
|
||||
["policy.revision"] = version,
|
||||
["policy.activation.status"] = normalizedStatus,
|
||||
["policy.activation.actor"] = actorId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
scope["policy.tenant"] = tenantId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(comment))
|
||||
{
|
||||
scope["policy.activation.comment"] = Truncate(comment!, CommentLimit);
|
||||
}
|
||||
|
||||
if (result.Revision is { } revision)
|
||||
{
|
||||
scope["policy.activation.requires_two_person"] = revision.RequiresTwoPersonApproval;
|
||||
scope["policy.activation.approval_count"] = revision.Approvals.Length;
|
||||
if (revision.Approvals.Length > 0)
|
||||
{
|
||||
scope["policy.activation.approvers"] = revision.Approvals
|
||||
.Select(static approval => approval.ActorId)
|
||||
.Where(static actor => !string.IsNullOrWhiteSpace(actor))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Policy activation {PackId}/{Revision} completed with status {Status}.",
|
||||
packId,
|
||||
version,
|
||||
normalizedStatus);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeStatus(PolicyActivationResultStatus status)
|
||||
=> status.ToString().ToLowerInvariant();
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
=> value.Length <= maxLength ? value : value[..maxLength];
|
||||
}
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
using System;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal interface IPolicyActivationSettings
|
||||
{
|
||||
bool ResolveRequirement(bool? requested);
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationSettings : IPolicyActivationSettings
|
||||
{
|
||||
private readonly PolicyEngineOptions options;
|
||||
|
||||
public PolicyActivationSettings(PolicyEngineOptions options)
|
||||
{
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public bool ResolveRequirement(bool? requested)
|
||||
{
|
||||
if (options.Activation.ForceTwoPersonApproval)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (requested.HasValue)
|
||||
{
|
||||
return requested.Value;
|
||||
}
|
||||
|
||||
return options.Activation.DefaultRequiresTwoPersonApproval;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal interface IPolicyActivationSettings
|
||||
{
|
||||
bool ResolveRequirement(bool? requested);
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationSettings : IPolicyActivationSettings
|
||||
{
|
||||
private readonly PolicyEngineOptions options;
|
||||
|
||||
public PolicyActivationSettings(PolicyEngineOptions options)
|
||||
{
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public bool ResolveRequirement(bool? requested)
|
||||
{
|
||||
if (options.Activation.ForceTwoPersonApproval)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (requested.HasValue)
|
||||
{
|
||||
return requested.Value;
|
||||
}
|
||||
|
||||
return options.Activation.DefaultRequiresTwoPersonApproval;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal static class PolicyEngineDiagnosticCodes
|
||||
{
|
||||
public const string CompilationComplexityExceeded = "ERR_POL_COMPLEXITY";
|
||||
}
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal static class PolicyEngineDiagnosticCodes
|
||||
{
|
||||
public const string CompilationComplexityExceeded = "ERR_POL_COMPLEXITY";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
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)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value.RiskProfile ?? throw new ArgumentNullException(nameof(options));
|
||||
_mergeService = new RiskProfileMergeService();
|
||||
_hasher = new RiskProfileHasher();
|
||||
_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)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Risk profile file '{File}' failed validation: {Errors}",
|
||||
file,
|
||||
string.Join("; ", validation.Message ?? "Unknown error"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var profile = JsonSerializer.Deserialize<RiskProfileModel>(json, JsonOptions);
|
||||
if (profile != null)
|
||||
{
|
||||
_profileCache[profile.Id] = profile;
|
||||
_logger.LogDebug("Loaded profile '{ProfileId}' from {File}", profile.Id, file);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load risk profile from '{File}'", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureDefaultProfile()
|
||||
{
|
||||
if (_profileCache.ContainsKey(_options.DefaultProfileId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var defaultProfile = CreateBuiltInDefaultProfile();
|
||||
_profileCache[defaultProfile.Id] = defaultProfile;
|
||||
_logger.LogDebug("Created built-in default profile '{ProfileId}'", defaultProfile.Id);
|
||||
}
|
||||
|
||||
private static RiskProfileModel CreateBuiltInDefaultProfile()
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = "default",
|
||||
Version = "1.0.0",
|
||||
Description = "Built-in default risk profile with standard vulnerability signals.",
|
||||
Signals = new List<RiskSignal>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "cvss_score",
|
||||
Source = "vulnerability",
|
||||
Type = RiskSignalType.Numeric,
|
||||
Path = "/cvss/baseScore",
|
||||
Unit = "score"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "kev",
|
||||
Source = "cisa",
|
||||
Type = RiskSignalType.Boolean,
|
||||
Path = "/kev/inCatalog"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "epss",
|
||||
Source = "first",
|
||||
Type = RiskSignalType.Numeric,
|
||||
Path = "/epss/probability",
|
||||
Unit = "probability"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "reachability",
|
||||
Source = "analysis",
|
||||
Type = RiskSignalType.Categorical,
|
||||
Path = "/reachability/status"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "exploit_available",
|
||||
Source = "exploit-db",
|
||||
Type = RiskSignalType.Boolean,
|
||||
Path = "/exploit/available"
|
||||
}
|
||||
},
|
||||
Weights = new Dictionary<string, double>
|
||||
{
|
||||
["cvss_score"] = 0.3,
|
||||
["kev"] = 0.25,
|
||||
["epss"] = 0.2,
|
||||
["reachability"] = 0.15,
|
||||
["exploit_available"] = 0.1
|
||||
},
|
||||
Overrides = new RiskOverrides(),
|
||||
Metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["builtin"] = true,
|
||||
["created"] = DateTimeOffset.UtcNow.ToString("o")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskProfileModel ConvertFromDefinition(RiskProfileDefinition definition)
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = definition.Id,
|
||||
Version = definition.Version,
|
||||
Description = definition.Description,
|
||||
Extends = definition.Extends,
|
||||
Signals = definition.Signals.Select(s => new RiskSignal
|
||||
{
|
||||
Name = s.Name,
|
||||
Source = s.Source,
|
||||
Type = ParseSignalType(s.Type),
|
||||
Path = s.Path,
|
||||
Transform = s.Transform,
|
||||
Unit = s.Unit
|
||||
}).ToList(),
|
||||
Weights = new Dictionary<string, double>(definition.Weights),
|
||||
Overrides = new RiskOverrides(),
|
||||
Metadata = definition.Metadata != null
|
||||
? new Dictionary<string, object?>(definition.Metadata)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskSignalType ParseSignalType(string type)
|
||||
{
|
||||
return type.ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" or "bool" => RiskSignalType.Boolean,
|
||||
"numeric" or "number" => RiskSignalType.Numeric,
|
||||
"categorical" or "category" => RiskSignalType.Categorical,
|
||||
_ => throw new ArgumentException($"Unknown signal type: {type}")
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
}
|
||||
@@ -1,25 +1,40 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Policy.Engine.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../../Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Policy.Engine.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Completed Tasks
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| POLICY-ENGINE-20-000 | DONE (2025-10-26) | Policy Guild, BE-Base Platform Guild | POLICY-AOC-19-001 | Spin up new `StellaOps.Policy.Engine` service project (minimal API host + worker), wire DI composition root, configuration binding, and Authority client scaffolding. | New project builds/tests; registered in solution; bootstrap validates configuration; host template committed with compliance checklist. |
|
||||
| POLICY-ENGINE-27-001 | DONE (2025-10-31) | Policy Guild, Security Guild | AUTH-POLICY-27-001, POLICY-ENGINE-20-004 | Replace legacy `policy:write/submit` scope usage across Policy Engine API/worker/scheduler clients with the new Policy Studio scope family (`policy:author/review/approve/operate/audit/simulate`), update bootstrap configuration and tests, and ensure RBAC denials surface deterministic errors. | All configs/tests reference new scope set, integration tests cover missing-scope failures, CLI/docs samples updated, and CI guard prevents reintroduction of legacy scope names. |
|
||||
| POLICY-GATEWAY-18-001 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-ENGINE-20-000 | Bootstrap Policy Gateway host (`StellaOps.Policy.Gateway`) with configuration bootstrap, Authority resource-server auth, structured logging, health endpoints, and solution registration. | Gateway project builds/tests, configuration validation wired, `/healthz` + `/readyz` exposed, logging uses standard format. |
|
||||
| POLICY-ENGINE-70-001 | DONE (2025-10-27) | Policy Guild, Governance Guild | POLICY-EXC-25-001 | Implement exception evaluation layer: specificity resolution, effect application (suppress/defer/downgrade/require control), and integration with explain traces. | Engine applies exceptions deterministically; unit/property tests cover precedence; explainer includes exception metadata. |
|
||||
| POLICY-ENGINE-20-001 | DONE (2025-10-26) | Policy Guild, Language Infrastructure Guild | POLICY-ENGINE-20-000 | Implement `stella-dsl@1` parser + IR compiler with grammar validation, syntax diagnostics, and checksum outputs for caching. | DSL parser handles full grammar + error reporting; IR checksum stored with policy version; unit tests cover success/error paths. |
|
||||
| POLICY-GATEWAY-18-002 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-GATEWAY-18-001 | Implement proxy routes for policy packs/revisions (`GET/POST /api/policy/packs`, `/revisions`) with scope enforcement (`policy:read`, `policy:edit`) and deterministic DTOs. | Endpoints proxy to Policy Engine, unit tests cover happy/error paths, unauthorized requests rejected correctly. |
|
||||
| POLICY-GATEWAY-18-003 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-GATEWAY-18-002 | Implement activation proxy (`POST /api/policy/packs/{packId}/revisions/{version}:activate`) supporting single/two-person flows, returning 202 when awaiting second approval, and emitting structured logs/metrics. | Activation responses match Policy Engine contract, logs include tenant/actor/pack info, metrics published for outcomes. |
|
||||
| POLICY-GATEWAY-18-004 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-GATEWAY-18-001 | Add typed HttpClient for Policy Engine with DPoP client credentials, retry/backoff, and consistent error mapping to ProblemDetails. | HttpClient registered with resilient pipeline, integration tests verify error translation and token usage. |
|
||||
| POLICY-GATEWAY-18-005 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-GATEWAY-18-002, POLICY-GATEWAY-18-003 | Update docs/offline kit configs with new gateway service, sample curl commands, and CLI/UI integration guidance. | Docs merged, Offline Kit includes gateway config, verification script updated, release notes prepared. |
|
||||
# Completed Tasks
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| POLICY-ENGINE-20-000 | DONE (2025-10-26) | Policy Guild, BE-Base Platform Guild | POLICY-AOC-19-001 | Spin up new `StellaOps.Policy.Engine` service project (minimal API host + worker), wire DI composition root, configuration binding, and Authority client scaffolding. | New project builds/tests; registered in solution; bootstrap validates configuration; host template committed with compliance checklist. |
|
||||
| POLICY-ENGINE-27-001 | DONE (2025-10-31) | Policy Guild, Security Guild | AUTH-POLICY-27-001, POLICY-ENGINE-20-004 | Replace legacy `policy:write/submit` scope usage across Policy Engine API/worker/scheduler clients with the new Policy Studio scope family (`policy:author/review/approve/operate/audit/simulate`), update bootstrap configuration and tests, and ensure RBAC denials surface deterministic errors. | All configs/tests reference new scope set, integration tests cover missing-scope failures, CLI/docs samples updated, and CI guard prevents reintroduction of legacy scope names. |
|
||||
| POLICY-GATEWAY-18-001 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-ENGINE-20-000 | Bootstrap Policy Gateway host (`StellaOps.Policy.Gateway`) with configuration bootstrap, Authority resource-server auth, structured logging, health endpoints, and solution registration. | Gateway project builds/tests, configuration validation wired, `/healthz` + `/readyz` exposed, logging uses standard format. |
|
||||
| POLICY-ENGINE-70-001 | DONE (2025-10-27) | Policy Guild, Governance Guild | POLICY-EXC-25-001 | Implement exception evaluation layer: specificity resolution, effect application (suppress/defer/downgrade/require control), and integration with explain traces. | Engine applies exceptions deterministically; unit/property tests cover precedence; explainer includes exception metadata. |
|
||||
| POLICY-ENGINE-20-001 | DONE (2025-10-26) | Policy Guild, Language Infrastructure Guild | POLICY-ENGINE-20-000 | Implement `stella-dsl@1` parser + IR compiler with grammar validation, syntax diagnostics, and checksum outputs for caching. | DSL parser handles full grammar + error reporting; IR checksum stored with policy version; unit tests cover success/error paths. |
|
||||
| POLICY-GATEWAY-18-002 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-GATEWAY-18-001 | Implement proxy routes for policy packs/revisions (`GET/POST /api/policy/packs`, `/revisions`) with scope enforcement (`policy:read`, `policy:edit`) and deterministic DTOs. | Endpoints proxy to Policy Engine, unit tests cover happy/error paths, unauthorized requests rejected correctly. |
|
||||
| POLICY-GATEWAY-18-003 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-GATEWAY-18-002 | Implement activation proxy (`POST /api/policy/packs/{packId}/revisions/{version}:activate`) supporting single/two-person flows, returning 202 when awaiting second approval, and emitting structured logs/metrics. | Activation responses match Policy Engine contract, logs include tenant/actor/pack info, metrics published for outcomes. |
|
||||
| POLICY-GATEWAY-18-004 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-GATEWAY-18-001 | Add typed HttpClient for Policy Engine with DPoP client credentials, retry/backoff, and consistent error mapping to ProblemDetails. | HttpClient registered with resilient pipeline, integration tests verify error translation and token usage. |
|
||||
| POLICY-GATEWAY-18-005 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-GATEWAY-18-002, POLICY-GATEWAY-18-003 | Update docs/offline kit configs with new gateway service, sample curl commands, and CLI/UI integration guidance. | Docs merged, Offline Kit includes gateway config, verification script updated, release notes prepared. |
|
||||
|
||||
379
src/Policy/StellaOps.Policy.Engine/Telemetry/EvidenceBundle.cs
Normal file
379
src/Policy/StellaOps.Policy.Engine/Telemetry/EvidenceBundle.cs
Normal file
@@ -0,0 +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
|
||||
{
|
||||
}
|
||||
214
src/Policy/StellaOps.Policy.Engine/Telemetry/IncidentMode.cs
Normal file
214
src/Policy/StellaOps.Policy.Engine/Telemetry/IncidentMode.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
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,
|
||||
samplingParameters.Tags,
|
||||
samplingParameters.Links);
|
||||
}
|
||||
|
||||
// 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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry instrumentation for the Policy Engine service.
|
||||
/// Provides metrics, traces, and structured logging correlation.
|
||||
/// </summary>
|
||||
public static class PolicyEngineTelemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the meter used for Policy Engine metrics.
|
||||
/// </summary>
|
||||
public const string MeterName = "StellaOps.Policy.Engine";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the activity source used for Policy Engine traces.
|
||||
/// </summary>
|
||||
public const string ActivitySourceName = "StellaOps.Policy.Engine";
|
||||
|
||||
private static readonly Meter Meter = new(MeterName);
|
||||
|
||||
/// <summary>
|
||||
/// The activity source used for Policy Engine traces.
|
||||
/// </summary>
|
||||
public static readonly ActivitySource ActivitySource = new(ActivitySourceName);
|
||||
|
||||
// Histogram: policy_run_seconds{mode,tenant,policy}
|
||||
private static readonly Histogram<double> PolicyRunSecondsHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_run_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of policy evaluation runs.");
|
||||
|
||||
// Gauge: policy_run_queue_depth{tenant}
|
||||
private static readonly ObservableGauge<int> PolicyRunQueueDepthGauge =
|
||||
Meter.CreateObservableGauge(
|
||||
"policy_run_queue_depth",
|
||||
observeValue: () => QueueDepthObservations,
|
||||
unit: "jobs",
|
||||
description: "Current depth of pending policy run jobs per tenant.");
|
||||
|
||||
// Counter: policy_rules_fired_total{policy,rule}
|
||||
private static readonly Counter<long> PolicyRulesFiredCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_rules_fired_total",
|
||||
unit: "rules",
|
||||
description: "Total number of policy rules that fired during evaluation.");
|
||||
|
||||
// Counter: policy_vex_overrides_total{policy,vendor}
|
||||
private static readonly Counter<long> PolicyVexOverridesCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_vex_overrides_total",
|
||||
unit: "overrides",
|
||||
description: "Total number of VEX overrides applied during policy evaluation.");
|
||||
|
||||
// Counter: policy_compilation_total{outcome}
|
||||
private static readonly Counter<long> PolicyCompilationCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_compilation_total",
|
||||
unit: "compilations",
|
||||
description: "Total number of policy compilations attempted.");
|
||||
|
||||
// Histogram: policy_compilation_seconds
|
||||
private static readonly Histogram<double> PolicyCompilationSecondsHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_compilation_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of policy compilation.");
|
||||
|
||||
// Counter: policy_simulation_total{tenant,outcome}
|
||||
private static readonly Counter<long> PolicySimulationCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_simulation_total",
|
||||
unit: "simulations",
|
||||
description: "Total number of policy simulations executed.");
|
||||
|
||||
#region Golden Signals - Latency
|
||||
|
||||
// Histogram: policy_api_latency_seconds{endpoint,method,status}
|
||||
private static readonly Histogram<double> ApiLatencyHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_api_latency_seconds",
|
||||
unit: "s",
|
||||
description: "API request latency by endpoint.");
|
||||
|
||||
// Histogram: policy_evaluation_latency_seconds{tenant,policy}
|
||||
private static readonly Histogram<double> EvaluationLatencyHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_evaluation_latency_seconds",
|
||||
unit: "s",
|
||||
description: "Policy evaluation latency per batch.");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Signals - Traffic
|
||||
|
||||
// Counter: policy_requests_total{endpoint,method}
|
||||
private static readonly Counter<long> RequestsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_requests_total",
|
||||
unit: "requests",
|
||||
description: "Total API requests by endpoint and method.");
|
||||
|
||||
// Counter: policy_evaluations_total{tenant,policy,mode}
|
||||
private static readonly Counter<long> EvaluationsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_evaluations_total",
|
||||
unit: "evaluations",
|
||||
description: "Total policy evaluations by tenant, policy, and mode.");
|
||||
|
||||
// Counter: policy_findings_materialized_total{tenant,policy}
|
||||
private static readonly Counter<long> FindingsMaterializedCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_findings_materialized_total",
|
||||
unit: "findings",
|
||||
description: "Total findings materialized during policy evaluation.");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Signals - Errors
|
||||
|
||||
// Counter: policy_errors_total{type,tenant}
|
||||
private static readonly Counter<long> ErrorsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_errors_total",
|
||||
unit: "errors",
|
||||
description: "Total errors by type (compilation, evaluation, api, storage).");
|
||||
|
||||
// Counter: policy_api_errors_total{endpoint,status_code}
|
||||
private static readonly Counter<long> ApiErrorsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_api_errors_total",
|
||||
unit: "errors",
|
||||
description: "Total API errors by endpoint and status code.");
|
||||
|
||||
// Counter: policy_evaluation_failures_total{tenant,policy,reason}
|
||||
private static readonly Counter<long> EvaluationFailuresCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_evaluation_failures_total",
|
||||
unit: "failures",
|
||||
description: "Total evaluation failures by reason (timeout, determinism, storage, canceled).");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Signals - Saturation
|
||||
|
||||
// Gauge: policy_concurrent_evaluations{tenant}
|
||||
private static readonly ObservableGauge<int> ConcurrentEvaluationsGauge =
|
||||
Meter.CreateObservableGauge(
|
||||
"policy_concurrent_evaluations",
|
||||
observeValue: () => ConcurrentEvaluationsObservations,
|
||||
unit: "evaluations",
|
||||
description: "Current number of concurrent policy evaluations.");
|
||||
|
||||
// Gauge: policy_worker_utilization
|
||||
private static readonly ObservableGauge<double> WorkerUtilizationGauge =
|
||||
Meter.CreateObservableGauge(
|
||||
"policy_worker_utilization",
|
||||
observeValue: () => WorkerUtilizationObservations,
|
||||
unit: "ratio",
|
||||
description: "Worker pool utilization ratio (0.0 to 1.0).");
|
||||
|
||||
#endregion
|
||||
|
||||
#region SLO Metrics
|
||||
|
||||
// Gauge: policy_slo_burn_rate{slo_name}
|
||||
private static readonly ObservableGauge<double> SloBurnRateGauge =
|
||||
Meter.CreateObservableGauge(
|
||||
"policy_slo_burn_rate",
|
||||
observeValue: () => SloBurnRateObservations,
|
||||
unit: "ratio",
|
||||
description: "SLO burn rate over configured window.");
|
||||
|
||||
// Gauge: policy_error_budget_remaining{slo_name}
|
||||
private static readonly ObservableGauge<double> ErrorBudgetRemainingGauge =
|
||||
Meter.CreateObservableGauge(
|
||||
"policy_error_budget_remaining",
|
||||
observeValue: () => ErrorBudgetObservations,
|
||||
unit: "ratio",
|
||||
description: "Remaining error budget as ratio (0.0 to 1.0).");
|
||||
|
||||
// Counter: policy_slo_violations_total{slo_name}
|
||||
private static readonly Counter<long> SloViolationsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_slo_violations_total",
|
||||
unit: "violations",
|
||||
description: "Total SLO violations detected.");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Risk Scoring Metrics
|
||||
|
||||
// Counter: policy_risk_scoring_jobs_created_total
|
||||
private static readonly Counter<long> RiskScoringJobsCreatedCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_risk_scoring_jobs_created_total",
|
||||
unit: "jobs",
|
||||
description: "Total risk scoring jobs created.");
|
||||
|
||||
// Counter: policy_risk_scoring_triggers_skipped_total
|
||||
private static readonly Counter<long> RiskScoringTriggersSkippedCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_risk_scoring_triggers_skipped_total",
|
||||
unit: "triggers",
|
||||
description: "Total risk scoring triggers skipped due to deduplication.");
|
||||
|
||||
// Histogram: policy_risk_scoring_duration_seconds
|
||||
private static readonly Histogram<double> RiskScoringDurationHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_risk_scoring_duration_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of risk scoring job execution.");
|
||||
|
||||
// Counter: policy_risk_scoring_findings_scored_total
|
||||
private static readonly Counter<long> RiskScoringFindingsScoredCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_risk_scoring_findings_scored_total",
|
||||
unit: "findings",
|
||||
description: "Total findings scored by risk scoring jobs.");
|
||||
|
||||
/// <summary>
|
||||
/// Counter for risk scoring jobs created.
|
||||
/// </summary>
|
||||
public static Counter<long> RiskScoringJobsCreated => RiskScoringJobsCreatedCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Counter for risk scoring triggers skipped.
|
||||
/// </summary>
|
||||
public static Counter<long> RiskScoringTriggersSkipped => RiskScoringTriggersSkippedCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Records risk scoring duration.
|
||||
/// </summary>
|
||||
/// <param name="seconds">Duration in seconds.</param>
|
||||
/// <param name="profileId">Profile identifier.</param>
|
||||
/// <param name="findingCount">Number of findings scored.</param>
|
||||
public static void RecordRiskScoringDuration(double seconds, string profileId, int findingCount)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "profile_id", NormalizeTag(profileId) },
|
||||
{ "finding_count", findingCount.ToString() },
|
||||
};
|
||||
|
||||
RiskScoringDurationHistogram.Record(seconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records findings scored by risk scoring.
|
||||
/// </summary>
|
||||
/// <param name="profileId">Profile identifier.</param>
|
||||
/// <param name="count">Number of findings scored.</param>
|
||||
public static void RecordFindingsScored(string profileId, long count)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "profile_id", NormalizeTag(profileId) },
|
||||
};
|
||||
|
||||
RiskScoringFindingsScoredCounter.Add(count, tags);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// Storage for observable gauge observations
|
||||
private static IEnumerable<Measurement<int>> QueueDepthObservations = Enumerable.Empty<Measurement<int>>();
|
||||
private static IEnumerable<Measurement<int>> ConcurrentEvaluationsObservations = Enumerable.Empty<Measurement<int>>();
|
||||
private static IEnumerable<Measurement<double>> WorkerUtilizationObservations = Enumerable.Empty<Measurement<double>>();
|
||||
private static IEnumerable<Measurement<double>> SloBurnRateObservations = Enumerable.Empty<Measurement<double>>();
|
||||
private static IEnumerable<Measurement<double>> ErrorBudgetObservations = Enumerable.Empty<Measurement<double>>();
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback to observe queue depth measurements.
|
||||
/// </summary>
|
||||
/// <param name="observeFunc">Function that returns current queue depth measurements.</param>
|
||||
public static void RegisterQueueDepthObservation(Func<IEnumerable<Measurement<int>>> observeFunc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observeFunc);
|
||||
QueueDepthObservations = observeFunc();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the duration of a policy run.
|
||||
/// </summary>
|
||||
/// <param name="seconds">Duration in seconds.</param>
|
||||
/// <param name="mode">Run mode (full, incremental, simulate).</param>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
/// <param name="outcome">Outcome of the run (success, failure, canceled).</param>
|
||||
public static void RecordRunDuration(double seconds, string mode, string tenant, string policy, string outcome)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "mode", NormalizeTag(mode) },
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
{ "outcome", NormalizeTag(outcome) },
|
||||
};
|
||||
|
||||
PolicyRunSecondsHistogram.Record(seconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that a policy rule fired during evaluation.
|
||||
/// </summary>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
/// <param name="rule">Rule identifier.</param>
|
||||
/// <param name="count">Number of times the rule fired.</param>
|
||||
public static void RecordRuleFired(string policy, string rule, long count = 1)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
{ "rule", NormalizeTag(rule) },
|
||||
};
|
||||
|
||||
PolicyRulesFiredCounter.Add(count, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a VEX override applied during policy evaluation.
|
||||
/// </summary>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
/// <param name="vendor">VEX vendor identifier.</param>
|
||||
/// <param name="count">Number of overrides.</param>
|
||||
public static void RecordVexOverride(string policy, string vendor, long count = 1)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
{ "vendor", NormalizeTag(vendor) },
|
||||
};
|
||||
|
||||
PolicyVexOverridesCounter.Add(count, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a policy compilation attempt.
|
||||
/// </summary>
|
||||
/// <param name="outcome">Outcome (success, failure).</param>
|
||||
/// <param name="seconds">Duration in seconds.</param>
|
||||
public static void RecordCompilation(string outcome, double seconds)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "outcome", NormalizeTag(outcome) },
|
||||
};
|
||||
|
||||
PolicyCompilationCounter.Add(1, tags);
|
||||
PolicyCompilationSecondsHistogram.Record(seconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a policy simulation execution.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="outcome">Outcome (success, failure).</param>
|
||||
public static void RecordSimulation(string tenant, string outcome)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "outcome", NormalizeTag(outcome) },
|
||||
};
|
||||
|
||||
PolicySimulationCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
#region Golden Signals - Recording Methods
|
||||
|
||||
/// <summary>
|
||||
/// Records API request latency.
|
||||
/// </summary>
|
||||
/// <param name="seconds">Latency in seconds.</param>
|
||||
/// <param name="endpoint">API endpoint name.</param>
|
||||
/// <param name="method">HTTP method.</param>
|
||||
/// <param name="statusCode">HTTP status code.</param>
|
||||
public static void RecordApiLatency(double seconds, string endpoint, string method, int statusCode)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "endpoint", NormalizeTag(endpoint) },
|
||||
{ "method", NormalizeTag(method) },
|
||||
{ "status", statusCode.ToString() },
|
||||
};
|
||||
|
||||
ApiLatencyHistogram.Record(seconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records policy evaluation latency for a batch.
|
||||
/// </summary>
|
||||
/// <param name="seconds">Latency in seconds.</param>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
public static void RecordEvaluationLatency(double seconds, string tenant, string policy)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
};
|
||||
|
||||
EvaluationLatencyHistogram.Record(seconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an API request.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">API endpoint name.</param>
|
||||
/// <param name="method">HTTP method.</param>
|
||||
public static void RecordRequest(string endpoint, string method)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "endpoint", NormalizeTag(endpoint) },
|
||||
{ "method", NormalizeTag(method) },
|
||||
};
|
||||
|
||||
RequestsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a policy evaluation execution.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
/// <param name="mode">Evaluation mode (full, incremental, simulate).</param>
|
||||
public static void RecordEvaluation(string tenant, string policy, string mode)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
{ "mode", NormalizeTag(mode) },
|
||||
};
|
||||
|
||||
EvaluationsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records findings materialized during policy evaluation.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
/// <param name="count">Number of findings materialized.</param>
|
||||
public static void RecordFindingsMaterialized(string tenant, string policy, long count)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
};
|
||||
|
||||
FindingsMaterializedCounter.Add(count, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an error.
|
||||
/// </summary>
|
||||
/// <param name="errorType">Error type (compilation, evaluation, api, storage).</param>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
public static void RecordError(string errorType, string? tenant = null)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "type", NormalizeTag(errorType) },
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
};
|
||||
|
||||
ErrorsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an API error.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">API endpoint name.</param>
|
||||
/// <param name="statusCode">HTTP status code.</param>
|
||||
public static void RecordApiError(string endpoint, int statusCode)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "endpoint", NormalizeTag(endpoint) },
|
||||
{ "status_code", statusCode.ToString() },
|
||||
};
|
||||
|
||||
ApiErrorsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an evaluation failure.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
/// <param name="reason">Failure reason (timeout, determinism, storage, canceled).</param>
|
||||
public static void RecordEvaluationFailure(string tenant, string policy, string reason)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
{ "reason", NormalizeTag(reason) },
|
||||
};
|
||||
|
||||
EvaluationFailuresCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an SLO violation.
|
||||
/// </summary>
|
||||
/// <param name="sloName">Name of the SLO that was violated.</param>
|
||||
public static void RecordSloViolation(string sloName)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "slo_name", NormalizeTag(sloName) },
|
||||
};
|
||||
|
||||
SloViolationsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback to observe concurrent evaluations measurements.
|
||||
/// </summary>
|
||||
/// <param name="observeFunc">Function that returns current concurrent evaluations measurements.</param>
|
||||
public static void RegisterConcurrentEvaluationsObservation(Func<IEnumerable<Measurement<int>>> observeFunc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observeFunc);
|
||||
ConcurrentEvaluationsObservations = observeFunc();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback to observe worker utilization measurements.
|
||||
/// </summary>
|
||||
/// <param name="observeFunc">Function that returns current worker utilization measurements.</param>
|
||||
public static void RegisterWorkerUtilizationObservation(Func<IEnumerable<Measurement<double>>> observeFunc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observeFunc);
|
||||
WorkerUtilizationObservations = observeFunc();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback to observe SLO burn rate measurements.
|
||||
/// </summary>
|
||||
/// <param name="observeFunc">Function that returns current SLO burn rate measurements.</param>
|
||||
public static void RegisterSloBurnRateObservation(Func<IEnumerable<Measurement<double>>> observeFunc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observeFunc);
|
||||
SloBurnRateObservations = observeFunc();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback to observe error budget measurements.
|
||||
/// </summary>
|
||||
/// <param name="observeFunc">Function that returns current error budget measurements.</param>
|
||||
public static void RegisterErrorBudgetObservation(Func<IEnumerable<Measurement<double>>> observeFunc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observeFunc);
|
||||
ErrorBudgetObservations = observeFunc();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for selection layer operations.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policyId">Policy identifier.</param>
|
||||
/// <returns>The started activity, or null if not sampled.</returns>
|
||||
public static Activity? StartSelectActivity(string? tenant, string? policyId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy.select", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant", NormalizeTenant(tenant));
|
||||
activity?.SetTag("policy.id", policyId ?? "unknown");
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for policy evaluation.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policyId">Policy identifier.</param>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <returns>The started activity, or null if not sampled.</returns>
|
||||
public static Activity? StartEvaluateActivity(string? tenant, string? policyId, string? runId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy.evaluate", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant", NormalizeTenant(tenant));
|
||||
activity?.SetTag("policy.id", policyId ?? "unknown");
|
||||
activity?.SetTag("run.id", runId ?? "unknown");
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for materialization operations.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policyId">Policy identifier.</param>
|
||||
/// <param name="batchSize">Number of items in the batch.</param>
|
||||
/// <returns>The started activity, or null if not sampled.</returns>
|
||||
public static Activity? StartMaterializeActivity(string? tenant, string? policyId, int batchSize)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy.materialize", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant", NormalizeTenant(tenant));
|
||||
activity?.SetTag("policy.id", policyId ?? "unknown");
|
||||
activity?.SetTag("batch.size", batchSize);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for simulation operations.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policyId">Policy identifier.</param>
|
||||
/// <returns>The started activity, or null if not sampled.</returns>
|
||||
public static Activity? StartSimulateActivity(string? tenant, string? policyId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy.simulate", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant", NormalizeTenant(tenant));
|
||||
activity?.SetTag("policy.id", policyId ?? "unknown");
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for compilation operations.
|
||||
/// </summary>
|
||||
/// <param name="policyId">Policy identifier.</param>
|
||||
/// <param name="version">Policy version.</param>
|
||||
/// <returns>The started activity, or null if not sampled.</returns>
|
||||
public static Activity? StartCompileActivity(string? policyId, string? version)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy.compile", ActivityKind.Internal);
|
||||
activity?.SetTag("policy.id", policyId ?? "unknown");
|
||||
activity?.SetTag("policy.version", version ?? "unknown");
|
||||
return activity;
|
||||
}
|
||||
|
||||
private static string NormalizeTenant(string? tenant)
|
||||
=> string.IsNullOrWhiteSpace(tenant) ? "default" : tenant;
|
||||
|
||||
private static string NormalizeTag(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? "unknown" : value;
|
||||
}
|
||||
@@ -0,0 +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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 PolicyEvaluationEnvironment 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 evaluation.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationEnvironment
|
||||
{
|
||||
[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 PolicyEvaluationEnvironment
|
||||
{
|
||||
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(PolicyEvaluationEnvironment))]
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
internal partial class PolicyAttestationJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +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
|
||||
{
|
||||
}
|
||||
@@ -0,0 +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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
|
||||
@@ -12,15 +13,18 @@ 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)
|
||||
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)
|
||||
@@ -29,6 +33,19 @@ internal sealed class PolicyEngineBootstrapWorker : BackgroundService
|
||||
options.Authority.Issuer,
|
||||
options.Storage.DatabaseName);
|
||||
|
||||
if (options.RiskProfile.Enabled)
|
||||
{
|
||||
riskProfileService.LoadProfiles();
|
||||
logger.LogInformation(
|
||||
"Risk profile integration enabled. Default profile: {DefaultProfileId}. Loaded profiles: {ProfileCount}.",
|
||||
riskProfileService.DefaultProfileId,
|
||||
riskProfileService.GetProfileIds().Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Risk profile integration is disabled.");
|
||||
}
|
||||
|
||||
diagnostics.MarkReady();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user