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

This commit is contained in:
StellaOps Bot
2025-11-27 21:45:32 +02:00
510 changed files with 138401 additions and 51276 deletions

View File

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

View File

@@ -1,283 +1,283 @@
using System;
using System.Collections.Immutable;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Compilation;
/// <summary>
/// Computes deterministic complexity metrics for compiled policies.
/// </summary>
internal sealed class PolicyComplexityAnalyzer
{
public PolicyComplexityReport Analyze(PolicyIrDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var metrics = new ComplexityMetrics();
metrics.RuleCount = document.Rules.IsDefault ? 0 : document.Rules.Length;
VisitMetadata(document.Metadata.Values, metrics);
VisitMetadata(document.Settings.Values, metrics);
VisitProfiles(document.Profiles, metrics);
if (!document.Rules.IsDefaultOrEmpty)
{
foreach (var rule in document.Rules)
{
metrics.ConditionCount++;
VisitExpression(rule.When, metrics, depth: 0);
VisitActions(rule.ThenActions, metrics);
VisitActions(rule.ElseActions, metrics);
}
}
var score = CalculateScore(metrics);
var roundedScore = Math.Round(score, 3, MidpointRounding.AwayFromZero);
return new PolicyComplexityReport(
roundedScore,
metrics.RuleCount,
metrics.ActionCount,
metrics.ExpressionCount,
metrics.InvocationCount,
metrics.MemberAccessCount,
metrics.IdentifierCount,
metrics.LiteralCount,
metrics.MaxDepth,
metrics.ProfileCount,
metrics.ProfileBindings,
metrics.ConditionCount,
metrics.ListItems);
}
private static void VisitProfiles(ImmutableArray<PolicyIrProfile> profiles, ComplexityMetrics metrics)
{
if (profiles.IsDefaultOrEmpty)
{
return;
}
foreach (var profile in profiles)
{
metrics.ProfileCount++;
if (!profile.Maps.IsDefaultOrEmpty)
{
foreach (var map in profile.Maps)
{
if (map.Entries.IsDefaultOrEmpty)
{
continue;
}
foreach (var entry in map.Entries)
{
metrics.ProfileBindings++;
metrics.LiteralCount++; // weight values contribute to literal count
}
}
}
if (!profile.Environments.IsDefaultOrEmpty)
{
foreach (var environment in profile.Environments)
{
if (environment.Entries.IsDefaultOrEmpty)
{
continue;
}
foreach (var entry in environment.Entries)
{
metrics.ProfileBindings++;
metrics.ConditionCount++;
VisitExpression(entry.Condition, metrics, depth: 0);
}
}
}
if (!profile.Scalars.IsDefaultOrEmpty)
{
foreach (var scalar in profile.Scalars)
{
metrics.ProfileBindings++;
VisitLiteral(scalar.Value, metrics);
}
}
}
}
private static void VisitMetadata(IEnumerable<PolicyIrLiteral> literals, ComplexityMetrics metrics)
{
foreach (var literal in literals)
{
VisitLiteral(literal, metrics);
}
}
private static void VisitLiteral(PolicyIrLiteral literal, ComplexityMetrics metrics)
{
switch (literal)
{
case PolicyIrListLiteral list when !list.Items.IsDefaultOrEmpty:
foreach (var item in list.Items)
{
VisitLiteral(item, metrics);
}
break;
}
metrics.LiteralCount++;
}
private static void VisitActions(ImmutableArray<PolicyIrAction> actions, ComplexityMetrics metrics)
{
if (actions.IsDefaultOrEmpty)
{
return;
}
foreach (var action in actions)
{
metrics.ActionCount++;
switch (action)
{
case PolicyIrAssignmentAction assignment:
VisitExpression(assignment.Value, metrics, depth: 0);
break;
case PolicyIrAnnotateAction annotate:
VisitExpression(annotate.Value, metrics, depth: 0);
break;
case PolicyIrIgnoreAction ignore when ignore.Until is not null:
VisitExpression(ignore.Until, metrics, depth: 0);
break;
case PolicyIrEscalateAction escalate:
VisitExpression(escalate.To, metrics, depth: 0);
VisitExpression(escalate.When, metrics, depth: 0);
break;
case PolicyIrRequireVexAction require when !require.Conditions.IsEmpty:
foreach (var condition in require.Conditions.Values)
{
VisitExpression(condition, metrics, depth: 0);
}
break;
case PolicyIrWarnAction warn when warn.Message is not null:
VisitExpression(warn.Message, metrics, depth: 0);
break;
case PolicyIrDeferAction defer when defer.Until is not null:
VisitExpression(defer.Until, metrics, depth: 0);
break;
}
}
}
private static void VisitExpression(PolicyExpression? expression, ComplexityMetrics metrics, int depth)
{
if (expression is null)
{
return;
}
metrics.ExpressionCount++;
var currentDepth = depth + 1;
if (currentDepth > metrics.MaxDepth)
{
metrics.MaxDepth = currentDepth;
}
switch (expression)
{
case PolicyLiteralExpression:
metrics.LiteralCount++;
break;
case PolicyListExpression listExpression:
if (!listExpression.Items.IsDefaultOrEmpty)
{
foreach (var item in listExpression.Items)
{
metrics.ListItems++;
VisitExpression(item, metrics, currentDepth);
}
}
break;
case PolicyIdentifierExpression:
metrics.IdentifierCount++;
break;
case PolicyMemberAccessExpression member:
metrics.MemberAccessCount++;
VisitExpression(member.Target, metrics, currentDepth);
break;
case PolicyInvocationExpression invocation:
metrics.InvocationCount++;
VisitExpression(invocation.Target, metrics, currentDepth);
if (!invocation.Arguments.IsDefaultOrEmpty)
{
foreach (var argument in invocation.Arguments)
{
VisitExpression(argument, metrics, currentDepth);
}
}
break;
case PolicyIndexerExpression indexer:
VisitExpression(indexer.Target, metrics, currentDepth);
VisitExpression(indexer.Index, metrics, currentDepth);
break;
case PolicyUnaryExpression unary:
VisitExpression(unary.Operand, metrics, currentDepth);
break;
case PolicyBinaryExpression binary:
VisitExpression(binary.Left, metrics, currentDepth);
VisitExpression(binary.Right, metrics, currentDepth);
break;
default:
break;
}
}
private static double CalculateScore(ComplexityMetrics metrics)
{
return metrics.RuleCount * 5d
+ metrics.ActionCount * 1.5d
+ metrics.ExpressionCount * 0.75d
+ metrics.InvocationCount * 1.5d
+ metrics.MemberAccessCount * 1.0d
+ metrics.IdentifierCount * 0.5d
+ metrics.LiteralCount * 0.25d
+ metrics.ProfileBindings * 0.5d
+ metrics.ConditionCount * 1.25d
+ metrics.MaxDepth * 2d
+ metrics.ListItems * 0.25d;
}
private sealed class ComplexityMetrics
{
public int RuleCount;
public int ActionCount;
public int ExpressionCount;
public int InvocationCount;
public int MemberAccessCount;
public int IdentifierCount;
public int LiteralCount;
public int ProfileCount;
public int ProfileBindings;
public int ConditionCount;
public int MaxDepth;
public int ListItems;
}
}
internal sealed record PolicyComplexityReport(
double Score,
int RuleCount,
int ActionCount,
int ExpressionCount,
int InvocationCount,
int MemberAccessCount,
int IdentifierCount,
int LiteralCount,
int MaxExpressionDepth,
int ProfileCount,
int ProfileBindingCount,
int ConditionCount,
int ListItemCount);
using System;
using System.Collections.Immutable;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Compilation;
/// <summary>
/// Computes deterministic complexity metrics for compiled policies.
/// </summary>
internal sealed class PolicyComplexityAnalyzer
{
public PolicyComplexityReport Analyze(PolicyIrDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var metrics = new ComplexityMetrics();
metrics.RuleCount = document.Rules.IsDefault ? 0 : document.Rules.Length;
VisitMetadata(document.Metadata.Values, metrics);
VisitMetadata(document.Settings.Values, metrics);
VisitProfiles(document.Profiles, metrics);
if (!document.Rules.IsDefaultOrEmpty)
{
foreach (var rule in document.Rules)
{
metrics.ConditionCount++;
VisitExpression(rule.When, metrics, depth: 0);
VisitActions(rule.ThenActions, metrics);
VisitActions(rule.ElseActions, metrics);
}
}
var score = CalculateScore(metrics);
var roundedScore = Math.Round(score, 3, MidpointRounding.AwayFromZero);
return new PolicyComplexityReport(
roundedScore,
metrics.RuleCount,
metrics.ActionCount,
metrics.ExpressionCount,
metrics.InvocationCount,
metrics.MemberAccessCount,
metrics.IdentifierCount,
metrics.LiteralCount,
metrics.MaxDepth,
metrics.ProfileCount,
metrics.ProfileBindings,
metrics.ConditionCount,
metrics.ListItems);
}
private static void VisitProfiles(ImmutableArray<PolicyIrProfile> profiles, ComplexityMetrics metrics)
{
if (profiles.IsDefaultOrEmpty)
{
return;
}
foreach (var profile in profiles)
{
metrics.ProfileCount++;
if (!profile.Maps.IsDefaultOrEmpty)
{
foreach (var map in profile.Maps)
{
if (map.Entries.IsDefaultOrEmpty)
{
continue;
}
foreach (var entry in map.Entries)
{
metrics.ProfileBindings++;
metrics.LiteralCount++; // weight values contribute to literal count
}
}
}
if (!profile.Environments.IsDefaultOrEmpty)
{
foreach (var environment in profile.Environments)
{
if (environment.Entries.IsDefaultOrEmpty)
{
continue;
}
foreach (var entry in environment.Entries)
{
metrics.ProfileBindings++;
metrics.ConditionCount++;
VisitExpression(entry.Condition, metrics, depth: 0);
}
}
}
if (!profile.Scalars.IsDefaultOrEmpty)
{
foreach (var scalar in profile.Scalars)
{
metrics.ProfileBindings++;
VisitLiteral(scalar.Value, metrics);
}
}
}
}
private static void VisitMetadata(IEnumerable<PolicyIrLiteral> literals, ComplexityMetrics metrics)
{
foreach (var literal in literals)
{
VisitLiteral(literal, metrics);
}
}
private static void VisitLiteral(PolicyIrLiteral literal, ComplexityMetrics metrics)
{
switch (literal)
{
case PolicyIrListLiteral list when !list.Items.IsDefaultOrEmpty:
foreach (var item in list.Items)
{
VisitLiteral(item, metrics);
}
break;
}
metrics.LiteralCount++;
}
private static void VisitActions(ImmutableArray<PolicyIrAction> actions, ComplexityMetrics metrics)
{
if (actions.IsDefaultOrEmpty)
{
return;
}
foreach (var action in actions)
{
metrics.ActionCount++;
switch (action)
{
case PolicyIrAssignmentAction assignment:
VisitExpression(assignment.Value, metrics, depth: 0);
break;
case PolicyIrAnnotateAction annotate:
VisitExpression(annotate.Value, metrics, depth: 0);
break;
case PolicyIrIgnoreAction ignore when ignore.Until is not null:
VisitExpression(ignore.Until, metrics, depth: 0);
break;
case PolicyIrEscalateAction escalate:
VisitExpression(escalate.To, metrics, depth: 0);
VisitExpression(escalate.When, metrics, depth: 0);
break;
case PolicyIrRequireVexAction require when !require.Conditions.IsEmpty:
foreach (var condition in require.Conditions.Values)
{
VisitExpression(condition, metrics, depth: 0);
}
break;
case PolicyIrWarnAction warn when warn.Message is not null:
VisitExpression(warn.Message, metrics, depth: 0);
break;
case PolicyIrDeferAction defer when defer.Until is not null:
VisitExpression(defer.Until, metrics, depth: 0);
break;
}
}
}
private static void VisitExpression(PolicyExpression? expression, ComplexityMetrics metrics, int depth)
{
if (expression is null)
{
return;
}
metrics.ExpressionCount++;
var currentDepth = depth + 1;
if (currentDepth > metrics.MaxDepth)
{
metrics.MaxDepth = currentDepth;
}
switch (expression)
{
case PolicyLiteralExpression:
metrics.LiteralCount++;
break;
case PolicyListExpression listExpression:
if (!listExpression.Items.IsDefaultOrEmpty)
{
foreach (var item in listExpression.Items)
{
metrics.ListItems++;
VisitExpression(item, metrics, currentDepth);
}
}
break;
case PolicyIdentifierExpression:
metrics.IdentifierCount++;
break;
case PolicyMemberAccessExpression member:
metrics.MemberAccessCount++;
VisitExpression(member.Target, metrics, currentDepth);
break;
case PolicyInvocationExpression invocation:
metrics.InvocationCount++;
VisitExpression(invocation.Target, metrics, currentDepth);
if (!invocation.Arguments.IsDefaultOrEmpty)
{
foreach (var argument in invocation.Arguments)
{
VisitExpression(argument, metrics, currentDepth);
}
}
break;
case PolicyIndexerExpression indexer:
VisitExpression(indexer.Target, metrics, currentDepth);
VisitExpression(indexer.Index, metrics, currentDepth);
break;
case PolicyUnaryExpression unary:
VisitExpression(unary.Operand, metrics, currentDepth);
break;
case PolicyBinaryExpression binary:
VisitExpression(binary.Left, metrics, currentDepth);
VisitExpression(binary.Right, metrics, currentDepth);
break;
default:
break;
}
}
private static double CalculateScore(ComplexityMetrics metrics)
{
return metrics.RuleCount * 5d
+ metrics.ActionCount * 1.5d
+ metrics.ExpressionCount * 0.75d
+ metrics.InvocationCount * 1.5d
+ metrics.MemberAccessCount * 1.0d
+ metrics.IdentifierCount * 0.5d
+ metrics.LiteralCount * 0.25d
+ metrics.ProfileBindings * 0.5d
+ metrics.ConditionCount * 1.25d
+ metrics.MaxDepth * 2d
+ metrics.ListItems * 0.25d;
}
private sealed class ComplexityMetrics
{
public int RuleCount;
public int ActionCount;
public int ExpressionCount;
public int InvocationCount;
public int MemberAccessCount;
public int IdentifierCount;
public int LiteralCount;
public int ProfileCount;
public int ProfileBindings;
public int ConditionCount;
public int MaxDepth;
public int ListItems;
}
}
internal sealed record PolicyComplexityReport(
double Score,
int RuleCount,
int ActionCount,
int ExpressionCount,
int InvocationCount,
int MemberAccessCount,
int IdentifierCount,
int LiteralCount,
int MaxExpressionDepth,
int ProfileCount,
int ProfileBindingCount,
int ConditionCount,
int ListItemCount);

View File

@@ -1,150 +1,150 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Services;
using System.Collections.Immutable;
namespace StellaOps.Policy.Engine.Endpoints;
internal static class PolicyCompilationEndpoints
{
private const string CompileRoute = "/api/policy/policies/{policyId}/versions/{version}:compile";
public static IEndpointRouteBuilder MapPolicyCompilation(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost(CompileRoute, CompilePolicy)
.WithName("CompilePolicy")
.WithSummary("Compile and lint a policy DSL document.")
.WithDescription("Compiles a stella-dsl@1 policy document and returns deterministic digest and statistics.")
.Produces<PolicyCompileResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.RequireAuthorization(); // scopes enforced by policy middleware.
return endpoints;
}
private static IResult CompilePolicy(
[FromRoute] string policyId,
[FromRoute] int version,
[FromBody] PolicyCompileRequest request,
PolicyCompilationService compilationService)
{
if (request is null)
{
return Results.BadRequest(BuildProblem("ERR_POL_001", "Request body missing.", policyId, version));
}
var result = compilationService.Compile(request);
if (!result.Success)
{
return Results.BadRequest(BuildProblem("ERR_POL_001", "Policy compilation failed.", policyId, version, result.Diagnostics));
}
var response = new PolicyCompileResponse(
result.Digest!,
result.Statistics ?? new PolicyCompilationStatistics(0, ImmutableDictionary<string, int>.Empty),
MapComplexity(result.Complexity),
result.DurationMilliseconds,
ConvertDiagnostics(result.Diagnostics));
return Results.Ok(response);
}
private static PolicyProblemDetails BuildProblem(string code, string message, string policyId, int version, ImmutableArray<PolicyIssue>? diagnostics = null)
{
var problem = new PolicyProblemDetails
{
Code = code,
Title = "Policy compilation error",
Detail = message,
PolicyId = policyId,
PolicyVersion = version
};
if (diagnostics is { Length: > 0 } diag)
{
problem.Diagnostics = diag;
}
return problem;
}
private static ImmutableArray<PolicyDiagnosticDto> ConvertDiagnostics(ImmutableArray<PolicyIssue> issues)
{
if (issues.IsDefaultOrEmpty)
{
return ImmutableArray<PolicyDiagnosticDto>.Empty;
}
var builder = ImmutableArray.CreateBuilder<PolicyDiagnosticDto>(issues.Length);
foreach (var issue in issues)
{
if (issue.Severity != PolicyIssueSeverity.Warning)
{
continue;
}
builder.Add(new PolicyDiagnosticDto(issue.Code, issue.Message, issue.Path));
}
return builder.ToImmutable();
}
private static PolicyComplexityReportDto? MapComplexity(PolicyComplexityReport? report)
{
if (report is null)
{
return null;
}
return new PolicyComplexityReportDto(
report.Score,
report.RuleCount,
report.ActionCount,
report.ExpressionCount,
report.InvocationCount,
report.MemberAccessCount,
report.IdentifierCount,
report.LiteralCount,
report.MaxExpressionDepth,
report.ProfileCount,
report.ProfileBindingCount,
report.ConditionCount,
report.ListItemCount);
}
private sealed class PolicyProblemDetails : ProblemDetails
{
public string Code { get; set; } = "ERR_POL_001";
public string? PolicyId { get; set; }
public int PolicyVersion { get; set; }
public ImmutableArray<PolicyIssue> Diagnostics { get; set; } = ImmutableArray<PolicyIssue>.Empty;
}
}
internal sealed record PolicyCompileResponse(
string Digest,
PolicyCompilationStatistics Statistics,
PolicyComplexityReportDto? Complexity,
long DurationMilliseconds,
ImmutableArray<PolicyDiagnosticDto> Warnings);
internal sealed record PolicyDiagnosticDto(string Code, string Message, string Path);
internal sealed record PolicyComplexityReportDto(
double Score,
int RuleCount,
int ActionCount,
int ExpressionCount,
int InvocationCount,
int MemberAccessCount,
int IdentifierCount,
int LiteralCount,
int MaxExpressionDepth,
int ProfileCount,
int ProfileBindingCount,
int ConditionCount,
int ListItemCount);
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Services;
using System.Collections.Immutable;
namespace StellaOps.Policy.Engine.Endpoints;
internal static class PolicyCompilationEndpoints
{
private const string CompileRoute = "/api/policy/policies/{policyId}/versions/{version}:compile";
public static IEndpointRouteBuilder MapPolicyCompilation(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost(CompileRoute, CompilePolicy)
.WithName("CompilePolicy")
.WithSummary("Compile and lint a policy DSL document.")
.WithDescription("Compiles a stella-dsl@1 policy document and returns deterministic digest and statistics.")
.Produces<PolicyCompileResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.RequireAuthorization(); // scopes enforced by policy middleware.
return endpoints;
}
private static IResult CompilePolicy(
[FromRoute] string policyId,
[FromRoute] int version,
[FromBody] PolicyCompileRequest request,
PolicyCompilationService compilationService)
{
if (request is null)
{
return Results.BadRequest(BuildProblem("ERR_POL_001", "Request body missing.", policyId, version));
}
var result = compilationService.Compile(request);
if (!result.Success)
{
return Results.BadRequest(BuildProblem("ERR_POL_001", "Policy compilation failed.", policyId, version, result.Diagnostics));
}
var response = new PolicyCompileResponse(
result.Digest!,
result.Statistics ?? new PolicyCompilationStatistics(0, ImmutableDictionary<string, int>.Empty),
MapComplexity(result.Complexity),
result.DurationMilliseconds,
ConvertDiagnostics(result.Diagnostics));
return Results.Ok(response);
}
private static PolicyProblemDetails BuildProblem(string code, string message, string policyId, int version, ImmutableArray<PolicyIssue>? diagnostics = null)
{
var problem = new PolicyProblemDetails
{
Code = code,
Title = "Policy compilation error",
Detail = message,
PolicyId = policyId,
PolicyVersion = version
};
if (diagnostics is { Length: > 0 } diag)
{
problem.Diagnostics = diag;
}
return problem;
}
private static ImmutableArray<PolicyDiagnosticDto> ConvertDiagnostics(ImmutableArray<PolicyIssue> issues)
{
if (issues.IsDefaultOrEmpty)
{
return ImmutableArray<PolicyDiagnosticDto>.Empty;
}
var builder = ImmutableArray.CreateBuilder<PolicyDiagnosticDto>(issues.Length);
foreach (var issue in issues)
{
if (issue.Severity != PolicyIssueSeverity.Warning)
{
continue;
}
builder.Add(new PolicyDiagnosticDto(issue.Code, issue.Message, issue.Path));
}
return builder.ToImmutable();
}
private static PolicyComplexityReportDto? MapComplexity(PolicyComplexityReport? report)
{
if (report is null)
{
return null;
}
return new PolicyComplexityReportDto(
report.Score,
report.RuleCount,
report.ActionCount,
report.ExpressionCount,
report.InvocationCount,
report.MemberAccessCount,
report.IdentifierCount,
report.LiteralCount,
report.MaxExpressionDepth,
report.ProfileCount,
report.ProfileBindingCount,
report.ConditionCount,
report.ListItemCount);
}
private sealed class PolicyProblemDetails : ProblemDetails
{
public string Code { get; set; } = "ERR_POL_001";
public string? PolicyId { get; set; }
public int PolicyVersion { get; set; }
public ImmutableArray<PolicyIssue> Diagnostics { get; set; } = ImmutableArray<PolicyIssue>.Empty;
}
}
internal sealed record PolicyCompileResponse(
string Digest,
PolicyCompilationStatistics Statistics,
PolicyComplexityReportDto? Complexity,
long DurationMilliseconds,
ImmutableArray<PolicyDiagnosticDto> Warnings);
internal sealed record PolicyDiagnosticDto(string Code, string Message, string Path);
internal sealed record PolicyComplexityReportDto(
double Score,
int RuleCount,
int ActionCount,
int ExpressionCount,
int InvocationCount,
int MemberAccessCount,
int IdentifierCount,
int LiteralCount,
int MaxExpressionDepth,
int ProfileCount,
int ProfileBindingCount,
int ConditionCount,
int ListItemCount);

View File

@@ -1,30 +1,30 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Services;
namespace StellaOps.Policy.Engine.Endpoints;
internal static class PolicyPackEndpoints
{
public static IEndpointRouteBuilder MapPolicyPacks(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/policy/packs")
.RequireAuthorization()
.WithTags("Policy Packs");
group.MapPost(string.Empty, CreatePack)
.WithName("CreatePolicyPack")
.WithSummary("Create a new policy pack container.")
.Produces<PolicyPackDto>(StatusCodes.Status201Created);
group.MapGet(string.Empty, ListPacks)
.WithName("ListPolicyPacks")
.WithSummary("List policy packs for the current tenant.")
.Produces<IReadOnlyList<PolicyPackSummaryDto>>(StatusCodes.Status200OK);
using System.Security.Claims;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Services;
namespace StellaOps.Policy.Engine.Endpoints;
internal static class PolicyPackEndpoints
{
public static IEndpointRouteBuilder MapPolicyPacks(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/policy/packs")
.RequireAuthorization()
.WithTags("Policy Packs");
group.MapPost(string.Empty, CreatePack)
.WithName("CreatePolicyPack")
.WithSummary("Create a new policy pack container.")
.Produces<PolicyPackDto>(StatusCodes.Status201Created);
group.MapGet(string.Empty, ListPacks)
.WithName("ListPolicyPacks")
.WithSummary("List policy packs for the current tenant.")
.Produces<IReadOnlyList<PolicyPackSummaryDto>>(StatusCodes.Status200OK);
group.MapPost("/{packId}/revisions", CreateRevision)
.WithName("CreatePolicyRevision")
.WithSummary("Create or update policy revision metadata.")
@@ -49,149 +49,149 @@ internal static class PolicyPackEndpoints
.WithSummary("Activate an approved policy revision, enforcing two-person approval when required.")
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status200OK)
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status202Accepted)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
return endpoints;
}
private static async Task<IResult> CreatePack(
HttpContext context,
[FromBody] CreatePolicyPackRequest request,
IPolicyPackRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
if (scopeResult is not null)
{
return scopeResult;
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "Request body is required.",
Status = StatusCodes.Status400BadRequest
});
}
var packId = string.IsNullOrWhiteSpace(request.PackId)
? $"pack-{Guid.NewGuid():n}"
: request.PackId.Trim();
var pack = await repository.CreateAsync(packId, request.DisplayName?.Trim(), cancellationToken).ConfigureAwait(false);
var dto = PolicyPackMapper.ToDto(pack);
return Results.Created($"/api/policy/packs/{dto.PackId}", dto);
}
private static async Task<IResult> ListPacks(
HttpContext context,
IPolicyPackRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var packs = await repository.ListAsync(cancellationToken).ConfigureAwait(false);
var summaries = packs.Select(PolicyPackMapper.ToSummaryDto).ToArray();
return Results.Ok(summaries);
}
private static async Task<IResult> CreateRevision(
HttpContext context,
[FromRoute] string packId,
[FromBody] CreatePolicyRevisionRequest request,
IPolicyPackRepository repository,
IPolicyActivationSettings activationSettings,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
if (scopeResult is not null)
{
return scopeResult;
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "Request body is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request.InitialStatus is not (PolicyRevisionStatus.Draft or PolicyRevisionStatus.Approved))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid status",
Detail = "Only Draft or Approved statuses are supported for new revisions.",
Status = StatusCodes.Status400BadRequest
});
}
var requiresTwoPersonApproval = activationSettings.ResolveRequirement(request.RequiresTwoPersonApproval);
var revision = await repository.UpsertRevisionAsync(
packId,
request.Version ?? 0,
requiresTwoPersonApproval,
request.InitialStatus,
cancellationToken).ConfigureAwait(false);
return Results.Created(
$"/api/policy/packs/{packId}/revisions/{revision.Version}",
PolicyPackMapper.ToDto(packId, revision));
}
private static async Task<IResult> ActivateRevision(
HttpContext context,
[FromRoute] string packId,
[FromRoute] int version,
[FromBody] ActivatePolicyRevisionRequest request,
IPolicyPackRepository repository,
IPolicyActivationAuditor auditor,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
if (scopeResult is not null)
{
return scopeResult;
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "Request body is required.",
Status = StatusCodes.Status400BadRequest
});
}
var actorId = ResolveActorId(context);
if (actorId is null)
{
return Results.Problem("Actor identity required.", statusCode: StatusCodes.Status401Unauthorized);
}
var result = await repository.RecordActivationAsync(
packId,
version,
actorId,
DateTimeOffset.UtcNow,
request.Comment,
cancellationToken).ConfigureAwait(false);
var tenantId = context.User?.FindFirst(StellaOpsClaimTypes.Tenant)?.Value;
auditor.RecordActivation(packId, version, actorId, tenantId, result, request.Comment);
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
return endpoints;
}
private static async Task<IResult> CreatePack(
HttpContext context,
[FromBody] CreatePolicyPackRequest request,
IPolicyPackRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
if (scopeResult is not null)
{
return scopeResult;
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "Request body is required.",
Status = StatusCodes.Status400BadRequest
});
}
var packId = string.IsNullOrWhiteSpace(request.PackId)
? $"pack-{Guid.NewGuid():n}"
: request.PackId.Trim();
var pack = await repository.CreateAsync(packId, request.DisplayName?.Trim(), cancellationToken).ConfigureAwait(false);
var dto = PolicyPackMapper.ToDto(pack);
return Results.Created($"/api/policy/packs/{dto.PackId}", dto);
}
private static async Task<IResult> ListPacks(
HttpContext context,
IPolicyPackRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var packs = await repository.ListAsync(cancellationToken).ConfigureAwait(false);
var summaries = packs.Select(PolicyPackMapper.ToSummaryDto).ToArray();
return Results.Ok(summaries);
}
private static async Task<IResult> CreateRevision(
HttpContext context,
[FromRoute] string packId,
[FromBody] CreatePolicyRevisionRequest request,
IPolicyPackRepository repository,
IPolicyActivationSettings activationSettings,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
if (scopeResult is not null)
{
return scopeResult;
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "Request body is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request.InitialStatus is not (PolicyRevisionStatus.Draft or PolicyRevisionStatus.Approved))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid status",
Detail = "Only Draft or Approved statuses are supported for new revisions.",
Status = StatusCodes.Status400BadRequest
});
}
var requiresTwoPersonApproval = activationSettings.ResolveRequirement(request.RequiresTwoPersonApproval);
var revision = await repository.UpsertRevisionAsync(
packId,
request.Version ?? 0,
requiresTwoPersonApproval,
request.InitialStatus,
cancellationToken).ConfigureAwait(false);
return Results.Created(
$"/api/policy/packs/{packId}/revisions/{revision.Version}",
PolicyPackMapper.ToDto(packId, revision));
}
private static async Task<IResult> ActivateRevision(
HttpContext context,
[FromRoute] string packId,
[FromRoute] int version,
[FromBody] ActivatePolicyRevisionRequest request,
IPolicyPackRepository repository,
IPolicyActivationAuditor auditor,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
if (scopeResult is not null)
{
return scopeResult;
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "Request body is required.",
Status = StatusCodes.Status400BadRequest
});
}
var actorId = ResolveActorId(context);
if (actorId is null)
{
return Results.Problem("Actor identity required.", statusCode: StatusCodes.Status401Unauthorized);
}
var result = await repository.RecordActivationAsync(
packId,
version,
actorId,
DateTimeOffset.UtcNow,
request.Comment,
cancellationToken).ConfigureAwait(false);
var tenantId = context.User?.FindFirst(StellaOpsClaimTypes.Tenant)?.Value;
auditor.RecordActivation(packId, version, actorId, tenantId, result, request.Comment);
return result.Status switch
{
PolicyActivationResultStatus.PackNotFound => Results.NotFound(new ProblemDetails
@@ -199,29 +199,29 @@ internal static class PolicyPackEndpoints
Title = "Policy pack not found",
Status = StatusCodes.Status404NotFound
}),
PolicyActivationResultStatus.RevisionNotFound => Results.NotFound(new ProblemDetails
{
Title = "Policy revision not found",
Status = StatusCodes.Status404NotFound
}),
PolicyActivationResultStatus.NotApproved => Results.BadRequest(new ProblemDetails
{
Title = "Revision not approved",
Detail = "Only approved revisions may be activated.",
Status = StatusCodes.Status400BadRequest
}),
PolicyActivationResultStatus.DuplicateApproval => Results.BadRequest(new ProblemDetails
{
Title = "Approval already recorded",
Detail = "This approver has already approved activation.",
Status = StatusCodes.Status400BadRequest
}),
PolicyActivationResultStatus.PendingSecondApproval => Results.Accepted(
$"/api/policy/packs/{packId}/revisions/{version}",
new PolicyRevisionActivationResponse("pending_second_approval", PolicyPackMapper.ToDto(packId, result.Revision!))),
PolicyActivationResultStatus.Activated => Results.Ok(new PolicyRevisionActivationResponse("activated", PolicyPackMapper.ToDto(packId, result.Revision!))),
PolicyActivationResultStatus.AlreadyActive => Results.Ok(new PolicyRevisionActivationResponse("already_active", PolicyPackMapper.ToDto(packId, result.Revision!))),
_ => Results.BadRequest(new ProblemDetails
PolicyActivationResultStatus.RevisionNotFound => Results.NotFound(new ProblemDetails
{
Title = "Policy revision not found",
Status = StatusCodes.Status404NotFound
}),
PolicyActivationResultStatus.NotApproved => Results.BadRequest(new ProblemDetails
{
Title = "Revision not approved",
Detail = "Only approved revisions may be activated.",
Status = StatusCodes.Status400BadRequest
}),
PolicyActivationResultStatus.DuplicateApproval => Results.BadRequest(new ProblemDetails
{
Title = "Approval already recorded",
Detail = "This approver has already approved activation.",
Status = StatusCodes.Status400BadRequest
}),
PolicyActivationResultStatus.PendingSecondApproval => Results.Accepted(
$"/api/policy/packs/{packId}/revisions/{version}",
new PolicyRevisionActivationResponse("pending_second_approval", PolicyPackMapper.ToDto(packId, result.Revision!))),
PolicyActivationResultStatus.Activated => Results.Ok(new PolicyRevisionActivationResponse("activated", PolicyPackMapper.ToDto(packId, result.Revision!))),
PolicyActivationResultStatus.AlreadyActive => Results.Ok(new PolicyRevisionActivationResponse("already_active", PolicyPackMapper.ToDto(packId, result.Revision!))),
_ => Results.BadRequest(new ProblemDetails
{
Title = "Activation failed",
Detail = "Unknown activation result.",
@@ -323,57 +323,57 @@ internal static class PolicyPackEndpoints
}
private static string? ResolveActorId(HttpContext context)
{
var user = context.User;
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user?.FindFirst(ClaimTypes.Upn)?.Value
?? user?.FindFirst("sub")?.Value;
if (!string.IsNullOrWhiteSpace(actor))
{
return actor;
}
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
{
return header.ToString();
}
return null;
}
}
internal static class PolicyPackMapper
{
public static PolicyPackDto ToDto(PolicyPackRecord record)
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => ToDto(record.PackId, r)).ToArray());
public static PolicyPackSummaryDto ToSummaryDto(PolicyPackRecord record)
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => r.Version).ToArray());
public static PolicyRevisionDto ToDto(string packId, PolicyRevisionRecord revision)
=> new(
packId,
revision.Version,
revision.Status.ToString(),
revision.RequiresTwoPersonApproval,
revision.CreatedAt,
revision.ActivatedAt,
revision.Approvals.Select(a => new PolicyActivationApprovalDto(a.ActorId, a.ApprovedAt, a.Comment)).ToArray());
}
internal sealed record CreatePolicyPackRequest(string? PackId, string? DisplayName);
internal sealed record PolicyPackDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<PolicyRevisionDto> Revisions);
internal sealed record PolicyPackSummaryDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<int> Versions);
internal sealed record CreatePolicyRevisionRequest(int? Version, bool? RequiresTwoPersonApproval, PolicyRevisionStatus InitialStatus = PolicyRevisionStatus.Approved);
internal sealed record PolicyRevisionDto(string PackId, int Version, string Status, bool RequiresTwoPersonApproval, DateTimeOffset CreatedAt, DateTimeOffset? ActivatedAt, IReadOnlyList<PolicyActivationApprovalDto> Approvals);
internal sealed record PolicyActivationApprovalDto(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
internal sealed record ActivatePolicyRevisionRequest(string? Comment);
internal sealed record PolicyRevisionActivationResponse(string Status, PolicyRevisionDto Revision);
{
var user = context.User;
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user?.FindFirst(ClaimTypes.Upn)?.Value
?? user?.FindFirst("sub")?.Value;
if (!string.IsNullOrWhiteSpace(actor))
{
return actor;
}
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
{
return header.ToString();
}
return null;
}
}
internal static class PolicyPackMapper
{
public static PolicyPackDto ToDto(PolicyPackRecord record)
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => ToDto(record.PackId, r)).ToArray());
public static PolicyPackSummaryDto ToSummaryDto(PolicyPackRecord record)
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => r.Version).ToArray());
public static PolicyRevisionDto ToDto(string packId, PolicyRevisionRecord revision)
=> new(
packId,
revision.Version,
revision.Status.ToString(),
revision.RequiresTwoPersonApproval,
revision.CreatedAt,
revision.ActivatedAt,
revision.Approvals.Select(a => new PolicyActivationApprovalDto(a.ActorId, a.ApprovedAt, a.Comment)).ToArray());
}
internal sealed record CreatePolicyPackRequest(string? PackId, string? DisplayName);
internal sealed record PolicyPackDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<PolicyRevisionDto> Revisions);
internal sealed record PolicyPackSummaryDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<int> Versions);
internal sealed record CreatePolicyRevisionRequest(int? Version, bool? RequiresTwoPersonApproval, PolicyRevisionStatus InitialStatus = PolicyRevisionStatus.Approved);
internal sealed record PolicyRevisionDto(string PackId, int Version, string Status, bool RequiresTwoPersonApproval, DateTimeOffset CreatedAt, DateTimeOffset? ActivatedAt, IReadOnlyList<PolicyActivationApprovalDto> Approvals);
internal sealed record PolicyActivationApprovalDto(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
internal sealed record ActivatePolicyRevisionRequest(string? Comment);
internal sealed record PolicyRevisionActivationResponse(string Status, PolicyRevisionDto Revision);

View File

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

View File

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

View File

@@ -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>&lt;= 0</c> to disable.
/// </summary>
public double MaxComplexityScore { get; set; } = 750d;
/// <summary>
/// Maximum allowed compilation wall-clock duration in milliseconds. Set to <c>&lt;= 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>&lt;= 0</c> to disable.
/// </summary>
public double MaxComplexityScore { get; set; } = 750d;
/// <summary>
/// Maximum allowed compilation wall-clock duration in milliseconds. Set to <c>&lt;= 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; }
}

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,100 +1,100 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Options;
namespace StellaOps.Policy.Engine.Services;
internal interface IPolicyActivationAuditor
{
void RecordActivation(
string packId,
int version,
string actorId,
string? tenantId,
PolicyActivationResult result,
string? comment);
}
internal sealed class PolicyActivationAuditor : IPolicyActivationAuditor
{
private const int CommentLimit = 512;
private readonly PolicyEngineOptions options;
private readonly ILogger<PolicyActivationAuditor> logger;
public PolicyActivationAuditor(
PolicyEngineOptions options,
ILogger<PolicyActivationAuditor> logger)
{
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void RecordActivation(
string packId,
int version,
string actorId,
string? tenantId,
PolicyActivationResult result,
string? comment)
{
if (!options.Activation.EmitAuditLogs)
{
return;
}
ArgumentNullException.ThrowIfNull(packId);
ArgumentNullException.ThrowIfNull(actorId);
ArgumentNullException.ThrowIfNull(result);
var normalizedStatus = NormalizeStatus(result.Status);
var scope = new Dictionary<string, object?>
{
["policy.pack_id"] = packId,
["policy.revision"] = version,
["policy.activation.status"] = normalizedStatus,
["policy.activation.actor"] = actorId
};
if (!string.IsNullOrWhiteSpace(tenantId))
{
scope["policy.tenant"] = tenantId;
}
if (!string.IsNullOrWhiteSpace(comment))
{
scope["policy.activation.comment"] = Truncate(comment!, CommentLimit);
}
if (result.Revision is { } revision)
{
scope["policy.activation.requires_two_person"] = revision.RequiresTwoPersonApproval;
scope["policy.activation.approval_count"] = revision.Approvals.Length;
if (revision.Approvals.Length > 0)
{
scope["policy.activation.approvers"] = revision.Approvals
.Select(static approval => approval.ActorId)
.Where(static actor => !string.IsNullOrWhiteSpace(actor))
.ToArray();
}
}
using (logger.BeginScope(scope))
{
logger.LogInformation(
"Policy activation {PackId}/{Revision} completed with status {Status}.",
packId,
version,
normalizedStatus);
}
}
private static string NormalizeStatus(PolicyActivationResultStatus status)
=> status.ToString().ToLowerInvariant();
private static string Truncate(string value, int maxLength)
=> value.Length <= maxLength ? value : value[..maxLength];
}
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Options;
namespace StellaOps.Policy.Engine.Services;
internal interface IPolicyActivationAuditor
{
void RecordActivation(
string packId,
int version,
string actorId,
string? tenantId,
PolicyActivationResult result,
string? comment);
}
internal sealed class PolicyActivationAuditor : IPolicyActivationAuditor
{
private const int CommentLimit = 512;
private readonly PolicyEngineOptions options;
private readonly ILogger<PolicyActivationAuditor> logger;
public PolicyActivationAuditor(
PolicyEngineOptions options,
ILogger<PolicyActivationAuditor> logger)
{
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void RecordActivation(
string packId,
int version,
string actorId,
string? tenantId,
PolicyActivationResult result,
string? comment)
{
if (!options.Activation.EmitAuditLogs)
{
return;
}
ArgumentNullException.ThrowIfNull(packId);
ArgumentNullException.ThrowIfNull(actorId);
ArgumentNullException.ThrowIfNull(result);
var normalizedStatus = NormalizeStatus(result.Status);
var scope = new Dictionary<string, object?>
{
["policy.pack_id"] = packId,
["policy.revision"] = version,
["policy.activation.status"] = normalizedStatus,
["policy.activation.actor"] = actorId
};
if (!string.IsNullOrWhiteSpace(tenantId))
{
scope["policy.tenant"] = tenantId;
}
if (!string.IsNullOrWhiteSpace(comment))
{
scope["policy.activation.comment"] = Truncate(comment!, CommentLimit);
}
if (result.Revision is { } revision)
{
scope["policy.activation.requires_two_person"] = revision.RequiresTwoPersonApproval;
scope["policy.activation.approval_count"] = revision.Approvals.Length;
if (revision.Approvals.Length > 0)
{
scope["policy.activation.approvers"] = revision.Approvals
.Select(static approval => approval.ActorId)
.Where(static actor => !string.IsNullOrWhiteSpace(actor))
.ToArray();
}
}
using (logger.BeginScope(scope))
{
logger.LogInformation(
"Policy activation {PackId}/{Revision} completed with status {Status}.",
packId,
version,
normalizedStatus);
}
}
private static string NormalizeStatus(PolicyActivationResultStatus status)
=> status.ToString().ToLowerInvariant();
private static string Truncate(string value, int maxLength)
=> value.Length <= maxLength ? value : value[..maxLength];
}

View File

@@ -1,34 +1,34 @@
using System;
using StellaOps.Policy.Engine.Options;
namespace StellaOps.Policy.Engine.Services;
internal interface IPolicyActivationSettings
{
bool ResolveRequirement(bool? requested);
}
internal sealed class PolicyActivationSettings : IPolicyActivationSettings
{
private readonly PolicyEngineOptions options;
public PolicyActivationSettings(PolicyEngineOptions options)
{
this.options = options ?? throw new ArgumentNullException(nameof(options));
}
public bool ResolveRequirement(bool? requested)
{
if (options.Activation.ForceTwoPersonApproval)
{
return true;
}
if (requested.HasValue)
{
return requested.Value;
}
return options.Activation.DefaultRequiresTwoPersonApproval;
}
}
using System;
using StellaOps.Policy.Engine.Options;
namespace StellaOps.Policy.Engine.Services;
internal interface IPolicyActivationSettings
{
bool ResolveRequirement(bool? requested);
}
internal sealed class PolicyActivationSettings : IPolicyActivationSettings
{
private readonly PolicyEngineOptions options;
public PolicyActivationSettings(PolicyEngineOptions options)
{
this.options = options ?? throw new ArgumentNullException(nameof(options));
}
public bool ResolveRequirement(bool? requested)
{
if (options.Activation.ForceTwoPersonApproval)
{
return true;
}
if (requested.HasValue)
{
return requested.Value;
}
return options.Activation.DefaultRequiresTwoPersonApproval;
}
}

View File

@@ -1,6 +1,6 @@
namespace StellaOps.Policy.Engine.Services;
internal static class PolicyEngineDiagnosticCodes
{
public const string CompilationComplexityExceeded = "ERR_POL_COMPLEXITY";
}
namespace StellaOps.Policy.Engine.Services;
internal static class PolicyEngineDiagnosticCodes
{
public const string CompilationComplexityExceeded = "ERR_POL_COMPLEXITY";
}

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,213 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.RiskProfile.Hashing;
/// <summary>
/// Service for computing deterministic hashes of risk profiles.
/// </summary>
public sealed class RiskProfileHasher
{
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
},
};
/// <summary>
/// Computes a deterministic SHA-256 hash of the risk profile.
/// </summary>
/// <param name="profile">The profile to hash.</param>
/// <returns>Lowercase hex-encoded SHA-256 hash.</returns>
public string ComputeHash(RiskProfileModel profile)
{
ArgumentNullException.ThrowIfNull(profile);
var canonical = CreateCanonicalForm(profile);
var json = JsonSerializer.Serialize(canonical, CanonicalJsonOptions);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexStringLower(hash);
}
/// <summary>
/// Computes a deterministic content hash that ignores identity fields (id, version).
/// Useful for detecting semantic changes regardless of versioning.
/// </summary>
/// <param name="profile">The profile to hash.</param>
/// <returns>Lowercase hex-encoded SHA-256 hash.</returns>
public string ComputeContentHash(RiskProfileModel profile)
{
ArgumentNullException.ThrowIfNull(profile);
var canonical = CreateCanonicalContentForm(profile);
var json = JsonSerializer.Serialize(canonical, CanonicalJsonOptions);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexStringLower(hash);
}
/// <summary>
/// Verifies that two profiles have the same semantic content (ignoring identity fields).
/// </summary>
public bool AreEquivalent(RiskProfileModel profile1, RiskProfileModel profile2)
{
ArgumentNullException.ThrowIfNull(profile1);
ArgumentNullException.ThrowIfNull(profile2);
return ComputeContentHash(profile1) == ComputeContentHash(profile2);
}
private static CanonicalRiskProfile CreateCanonicalForm(RiskProfileModel profile)
{
return new CanonicalRiskProfile
{
Id = profile.Id,
Version = profile.Version,
Description = profile.Description,
Extends = profile.Extends,
Signals = CreateCanonicalSignals(profile.Signals),
Weights = CreateCanonicalWeights(profile.Weights),
Overrides = CreateCanonicalOverrides(profile.Overrides),
Metadata = CreateCanonicalMetadata(profile.Metadata),
};
}
private static CanonicalRiskProfileContent CreateCanonicalContentForm(RiskProfileModel profile)
{
return new CanonicalRiskProfileContent
{
Signals = CreateCanonicalSignals(profile.Signals),
Weights = CreateCanonicalWeights(profile.Weights),
Overrides = CreateCanonicalOverrides(profile.Overrides),
};
}
private static List<CanonicalSignal> CreateCanonicalSignals(List<RiskSignal> signals)
{
return signals
.OrderBy(s => s.Name, StringComparer.Ordinal)
.Select(s => new CanonicalSignal
{
Name = s.Name,
Source = s.Source,
Type = s.Type.ToString().ToLowerInvariant(),
Path = s.Path,
Transform = s.Transform,
Unit = s.Unit,
})
.ToList();
}
private static SortedDictionary<string, double> CreateCanonicalWeights(Dictionary<string, double> weights)
{
return new SortedDictionary<string, double>(weights, StringComparer.Ordinal);
}
private static CanonicalOverrides CreateCanonicalOverrides(RiskOverrides overrides)
{
return new CanonicalOverrides
{
Severity = overrides.Severity
.Select(CreateCanonicalSeverityOverride)
.ToList(),
Decisions = overrides.Decisions
.Select(CreateCanonicalDecisionOverride)
.ToList(),
};
}
private static CanonicalSeverityOverride CreateCanonicalSeverityOverride(SeverityOverride rule)
{
return new CanonicalSeverityOverride
{
When = CreateCanonicalWhen(rule.When),
Set = rule.Set.ToString().ToLowerInvariant(),
};
}
private static CanonicalDecisionOverride CreateCanonicalDecisionOverride(DecisionOverride rule)
{
return new CanonicalDecisionOverride
{
When = CreateCanonicalWhen(rule.When),
Action = rule.Action.ToString().ToLowerInvariant(),
Reason = rule.Reason,
};
}
private static SortedDictionary<string, object> CreateCanonicalWhen(Dictionary<string, object> when)
{
return new SortedDictionary<string, object>(when, StringComparer.Ordinal);
}
private static SortedDictionary<string, object?>? CreateCanonicalMetadata(Dictionary<string, object?>? metadata)
{
if (metadata == null || metadata.Count == 0)
{
return null;
}
return new SortedDictionary<string, object?>(metadata, StringComparer.Ordinal);
}
#region Canonical Form Types
private sealed class CanonicalRiskProfile
{
public required string Id { get; init; }
public required string Version { get; init; }
public string? Description { get; init; }
public string? Extends { get; init; }
public required List<CanonicalSignal> Signals { get; init; }
public required SortedDictionary<string, double> Weights { get; init; }
public required CanonicalOverrides Overrides { get; init; }
public SortedDictionary<string, object?>? Metadata { get; init; }
}
private sealed class CanonicalRiskProfileContent
{
public required List<CanonicalSignal> Signals { get; init; }
public required SortedDictionary<string, double> Weights { get; init; }
public required CanonicalOverrides Overrides { get; init; }
}
private sealed class CanonicalSignal
{
public required string Name { get; init; }
public required string Source { get; init; }
public required string Type { get; init; }
public string? Path { get; init; }
public string? Transform { get; init; }
public string? Unit { get; init; }
}
private sealed class CanonicalOverrides
{
public required List<CanonicalSeverityOverride> Severity { get; init; }
public required List<CanonicalDecisionOverride> Decisions { get; init; }
}
private sealed class CanonicalSeverityOverride
{
public required SortedDictionary<string, object> When { get; init; }
public required string Set { get; init; }
}
private sealed class CanonicalDecisionOverride
{
public required SortedDictionary<string, object> When { get; init; }
public required string Action { get; init; }
public string? Reason { get; init; }
}
#endregion
}

View File

@@ -0,0 +1,139 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.RiskProfile.Lifecycle;
/// <summary>
/// Lifecycle status of a risk profile.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskProfileLifecycleStatus>))]
public enum RiskProfileLifecycleStatus
{
/// <summary>
/// Profile is in draft/development.
/// </summary>
[JsonPropertyName("draft")]
Draft,
/// <summary>
/// Profile is active and available for use.
/// </summary>
[JsonPropertyName("active")]
Active,
/// <summary>
/// Profile is deprecated; use is discouraged.
/// </summary>
[JsonPropertyName("deprecated")]
Deprecated,
/// <summary>
/// Profile is archived; no longer available for new use.
/// </summary>
[JsonPropertyName("archived")]
Archived
}
/// <summary>
/// Metadata about a profile version.
/// </summary>
public sealed record RiskProfileVersionInfo(
[property: JsonPropertyName("profile_id")] string ProfileId,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("status")] RiskProfileLifecycleStatus Status,
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("created_by")] string? CreatedBy,
[property: JsonPropertyName("activated_at")] DateTimeOffset? ActivatedAt,
[property: JsonPropertyName("deprecated_at")] DateTimeOffset? DeprecatedAt,
[property: JsonPropertyName("archived_at")] DateTimeOffset? ArchivedAt,
[property: JsonPropertyName("content_hash")] string ContentHash,
[property: JsonPropertyName("successor_version")] string? SuccessorVersion = null,
[property: JsonPropertyName("deprecation_reason")] string? DeprecationReason = null);
/// <summary>
/// Event raised when a profile lifecycle changes.
/// </summary>
public sealed record RiskProfileLifecycleEvent(
[property: JsonPropertyName("event_id")] string EventId,
[property: JsonPropertyName("profile_id")] string ProfileId,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("event_type")] RiskProfileLifecycleEventType EventType,
[property: JsonPropertyName("old_status")] RiskProfileLifecycleStatus? OldStatus,
[property: JsonPropertyName("new_status")] RiskProfileLifecycleStatus NewStatus,
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
[property: JsonPropertyName("actor")] string? Actor,
[property: JsonPropertyName("reason")] string? Reason = null);
/// <summary>
/// Types of lifecycle events.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskProfileLifecycleEventType>))]
public enum RiskProfileLifecycleEventType
{
[JsonPropertyName("created")]
Created,
[JsonPropertyName("activated")]
Activated,
[JsonPropertyName("deprecated")]
Deprecated,
[JsonPropertyName("archived")]
Archived,
[JsonPropertyName("restored")]
Restored
}
/// <summary>
/// Result of a version comparison.
/// </summary>
public sealed record RiskProfileVersionComparison(
[property: JsonPropertyName("profile_id")] string ProfileId,
[property: JsonPropertyName("from_version")] string FromVersion,
[property: JsonPropertyName("to_version")] string ToVersion,
[property: JsonPropertyName("has_breaking_changes")] bool HasBreakingChanges,
[property: JsonPropertyName("changes")] IReadOnlyList<RiskProfileChange> Changes);
/// <summary>
/// A specific change between profile versions.
/// </summary>
public sealed record RiskProfileChange(
[property: JsonPropertyName("change_type")] RiskProfileChangeType ChangeType,
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("is_breaking")] bool IsBreaking);
/// <summary>
/// Types of changes between profile versions.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskProfileChangeType>))]
public enum RiskProfileChangeType
{
[JsonPropertyName("signal_added")]
SignalAdded,
[JsonPropertyName("signal_removed")]
SignalRemoved,
[JsonPropertyName("signal_modified")]
SignalModified,
[JsonPropertyName("weight_changed")]
WeightChanged,
[JsonPropertyName("override_added")]
OverrideAdded,
[JsonPropertyName("override_removed")]
OverrideRemoved,
[JsonPropertyName("override_modified")]
OverrideModified,
[JsonPropertyName("metadata_changed")]
MetadataChanged,
[JsonPropertyName("inheritance_changed")]
InheritanceChanged
}

View File

@@ -0,0 +1,521 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Policy.RiskProfile.Hashing;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.RiskProfile.Lifecycle;
/// <summary>
/// Service for managing risk profile lifecycle and versioning.
/// </summary>
public sealed class RiskProfileLifecycleService
{
private readonly TimeProvider _timeProvider;
private readonly RiskProfileHasher _hasher;
private readonly ConcurrentDictionary<string, List<RiskProfileVersionInfo>> _versions;
private readonly ConcurrentDictionary<string, List<RiskProfileLifecycleEvent>> _events;
public RiskProfileLifecycleService(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_hasher = new RiskProfileHasher();
_versions = new ConcurrentDictionary<string, List<RiskProfileVersionInfo>>(StringComparer.OrdinalIgnoreCase);
_events = new ConcurrentDictionary<string, List<RiskProfileLifecycleEvent>>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Creates a new profile version in draft status.
/// </summary>
/// <param name="profile">The profile to create.</param>
/// <param name="createdBy">Creator identifier.</param>
/// <returns>Version info for the created profile.</returns>
public RiskProfileVersionInfo CreateVersion(RiskProfileModel profile, string? createdBy = null)
{
ArgumentNullException.ThrowIfNull(profile);
var now = _timeProvider.GetUtcNow();
var contentHash = _hasher.ComputeContentHash(profile);
var versionInfo = new RiskProfileVersionInfo(
ProfileId: profile.Id,
Version: profile.Version,
Status: RiskProfileLifecycleStatus.Draft,
CreatedAt: now,
CreatedBy: createdBy,
ActivatedAt: null,
DeprecatedAt: null,
ArchivedAt: null,
ContentHash: contentHash);
var versions = _versions.GetOrAdd(profile.Id, _ => new List<RiskProfileVersionInfo>());
lock (versions)
{
if (versions.Any(v => v.Version == profile.Version))
{
throw new InvalidOperationException($"Version {profile.Version} already exists for profile {profile.Id}.");
}
versions.Add(versionInfo);
}
RecordEvent(profile.Id, profile.Version, RiskProfileLifecycleEventType.Created, null, RiskProfileLifecycleStatus.Draft, createdBy);
return versionInfo;
}
/// <summary>
/// Activates a profile version, making it available for use.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="version">The version to activate.</param>
/// <param name="actor">Actor performing the activation.</param>
/// <returns>Updated version info.</returns>
public RiskProfileVersionInfo Activate(string profileId, string version, string? actor = null)
{
var info = GetVersionInfo(profileId, version);
if (info == null)
{
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
}
if (info.Status != RiskProfileLifecycleStatus.Draft)
{
throw new InvalidOperationException($"Cannot activate profile in {info.Status} status. Only Draft profiles can be activated.");
}
var now = _timeProvider.GetUtcNow();
var updated = info with
{
Status = RiskProfileLifecycleStatus.Active,
ActivatedAt = now
};
UpdateVersionInfo(profileId, version, updated);
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Activated, info.Status, RiskProfileLifecycleStatus.Active, actor);
return updated;
}
/// <summary>
/// Deprecates a profile version.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="version">The version to deprecate.</param>
/// <param name="successorVersion">Optional successor version to recommend.</param>
/// <param name="reason">Reason for deprecation.</param>
/// <param name="actor">Actor performing the deprecation.</param>
/// <returns>Updated version info.</returns>
public RiskProfileVersionInfo Deprecate(
string profileId,
string version,
string? successorVersion = null,
string? reason = null,
string? actor = null)
{
var info = GetVersionInfo(profileId, version);
if (info == null)
{
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
}
if (info.Status != RiskProfileLifecycleStatus.Active)
{
throw new InvalidOperationException($"Cannot deprecate profile in {info.Status} status. Only Active profiles can be deprecated.");
}
var now = _timeProvider.GetUtcNow();
var updated = info with
{
Status = RiskProfileLifecycleStatus.Deprecated,
DeprecatedAt = now,
SuccessorVersion = successorVersion,
DeprecationReason = reason
};
UpdateVersionInfo(profileId, version, updated);
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Deprecated, info.Status, RiskProfileLifecycleStatus.Deprecated, actor, reason);
return updated;
}
/// <summary>
/// Archives a profile version, removing it from active use.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="version">The version to archive.</param>
/// <param name="actor">Actor performing the archive.</param>
/// <returns>Updated version info.</returns>
public RiskProfileVersionInfo Archive(string profileId, string version, string? actor = null)
{
var info = GetVersionInfo(profileId, version);
if (info == null)
{
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
}
if (info.Status == RiskProfileLifecycleStatus.Archived)
{
return info;
}
var now = _timeProvider.GetUtcNow();
var updated = info with
{
Status = RiskProfileLifecycleStatus.Archived,
ArchivedAt = now
};
UpdateVersionInfo(profileId, version, updated);
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Archived, info.Status, RiskProfileLifecycleStatus.Archived, actor);
return updated;
}
/// <summary>
/// Restores an archived profile to deprecated status.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="version">The version to restore.</param>
/// <param name="actor">Actor performing the restoration.</param>
/// <returns>Updated version info.</returns>
public RiskProfileVersionInfo Restore(string profileId, string version, string? actor = null)
{
var info = GetVersionInfo(profileId, version);
if (info == null)
{
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
}
if (info.Status != RiskProfileLifecycleStatus.Archived)
{
throw new InvalidOperationException($"Cannot restore profile in {info.Status} status. Only Archived profiles can be restored.");
}
var updated = info with
{
Status = RiskProfileLifecycleStatus.Deprecated,
ArchivedAt = null
};
UpdateVersionInfo(profileId, version, updated);
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Restored, info.Status, RiskProfileLifecycleStatus.Deprecated, actor);
return updated;
}
/// <summary>
/// Gets version info for a specific profile version.
/// </summary>
public RiskProfileVersionInfo? GetVersionInfo(string profileId, string version)
{
if (_versions.TryGetValue(profileId, out var versions))
{
lock (versions)
{
return versions.FirstOrDefault(v => v.Version == version);
}
}
return null;
}
/// <summary>
/// Gets all versions for a profile.
/// </summary>
public IReadOnlyList<RiskProfileVersionInfo> GetAllVersions(string profileId)
{
if (_versions.TryGetValue(profileId, out var versions))
{
lock (versions)
{
return versions.OrderByDescending(v => ParseVersion(v.Version)).ToList().AsReadOnly();
}
}
return Array.Empty<RiskProfileVersionInfo>();
}
/// <summary>
/// Gets the latest active version for a profile.
/// </summary>
public RiskProfileVersionInfo? GetLatestActive(string profileId)
{
var versions = GetAllVersions(profileId);
return versions.FirstOrDefault(v => v.Status == RiskProfileLifecycleStatus.Active);
}
/// <summary>
/// Gets lifecycle events for a profile.
/// </summary>
public IReadOnlyList<RiskProfileLifecycleEvent> GetEvents(string profileId, int limit = 100)
{
if (_events.TryGetValue(profileId, out var events))
{
lock (events)
{
return events.OrderByDescending(e => e.Timestamp).Take(limit).ToList().AsReadOnly();
}
}
return Array.Empty<RiskProfileLifecycleEvent>();
}
/// <summary>
/// Compares two profile versions and returns the differences.
/// </summary>
public RiskProfileVersionComparison CompareVersions(
RiskProfileModel fromProfile,
RiskProfileModel toProfile)
{
ArgumentNullException.ThrowIfNull(fromProfile);
ArgumentNullException.ThrowIfNull(toProfile);
if (fromProfile.Id != toProfile.Id)
{
throw new ArgumentException("Profiles must have the same ID to compare.");
}
var changes = new List<RiskProfileChange>();
var hasBreaking = false;
CompareSignals(fromProfile, toProfile, changes, ref hasBreaking);
CompareWeights(fromProfile, toProfile, changes);
CompareOverrides(fromProfile, toProfile, changes);
CompareInheritance(fromProfile, toProfile, changes, ref hasBreaking);
CompareMetadata(fromProfile, toProfile, changes);
return new RiskProfileVersionComparison(
ProfileId: fromProfile.Id,
FromVersion: fromProfile.Version,
ToVersion: toProfile.Version,
HasBreakingChanges: hasBreaking,
Changes: changes.AsReadOnly());
}
/// <summary>
/// Determines if an upgrade from one version to another is safe (non-breaking).
/// </summary>
public bool IsSafeUpgrade(RiskProfileModel fromProfile, RiskProfileModel toProfile)
{
var comparison = CompareVersions(fromProfile, toProfile);
return !comparison.HasBreakingChanges;
}
private void UpdateVersionInfo(string profileId, string version, RiskProfileVersionInfo updated)
{
if (_versions.TryGetValue(profileId, out var versions))
{
lock (versions)
{
var index = versions.FindIndex(v => v.Version == version);
if (index >= 0)
{
versions[index] = updated;
}
}
}
}
private void RecordEvent(
string profileId,
string version,
RiskProfileLifecycleEventType eventType,
RiskProfileLifecycleStatus? oldStatus,
RiskProfileLifecycleStatus newStatus,
string? actor,
string? reason = null)
{
var eventId = GenerateEventId();
var evt = new RiskProfileLifecycleEvent(
EventId: eventId,
ProfileId: profileId,
Version: version,
EventType: eventType,
OldStatus: oldStatus,
NewStatus: newStatus,
Timestamp: _timeProvider.GetUtcNow(),
Actor: actor,
Reason: reason);
var events = _events.GetOrAdd(profileId, _ => new List<RiskProfileLifecycleEvent>());
lock (events)
{
events.Add(evt);
}
}
private static void CompareSignals(
RiskProfileModel from,
RiskProfileModel to,
List<RiskProfileChange> changes,
ref bool hasBreaking)
{
var fromSignals = from.Signals.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
var toSignals = to.Signals.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
foreach (var (name, signal) in fromSignals)
{
if (!toSignals.ContainsKey(name))
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.SignalRemoved,
$"/signals/{name}",
$"Signal '{name}' was removed",
IsBreaking: true));
hasBreaking = true;
}
else if (!SignalsEqual(signal, toSignals[name]))
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.SignalModified,
$"/signals/{name}",
$"Signal '{name}' was modified",
IsBreaking: false));
}
}
foreach (var name in toSignals.Keys)
{
if (!fromSignals.ContainsKey(name))
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.SignalAdded,
$"/signals/{name}",
$"Signal '{name}' was added",
IsBreaking: false));
}
}
}
private static void CompareWeights(
RiskProfileModel from,
RiskProfileModel to,
List<RiskProfileChange> changes)
{
var allKeys = from.Weights.Keys.Union(to.Weights.Keys, StringComparer.OrdinalIgnoreCase);
foreach (var key in allKeys)
{
var fromHas = from.Weights.TryGetValue(key, out var fromWeight);
var toHas = to.Weights.TryGetValue(key, out var toWeight);
if (fromHas && toHas && Math.Abs(fromWeight - toWeight) > 0.001)
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.WeightChanged,
$"/weights/{key}",
$"Weight for '{key}' changed from {fromWeight:F3} to {toWeight:F3}",
IsBreaking: false));
}
else if (fromHas && !toHas)
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.WeightChanged,
$"/weights/{key}",
$"Weight for '{key}' was removed",
IsBreaking: false));
}
else if (!fromHas && toHas)
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.WeightChanged,
$"/weights/{key}",
$"Weight for '{key}' was added with value {toWeight:F3}",
IsBreaking: false));
}
}
}
private static void CompareOverrides(
RiskProfileModel from,
RiskProfileModel to,
List<RiskProfileChange> changes)
{
if (from.Overrides.Severity.Count != to.Overrides.Severity.Count)
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.OverrideModified,
"/overrides/severity",
$"Severity overrides changed from {from.Overrides.Severity.Count} to {to.Overrides.Severity.Count} rules",
IsBreaking: false));
}
if (from.Overrides.Decisions.Count != to.Overrides.Decisions.Count)
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.OverrideModified,
"/overrides/decisions",
$"Decision overrides changed from {from.Overrides.Decisions.Count} to {to.Overrides.Decisions.Count} rules",
IsBreaking: false));
}
}
private static void CompareInheritance(
RiskProfileModel from,
RiskProfileModel to,
List<RiskProfileChange> changes,
ref bool hasBreaking)
{
if (!string.Equals(from.Extends, to.Extends, StringComparison.OrdinalIgnoreCase))
{
var fromExtends = from.Extends ?? "(none)";
var toExtends = to.Extends ?? "(none)";
changes.Add(new RiskProfileChange(
RiskProfileChangeType.InheritanceChanged,
"/extends",
$"Inheritance changed from '{fromExtends}' to '{toExtends}'",
IsBreaking: true));
hasBreaking = true;
}
}
private static void CompareMetadata(
RiskProfileModel from,
RiskProfileModel to,
List<RiskProfileChange> changes)
{
var fromKeys = from.Metadata?.Keys ?? Enumerable.Empty<string>();
var toKeys = to.Metadata?.Keys ?? Enumerable.Empty<string>();
var allKeys = fromKeys.Union(toKeys, StringComparer.OrdinalIgnoreCase);
foreach (var key in allKeys)
{
var fromHas = from.Metadata?.TryGetValue(key, out var fromValue) ?? false;
var toHas = to.Metadata?.TryGetValue(key, out var toValue) ?? false;
if (fromHas != toHas || (fromHas && toHas && !Equals(fromValue, toValue)))
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.MetadataChanged,
$"/metadata/{key}",
$"Metadata key '{key}' was changed",
IsBreaking: false));
}
}
}
private static bool SignalsEqual(RiskSignal a, RiskSignal b)
{
return a.Source == b.Source &&
a.Type == b.Type &&
a.Path == b.Path &&
a.Transform == b.Transform &&
a.Unit == b.Unit;
}
private static Version ParseVersion(string version)
{
var parts = version.Split(['-', '+'], 2);
if (Version.TryParse(parts[0], out var parsed))
{
return parsed;
}
return new Version(0, 0, 0);
}
private static string GenerateEventId()
{
var guid = Guid.NewGuid().ToByteArray();
return $"rple-{Convert.ToHexStringLower(guid)[..16]}";
}
}

View File

@@ -0,0 +1,241 @@
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.RiskProfile.Merge;
/// <summary>
/// Service for merging and resolving inheritance in risk profiles.
/// </summary>
public sealed class RiskProfileMergeService
{
/// <summary>
/// Resolves a risk profile by applying inheritance from parent profiles.
/// </summary>
/// <param name="profile">The profile to resolve.</param>
/// <param name="profileResolver">Function to resolve parent profiles by ID.</param>
/// <param name="maxDepth">Maximum inheritance depth to prevent cycles.</param>
/// <returns>A fully resolved profile with inherited values merged.</returns>
public RiskProfileModel ResolveInheritance(
RiskProfileModel profile,
Func<string, RiskProfileModel?> profileResolver,
int maxDepth = 10)
{
ArgumentNullException.ThrowIfNull(profile);
ArgumentNullException.ThrowIfNull(profileResolver);
if (string.IsNullOrWhiteSpace(profile.Extends))
{
return profile;
}
var chain = BuildInheritanceChain(profile, profileResolver, maxDepth);
return MergeChain(chain);
}
/// <summary>
/// Merges multiple profiles in order (later profiles override earlier ones).
/// </summary>
/// <param name="profiles">Profiles to merge, in order of precedence (first = base, last = highest priority).</param>
/// <returns>A merged profile.</returns>
public RiskProfileModel MergeProfiles(IEnumerable<RiskProfileModel> profiles)
{
ArgumentNullException.ThrowIfNull(profiles);
var profileList = profiles.ToList();
if (profileList.Count == 0)
{
throw new ArgumentException("At least one profile is required.", nameof(profiles));
}
return MergeChain(profileList);
}
private List<RiskProfileModel> BuildInheritanceChain(
RiskProfileModel profile,
Func<string, RiskProfileModel?> resolver,
int maxDepth)
{
var chain = new List<RiskProfileModel>();
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var current = profile;
var depth = 0;
while (current != null && depth < maxDepth)
{
if (!visited.Add(current.Id))
{
throw new InvalidOperationException(
$"Circular inheritance detected: profile '{current.Id}' already in chain.");
}
chain.Add(current);
depth++;
if (string.IsNullOrWhiteSpace(current.Extends))
{
break;
}
var parent = resolver(current.Extends);
if (parent == null)
{
throw new InvalidOperationException(
$"Parent profile '{current.Extends}' not found for profile '{current.Id}'.");
}
current = parent;
}
if (depth >= maxDepth)
{
throw new InvalidOperationException(
$"Maximum inheritance depth ({maxDepth}) exceeded for profile '{profile.Id}'.");
}
// Reverse so base profiles come first
chain.Reverse();
return chain;
}
private RiskProfileModel MergeChain(List<RiskProfileModel> chain)
{
if (chain.Count == 1)
{
return CloneProfile(chain[0]);
}
var result = CloneProfile(chain[0]);
for (int i = 1; i < chain.Count; i++)
{
var overlay = chain[i];
MergeInto(result, overlay);
}
return result;
}
private void MergeInto(RiskProfileModel target, RiskProfileModel overlay)
{
// Override identity fields
target.Id = overlay.Id;
target.Version = overlay.Version;
if (!string.IsNullOrWhiteSpace(overlay.Description))
{
target.Description = overlay.Description;
}
// Clear extends since inheritance has been resolved
target.Extends = null;
// Merge signals (overlay signals replace by name, new ones are added)
MergeSignals(target.Signals, overlay.Signals);
// Merge weights (overlay weights override by key)
foreach (var kvp in overlay.Weights)
{
target.Weights[kvp.Key] = kvp.Value;
}
// Merge overrides (append overlay rules)
MergeOverrides(target.Overrides, overlay.Overrides);
// Merge metadata (overlay values override by key)
if (overlay.Metadata != null)
{
target.Metadata ??= new Dictionary<string, object?>();
foreach (var kvp in overlay.Metadata)
{
target.Metadata[kvp.Key] = kvp.Value;
}
}
}
private static void MergeSignals(List<RiskSignal> target, List<RiskSignal> overlay)
{
var signalsByName = target.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
foreach (var signal in overlay)
{
if (signalsByName.TryGetValue(signal.Name, out var existing))
{
// Replace existing signal
var index = target.IndexOf(existing);
target[index] = CloneSignal(signal);
}
else
{
// Add new signal
target.Add(CloneSignal(signal));
}
}
}
private static void MergeOverrides(RiskOverrides target, RiskOverrides overlay)
{
// Append severity overrides (overlay rules take precedence by being evaluated later)
foreach (var rule in overlay.Severity)
{
target.Severity.Add(CloneSeverityOverride(rule));
}
// Append decision overrides
foreach (var rule in overlay.Decisions)
{
target.Decisions.Add(CloneDecisionOverride(rule));
}
}
private static RiskProfileModel CloneProfile(RiskProfileModel source)
{
return new RiskProfileModel
{
Id = source.Id,
Version = source.Version,
Description = source.Description,
Extends = source.Extends,
Signals = source.Signals.Select(CloneSignal).ToList(),
Weights = new Dictionary<string, double>(source.Weights),
Overrides = new RiskOverrides
{
Severity = source.Overrides.Severity.Select(CloneSeverityOverride).ToList(),
Decisions = source.Overrides.Decisions.Select(CloneDecisionOverride).ToList(),
},
Metadata = source.Metadata != null
? new Dictionary<string, object?>(source.Metadata)
: null,
};
}
private static RiskSignal CloneSignal(RiskSignal source)
{
return new RiskSignal
{
Name = source.Name,
Source = source.Source,
Type = source.Type,
Path = source.Path,
Transform = source.Transform,
Unit = source.Unit,
};
}
private static SeverityOverride CloneSeverityOverride(SeverityOverride source)
{
return new SeverityOverride
{
When = new Dictionary<string, object>(source.When),
Set = source.Set,
};
}
private static DecisionOverride CloneDecisionOverride(DecisionOverride source)
{
return new DecisionOverride
{
When = new Dictionary<string, object>(source.When),
Action = source.Action,
Reason = source.Reason,
};
}
}

View File

@@ -0,0 +1,213 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.RiskProfile.Models;
/// <summary>
/// Represents a risk profile definition used to score and prioritize findings.
/// </summary>
public sealed class RiskProfileModel
{
/// <summary>
/// Stable identifier for the risk profile (slug or URN).
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; set; }
/// <summary>
/// SemVer for the profile definition.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; set; }
/// <summary>
/// Human-readable summary of the profile intent.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// Optional parent profile ID for inheritance.
/// </summary>
[JsonPropertyName("extends")]
public string? Extends { get; set; }
/// <summary>
/// Signal definitions used for risk scoring.
/// </summary>
[JsonPropertyName("signals")]
public List<RiskSignal> Signals { get; set; } = new();
/// <summary>
/// Weight per signal name; weights are normalized by the consumer.
/// </summary>
[JsonPropertyName("weights")]
public Dictionary<string, double> Weights { get; set; } = new();
/// <summary>
/// Override rules for severity and decisions.
/// </summary>
[JsonPropertyName("overrides")]
public RiskOverrides Overrides { get; set; } = new();
/// <summary>
/// Free-form metadata with stable keys.
/// </summary>
[JsonPropertyName("metadata")]
public Dictionary<string, object?>? Metadata { get; set; }
}
/// <summary>
/// A signal definition used in risk scoring.
/// </summary>
public sealed class RiskSignal
{
/// <summary>
/// Logical signal key (e.g., reachability, kev, exploit_chain).
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; set; }
/// <summary>
/// Upstream provider or calculation origin.
/// </summary>
[JsonPropertyName("source")]
public required string Source { get; set; }
/// <summary>
/// Signal type.
/// </summary>
[JsonPropertyName("type")]
public required RiskSignalType Type { get; set; }
/// <summary>
/// JSON Pointer to the signal in the evidence document.
/// </summary>
[JsonPropertyName("path")]
public string? Path { get; set; }
/// <summary>
/// Optional transform applied before weighting.
/// </summary>
[JsonPropertyName("transform")]
public string? Transform { get; set; }
/// <summary>
/// Optional unit for numeric signals.
/// </summary>
[JsonPropertyName("unit")]
public string? Unit { get; set; }
}
/// <summary>
/// Signal type enumeration.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskSignalType>))]
public enum RiskSignalType
{
[JsonPropertyName("boolean")]
Boolean,
[JsonPropertyName("numeric")]
Numeric,
[JsonPropertyName("categorical")]
Categorical,
}
/// <summary>
/// Override rules for severity and decisions.
/// </summary>
public sealed class RiskOverrides
{
/// <summary>
/// Severity override rules.
/// </summary>
[JsonPropertyName("severity")]
public List<SeverityOverride> Severity { get; set; } = new();
/// <summary>
/// Decision override rules.
/// </summary>
[JsonPropertyName("decisions")]
public List<DecisionOverride> Decisions { get; set; } = new();
}
/// <summary>
/// A severity override rule.
/// </summary>
public sealed class SeverityOverride
{
/// <summary>
/// Predicate over signals (key/value equals).
/// </summary>
[JsonPropertyName("when")]
public required Dictionary<string, object> When { get; set; }
/// <summary>
/// Severity to set when predicate matches.
/// </summary>
[JsonPropertyName("set")]
public required RiskSeverity Set { get; set; }
}
/// <summary>
/// A decision override rule.
/// </summary>
public sealed class DecisionOverride
{
/// <summary>
/// Predicate over signals (key/value equals).
/// </summary>
[JsonPropertyName("when")]
public required Dictionary<string, object> When { get; set; }
/// <summary>
/// Action to take when predicate matches.
/// </summary>
[JsonPropertyName("action")]
public required RiskAction Action { get; set; }
/// <summary>
/// Optional reason for the override.
/// </summary>
[JsonPropertyName("reason")]
public string? Reason { get; set; }
}
/// <summary>
/// Severity levels.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskSeverity>))]
public enum RiskSeverity
{
[JsonPropertyName("critical")]
Critical,
[JsonPropertyName("high")]
High,
[JsonPropertyName("medium")]
Medium,
[JsonPropertyName("low")]
Low,
[JsonPropertyName("informational")]
Informational,
}
/// <summary>
/// Decision actions.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskAction>))]
public enum RiskAction
{
[JsonPropertyName("allow")]
Allow,
[JsonPropertyName("review")]
Review,
[JsonPropertyName("deny")]
Deny,
}

View File

@@ -1,4 +1,6 @@
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using Json.Schema;
namespace StellaOps.Policy.RiskProfile.Schema;
@@ -6,14 +8,54 @@ namespace StellaOps.Policy.RiskProfile.Schema;
public static class RiskProfileSchemaProvider
{
private const string SchemaResource = "StellaOps.Policy.RiskProfile.Schemas.risk-profile-schema@1.json";
private const string SchemaVersion = "1";
private static string? _cachedSchemaText;
private static string? _cachedETag;
public static JsonSchema GetSchema()
{
var schemaText = GetSchemaText();
return JsonSchema.FromText(schemaText);
}
/// <summary>
/// Returns the raw JSON schema text.
/// </summary>
public static string GetSchemaText()
{
if (_cachedSchemaText is not null)
{
return _cachedSchemaText;
}
using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(SchemaResource)
?? throw new InvalidOperationException($"Schema resource '{SchemaResource}' not found.");
using var reader = new StreamReader(stream);
var schemaText = reader.ReadToEnd();
_cachedSchemaText = reader.ReadToEnd();
return JsonSchema.FromText(schemaText);
return _cachedSchemaText;
}
/// <summary>
/// Returns the schema version identifier.
/// </summary>
public static string GetSchemaVersion() => SchemaVersion;
/// <summary>
/// Returns an ETag for the schema content.
/// </summary>
public static string GetETag()
{
if (_cachedETag is not null)
{
return _cachedETag;
}
var schemaText = GetSchemaText();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(schemaText));
_cachedETag = $"\"{Convert.ToHexStringLower(hash)[..16]}\"";
return _cachedETag;
}
}

View File

@@ -0,0 +1,358 @@
using System.Collections.Immutable;
using System.Text.Json;
using Json.Schema;
using StellaOps.Policy.RiskProfile.Models;
using StellaOps.Policy.RiskProfile.Schema;
using StellaOps.Policy.RiskProfile.Validation;
namespace StellaOps.Policy;
/// <summary>
/// Diagnostics report for a risk profile validation.
/// </summary>
public sealed record RiskProfileDiagnosticsReport(
string ProfileId,
string Version,
int SignalCount,
int WeightCount,
int OverrideCount,
int ErrorCount,
int WarningCount,
DateTimeOffset GeneratedAt,
ImmutableArray<RiskProfileIssue> Issues,
ImmutableArray<string> Recommendations);
/// <summary>
/// Represents a validation issue in a risk profile.
/// </summary>
public sealed record RiskProfileIssue(
string Code,
string Message,
RiskProfileIssueSeverity Severity,
string Path)
{
public static RiskProfileIssue Error(string code, string message, string path)
=> new(code, message, RiskProfileIssueSeverity.Error, path);
public static RiskProfileIssue Warning(string code, string message, string path)
=> new(code, message, RiskProfileIssueSeverity.Warning, path);
public static RiskProfileIssue Info(string code, string message, string path)
=> new(code, message, RiskProfileIssueSeverity.Info, path);
}
/// <summary>
/// Severity levels for risk profile issues.
/// </summary>
public enum RiskProfileIssueSeverity
{
Error,
Warning,
Info
}
/// <summary>
/// Provides validation and diagnostics for risk profiles.
/// </summary>
public static class RiskProfileDiagnostics
{
private static readonly RiskProfileValidator Validator = new();
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
/// <summary>
/// Creates a diagnostics report for a risk profile.
/// </summary>
/// <param name="profile">The profile to validate.</param>
/// <param name="timeProvider">Optional time provider.</param>
/// <returns>Diagnostics report.</returns>
public static RiskProfileDiagnosticsReport Create(RiskProfileModel profile, TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(profile);
var time = (timeProvider ?? TimeProvider.System).GetUtcNow();
var issues = ImmutableArray.CreateBuilder<RiskProfileIssue>();
ValidateStructure(profile, issues);
ValidateSignals(profile, issues);
ValidateWeights(profile, issues);
ValidateOverrides(profile, issues);
ValidateInheritance(profile, issues);
var errorCount = issues.Count(static i => i.Severity == RiskProfileIssueSeverity.Error);
var warningCount = issues.Count(static i => i.Severity == RiskProfileIssueSeverity.Warning);
var recommendations = BuildRecommendations(profile, errorCount, warningCount);
var overrideCount = profile.Overrides.Severity.Count + profile.Overrides.Decisions.Count;
return new RiskProfileDiagnosticsReport(
profile.Id,
profile.Version,
profile.Signals.Count,
profile.Weights.Count,
overrideCount,
errorCount,
warningCount,
time,
issues.ToImmutable(),
recommendations);
}
/// <summary>
/// Validates a risk profile JSON against the schema.
/// </summary>
/// <param name="json">The JSON to validate.</param>
/// <returns>Collection of validation issues.</returns>
public static ImmutableArray<RiskProfileIssue> ValidateJson(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return ImmutableArray.Create(
RiskProfileIssue.Error("RISK001", "Profile JSON is required.", "/"));
}
try
{
var results = Validator.Validate(json);
if (results.IsValid)
{
return ImmutableArray<RiskProfileIssue>.Empty;
}
return ExtractSchemaErrors(results).ToImmutableArray();
}
catch (JsonException ex)
{
return ImmutableArray.Create(
RiskProfileIssue.Error("RISK002", $"Invalid JSON: {ex.Message}", "/"));
}
}
/// <summary>
/// Validates a risk profile model for semantic correctness.
/// </summary>
/// <param name="profile">The profile to validate.</param>
/// <returns>Collection of validation issues.</returns>
public static ImmutableArray<RiskProfileIssue> Validate(RiskProfileModel profile)
{
ArgumentNullException.ThrowIfNull(profile);
var issues = ImmutableArray.CreateBuilder<RiskProfileIssue>();
ValidateStructure(profile, issues);
ValidateSignals(profile, issues);
ValidateWeights(profile, issues);
ValidateOverrides(profile, issues);
ValidateInheritance(profile, issues);
return issues.ToImmutable();
}
private static void ValidateStructure(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
{
if (string.IsNullOrWhiteSpace(profile.Id))
{
issues.Add(RiskProfileIssue.Error("RISK010", "Profile ID is required.", "/id"));
}
else if (profile.Id.Contains(' '))
{
issues.Add(RiskProfileIssue.Warning("RISK011", "Profile ID should not contain spaces.", "/id"));
}
if (string.IsNullOrWhiteSpace(profile.Version))
{
issues.Add(RiskProfileIssue.Error("RISK012", "Profile version is required.", "/version"));
}
else if (!IsValidSemVer(profile.Version))
{
issues.Add(RiskProfileIssue.Warning("RISK013", "Profile version should follow SemVer format.", "/version"));
}
}
private static void ValidateSignals(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
{
if (profile.Signals.Count == 0)
{
issues.Add(RiskProfileIssue.Warning("RISK020", "Profile has no signals defined.", "/signals"));
return;
}
var signalNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < profile.Signals.Count; i++)
{
var signal = profile.Signals[i];
var path = $"/signals/{i}";
if (string.IsNullOrWhiteSpace(signal.Name))
{
issues.Add(RiskProfileIssue.Error("RISK021", $"Signal at index {i} has no name.", path));
}
else if (!signalNames.Add(signal.Name))
{
issues.Add(RiskProfileIssue.Error("RISK022", $"Duplicate signal name: {signal.Name}", path));
}
if (string.IsNullOrWhiteSpace(signal.Source))
{
issues.Add(RiskProfileIssue.Error("RISK023", $"Signal '{signal.Name}' has no source.", $"{path}/source"));
}
if (signal.Type == RiskSignalType.Numeric && string.IsNullOrWhiteSpace(signal.Unit))
{
issues.Add(RiskProfileIssue.Info("RISK024", $"Numeric signal '{signal.Name}' has no unit specified.", $"{path}/unit"));
}
}
}
private static void ValidateWeights(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
{
var signalNames = profile.Signals.Select(s => s.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
double totalWeight = 0;
foreach (var (name, weight) in profile.Weights)
{
if (!signalNames.Contains(name))
{
issues.Add(RiskProfileIssue.Warning("RISK030", $"Weight defined for unknown signal: {name}", $"/weights/{name}"));
}
if (weight < 0)
{
issues.Add(RiskProfileIssue.Error("RISK031", $"Weight for '{name}' is negative: {weight}", $"/weights/{name}"));
}
else if (weight == 0)
{
issues.Add(RiskProfileIssue.Info("RISK032", $"Weight for '{name}' is zero.", $"/weights/{name}"));
}
totalWeight += weight;
}
foreach (var signal in profile.Signals)
{
if (!profile.Weights.ContainsKey(signal.Name))
{
issues.Add(RiskProfileIssue.Warning("RISK033", $"Signal '{signal.Name}' has no weight defined.", $"/weights"));
}
}
if (totalWeight > 0 && Math.Abs(totalWeight - 1.0) > 0.01)
{
issues.Add(RiskProfileIssue.Info("RISK034", $"Weights sum to {totalWeight:F3}; consider normalizing to 1.0.", "/weights"));
}
}
private static void ValidateOverrides(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
{
for (int i = 0; i < profile.Overrides.Severity.Count; i++)
{
var rule = profile.Overrides.Severity[i];
var path = $"/overrides/severity/{i}";
if (rule.When.Count == 0)
{
issues.Add(RiskProfileIssue.Warning("RISK040", $"Severity override at index {i} has empty 'when' clause.", path));
}
}
for (int i = 0; i < profile.Overrides.Decisions.Count; i++)
{
var rule = profile.Overrides.Decisions[i];
var path = $"/overrides/decisions/{i}";
if (rule.When.Count == 0)
{
issues.Add(RiskProfileIssue.Warning("RISK041", $"Decision override at index {i} has empty 'when' clause.", path));
}
if (rule.Action == RiskAction.Deny && string.IsNullOrWhiteSpace(rule.Reason))
{
issues.Add(RiskProfileIssue.Warning("RISK042", $"Decision override at index {i} with 'deny' action should have a reason.", path));
}
}
}
private static void ValidateInheritance(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
{
if (!string.IsNullOrWhiteSpace(profile.Extends))
{
if (string.Equals(profile.Extends, profile.Id, StringComparison.OrdinalIgnoreCase))
{
issues.Add(RiskProfileIssue.Error("RISK050", "Profile cannot extend itself.", "/extends"));
}
}
}
private static ImmutableArray<string> BuildRecommendations(RiskProfileModel profile, int errorCount, int warningCount)
{
var recommendations = ImmutableArray.CreateBuilder<string>();
if (errorCount > 0)
{
recommendations.Add("Resolve errors before using this profile in production.");
}
if (warningCount > 0)
{
recommendations.Add("Review warnings to ensure profile behaves as expected.");
}
if (profile.Signals.Count == 0)
{
recommendations.Add("Add at least one signal to enable risk scoring.");
}
if (profile.Weights.Count == 0 && profile.Signals.Count > 0)
{
recommendations.Add("Define weights for signals to control scoring influence.");
}
var hasReachability = profile.Signals.Any(s =>
s.Name.Equals("reachability", StringComparison.OrdinalIgnoreCase));
if (!hasReachability)
{
recommendations.Add("Consider adding a reachability signal to prioritize exploitable vulnerabilities.");
}
if (recommendations.Count == 0)
{
recommendations.Add("Risk profile validated successfully; ready for use.");
}
return recommendations.ToImmutable();
}
private static IEnumerable<RiskProfileIssue> ExtractSchemaErrors(ValidationResults results)
{
if (results.Details != null)
{
foreach (var detail in results.Details)
{
if (detail.HasErrors)
{
foreach (var error in detail.Errors ?? [])
{
yield return RiskProfileIssue.Error(
"RISK003",
error.Value ?? "Schema validation failed",
detail.EvaluationPath?.ToString() ?? "/");
}
}
}
}
else if (!string.IsNullOrEmpty(results.Message))
{
yield return RiskProfileIssue.Error("RISK003", results.Message, "/");
}
}
private static bool IsValidSemVer(string version)
{
var parts = version.Split(['-', '+'], 2);
return Version.TryParse(parts[0], out _);
}
}

View File

@@ -14,11 +14,15 @@
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
<EmbeddedResource Include="Schemas\policy-scoring-schema@1.json" />
<EmbeddedResource Include="Schemas\spl-schema@1.json" />
<EmbeddedResource Include="Schemas\spl-sample@1.json" />
</ItemGroup>
</Project>
<ItemGroup>
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
<EmbeddedResource Include="Schemas\policy-scoring-schema@1.json" />
<EmbeddedResource Include="Schemas\spl-schema@1.json" />
<EmbeddedResource Include="Schemas\spl-sample@1.json" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,82 @@
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.Storage;
/// <summary>
/// Repository for persisting and retrieving risk profiles.
/// </summary>
public interface IRiskProfileRepository
{
/// <summary>
/// Gets a risk profile by ID.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The profile, or null if not found.</returns>
Task<RiskProfileModel?> GetAsync(string profileId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific version of a risk profile.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="version">The semantic version.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The profile version, or null if not found.</returns>
Task<RiskProfileModel?> GetVersionAsync(string profileId, string version, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the latest version of a risk profile.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The latest profile version, or null if not found.</returns>
Task<RiskProfileModel?> GetLatestAsync(string profileId, CancellationToken cancellationToken = default);
/// <summary>
/// Lists all available risk profile IDs.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of profile IDs.</returns>
Task<IReadOnlyList<string>> ListProfileIdsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Lists all versions of a risk profile.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of profile versions ordered by version descending.</returns>
Task<IReadOnlyList<RiskProfileModel>> ListVersionsAsync(string profileId, CancellationToken cancellationToken = default);
/// <summary>
/// Saves a risk profile.
/// </summary>
/// <param name="profile">The profile to save.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if saved successfully, false if version conflict.</returns>
Task<bool> SaveAsync(RiskProfileModel profile, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a specific version of a risk profile.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="version">The version to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if deleted, false if not found.</returns>
Task<bool> DeleteVersionAsync(string profileId, string version, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes all versions of a risk profile.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if deleted, false if not found.</returns>
Task<bool> DeleteAllVersionsAsync(string profileId, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a profile exists.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the profile exists.</returns>
Task<bool> ExistsAsync(string profileId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,162 @@
using System.Collections.Concurrent;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.Storage;
/// <summary>
/// In-memory implementation of risk profile repository for testing and development.
/// </summary>
public sealed class InMemoryRiskProfileRepository : IRiskProfileRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, RiskProfileModel>> _profiles = new(StringComparer.OrdinalIgnoreCase);
public Task<RiskProfileModel?> GetAsync(string profileId, CancellationToken cancellationToken = default)
{
return GetLatestAsync(profileId, cancellationToken);
}
public Task<RiskProfileModel?> GetVersionAsync(string profileId, string version, CancellationToken cancellationToken = default)
{
if (_profiles.TryGetValue(profileId, out var versions) &&
versions.TryGetValue(version, out var profile))
{
return Task.FromResult<RiskProfileModel?>(CloneProfile(profile));
}
return Task.FromResult<RiskProfileModel?>(null);
}
public Task<RiskProfileModel?> GetLatestAsync(string profileId, CancellationToken cancellationToken = default)
{
if (!_profiles.TryGetValue(profileId, out var versions) || versions.IsEmpty)
{
return Task.FromResult<RiskProfileModel?>(null);
}
var latest = versions.Values
.OrderByDescending(p => ParseVersion(p.Version))
.FirstOrDefault();
return Task.FromResult(latest != null ? CloneProfile(latest) : null);
}
public Task<IReadOnlyList<string>> ListProfileIdsAsync(CancellationToken cancellationToken = default)
{
var ids = _profiles.Keys.ToList().AsReadOnly();
return Task.FromResult<IReadOnlyList<string>>(ids);
}
public Task<IReadOnlyList<RiskProfileModel>> ListVersionsAsync(string profileId, CancellationToken cancellationToken = default)
{
if (!_profiles.TryGetValue(profileId, out var versions))
{
return Task.FromResult<IReadOnlyList<RiskProfileModel>>(Array.Empty<RiskProfileModel>());
}
var list = versions.Values
.OrderByDescending(p => ParseVersion(p.Version))
.Select(CloneProfile)
.ToList()
.AsReadOnly();
return Task.FromResult<IReadOnlyList<RiskProfileModel>>(list);
}
public Task<bool> SaveAsync(RiskProfileModel profile, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
var versions = _profiles.GetOrAdd(profile.Id, _ => new ConcurrentDictionary<string, RiskProfileModel>(StringComparer.OrdinalIgnoreCase));
if (versions.ContainsKey(profile.Version))
{
return Task.FromResult(false);
}
versions[profile.Version] = CloneProfile(profile);
return Task.FromResult(true);
}
public Task<bool> DeleteVersionAsync(string profileId, string version, CancellationToken cancellationToken = default)
{
if (_profiles.TryGetValue(profileId, out var versions))
{
var removed = versions.TryRemove(version, out _);
if (versions.IsEmpty)
{
_profiles.TryRemove(profileId, out _);
}
return Task.FromResult(removed);
}
return Task.FromResult(false);
}
public Task<bool> DeleteAllVersionsAsync(string profileId, CancellationToken cancellationToken = default)
{
var removed = _profiles.TryRemove(profileId, out _);
return Task.FromResult(removed);
}
public Task<bool> ExistsAsync(string profileId, CancellationToken cancellationToken = default)
{
var exists = _profiles.TryGetValue(profileId, out var versions) && !versions.IsEmpty;
return Task.FromResult(exists);
}
private static Version ParseVersion(string version)
{
if (Version.TryParse(version, out var parsed))
{
return parsed;
}
var parts = version.Split(['-', '+'], 2);
if (parts.Length > 0 && Version.TryParse(parts[0], out parsed))
{
return parsed;
}
return new Version(0, 0, 0);
}
private static RiskProfileModel CloneProfile(RiskProfileModel source)
{
return new RiskProfileModel
{
Id = source.Id,
Version = source.Version,
Description = source.Description,
Extends = source.Extends,
Signals = source.Signals.Select(s => new RiskSignal
{
Name = s.Name,
Source = s.Source,
Type = s.Type,
Path = s.Path,
Transform = s.Transform,
Unit = s.Unit
}).ToList(),
Weights = new Dictionary<string, double>(source.Weights),
Overrides = new RiskOverrides
{
Severity = source.Overrides.Severity.Select(r => new SeverityOverride
{
When = new Dictionary<string, object>(r.When),
Set = r.Set
}).ToList(),
Decisions = source.Overrides.Decisions.Select(r => new DecisionOverride
{
When = new Dictionary<string, object>(r.When),
Action = r.Action,
Reason = r.Reason
}).ToList()
},
Metadata = source.Metadata != null
? new Dictionary<string, object?>(source.Metadata)
: null
};
}
}