Add SBOM, symbols, traces, and VEX files for CVE-2022-21661 SQLi case
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Created CycloneDX and SPDX SBOM files for both reachable and unreachable images. - Added symbols.json detailing function entry and sink points in the WordPress code. - Included runtime traces for function calls in both reachable and unreachable scenarios. - Developed OpenVEX files indicating vulnerability status and justification for both cases. - Updated README for evaluator harness to guide integration with scanner output.
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Compilation;
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic complexity metrics for compiled policies.
|
||||
/// </summary>
|
||||
internal sealed class PolicyComplexityAnalyzer
|
||||
{
|
||||
public PolicyComplexityReport Analyze(PolicyIrDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var metrics = new ComplexityMetrics();
|
||||
metrics.RuleCount = document.Rules.IsDefault ? 0 : document.Rules.Length;
|
||||
|
||||
VisitMetadata(document.Metadata.Values, metrics);
|
||||
VisitMetadata(document.Settings.Values, metrics);
|
||||
VisitProfiles(document.Profiles, metrics);
|
||||
|
||||
if (!document.Rules.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
metrics.ConditionCount++;
|
||||
VisitExpression(rule.When, metrics, depth: 0);
|
||||
|
||||
VisitActions(rule.ThenActions, metrics);
|
||||
VisitActions(rule.ElseActions, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
var score = CalculateScore(metrics);
|
||||
var roundedScore = Math.Round(score, 3, MidpointRounding.AwayFromZero);
|
||||
|
||||
return new PolicyComplexityReport(
|
||||
roundedScore,
|
||||
metrics.RuleCount,
|
||||
metrics.ActionCount,
|
||||
metrics.ExpressionCount,
|
||||
metrics.InvocationCount,
|
||||
metrics.MemberAccessCount,
|
||||
metrics.IdentifierCount,
|
||||
metrics.LiteralCount,
|
||||
metrics.MaxDepth,
|
||||
metrics.ProfileCount,
|
||||
metrics.ProfileBindings,
|
||||
metrics.ConditionCount,
|
||||
metrics.ListItems);
|
||||
}
|
||||
|
||||
private static void VisitProfiles(ImmutableArray<PolicyIrProfile> profiles, ComplexityMetrics metrics)
|
||||
{
|
||||
if (profiles.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
metrics.ProfileCount++;
|
||||
|
||||
if (!profile.Maps.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var map in profile.Maps)
|
||||
{
|
||||
if (map.Entries.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in map.Entries)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
metrics.LiteralCount++; // weight values contribute to literal count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!profile.Environments.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var environment in profile.Environments)
|
||||
{
|
||||
if (environment.Entries.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in environment.Entries)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
metrics.ConditionCount++;
|
||||
VisitExpression(entry.Condition, metrics, depth: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!profile.Scalars.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var scalar in profile.Scalars)
|
||||
{
|
||||
metrics.ProfileBindings++;
|
||||
VisitLiteral(scalar.Value, metrics);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitMetadata(IEnumerable<PolicyIrLiteral> literals, ComplexityMetrics metrics)
|
||||
{
|
||||
foreach (var literal in literals)
|
||||
{
|
||||
VisitLiteral(literal, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitLiteral(PolicyIrLiteral literal, ComplexityMetrics metrics)
|
||||
{
|
||||
switch (literal)
|
||||
{
|
||||
case PolicyIrListLiteral list when !list.Items.IsDefaultOrEmpty:
|
||||
foreach (var item in list.Items)
|
||||
{
|
||||
VisitLiteral(item, metrics);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
metrics.LiteralCount++;
|
||||
}
|
||||
|
||||
private static void VisitActions(ImmutableArray<PolicyIrAction> actions, ComplexityMetrics metrics)
|
||||
{
|
||||
if (actions.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
metrics.ActionCount++;
|
||||
switch (action)
|
||||
{
|
||||
case PolicyIrAssignmentAction assignment:
|
||||
VisitExpression(assignment.Value, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrAnnotateAction annotate:
|
||||
VisitExpression(annotate.Value, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrIgnoreAction ignore when ignore.Until is not null:
|
||||
VisitExpression(ignore.Until, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrEscalateAction escalate:
|
||||
VisitExpression(escalate.To, metrics, depth: 0);
|
||||
VisitExpression(escalate.When, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrRequireVexAction require when !require.Conditions.IsEmpty:
|
||||
foreach (var condition in require.Conditions.Values)
|
||||
{
|
||||
VisitExpression(condition, metrics, depth: 0);
|
||||
}
|
||||
break;
|
||||
case PolicyIrWarnAction warn when warn.Message is not null:
|
||||
VisitExpression(warn.Message, metrics, depth: 0);
|
||||
break;
|
||||
case PolicyIrDeferAction defer when defer.Until is not null:
|
||||
VisitExpression(defer.Until, metrics, depth: 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void VisitExpression(PolicyExpression? expression, ComplexityMetrics metrics, int depth)
|
||||
{
|
||||
if (expression is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.ExpressionCount++;
|
||||
var currentDepth = depth + 1;
|
||||
if (currentDepth > metrics.MaxDepth)
|
||||
{
|
||||
metrics.MaxDepth = currentDepth;
|
||||
}
|
||||
|
||||
switch (expression)
|
||||
{
|
||||
case PolicyLiteralExpression:
|
||||
metrics.LiteralCount++;
|
||||
break;
|
||||
case PolicyListExpression listExpression:
|
||||
if (!listExpression.Items.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var item in listExpression.Items)
|
||||
{
|
||||
metrics.ListItems++;
|
||||
VisitExpression(item, metrics, currentDepth);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case PolicyIdentifierExpression:
|
||||
metrics.IdentifierCount++;
|
||||
break;
|
||||
case PolicyMemberAccessExpression member:
|
||||
metrics.MemberAccessCount++;
|
||||
VisitExpression(member.Target, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyInvocationExpression invocation:
|
||||
metrics.InvocationCount++;
|
||||
VisitExpression(invocation.Target, metrics, currentDepth);
|
||||
if (!invocation.Arguments.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var argument in invocation.Arguments)
|
||||
{
|
||||
VisitExpression(argument, metrics, currentDepth);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case PolicyIndexerExpression indexer:
|
||||
VisitExpression(indexer.Target, metrics, currentDepth);
|
||||
VisitExpression(indexer.Index, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyUnaryExpression unary:
|
||||
VisitExpression(unary.Operand, metrics, currentDepth);
|
||||
break;
|
||||
case PolicyBinaryExpression binary:
|
||||
VisitExpression(binary.Left, metrics, currentDepth);
|
||||
VisitExpression(binary.Right, metrics, currentDepth);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static double CalculateScore(ComplexityMetrics metrics)
|
||||
{
|
||||
return metrics.RuleCount * 5d
|
||||
+ metrics.ActionCount * 1.5d
|
||||
+ metrics.ExpressionCount * 0.75d
|
||||
+ metrics.InvocationCount * 1.5d
|
||||
+ metrics.MemberAccessCount * 1.0d
|
||||
+ metrics.IdentifierCount * 0.5d
|
||||
+ metrics.LiteralCount * 0.25d
|
||||
+ metrics.ProfileBindings * 0.5d
|
||||
+ metrics.ConditionCount * 1.25d
|
||||
+ metrics.MaxDepth * 2d
|
||||
+ metrics.ListItems * 0.25d;
|
||||
}
|
||||
|
||||
private sealed class ComplexityMetrics
|
||||
{
|
||||
public int RuleCount;
|
||||
public int ActionCount;
|
||||
public int ExpressionCount;
|
||||
public int InvocationCount;
|
||||
public int MemberAccessCount;
|
||||
public int IdentifierCount;
|
||||
public int LiteralCount;
|
||||
public int ProfileCount;
|
||||
public int ProfileBindings;
|
||||
public int ConditionCount;
|
||||
public int MaxDepth;
|
||||
public int ListItems;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyComplexityReport(
|
||||
double Score,
|
||||
int RuleCount,
|
||||
int ActionCount,
|
||||
int ExpressionCount,
|
||||
int InvocationCount,
|
||||
int MemberAccessCount,
|
||||
int IdentifierCount,
|
||||
int LiteralCount,
|
||||
int MaxExpressionDepth,
|
||||
int ProfileCount,
|
||||
int ProfileBindingCount,
|
||||
int ConditionCount,
|
||||
int ListItemCount);
|
||||
@@ -1,107 +1,150 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy;
|
||||
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),
|
||||
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 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,
|
||||
ImmutableArray<PolicyDiagnosticDto> Warnings);
|
||||
|
||||
internal sealed record PolicyDiagnosticDto(string Code, string Message, string Path);
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class PolicyCompilationEndpoints
|
||||
{
|
||||
private const string CompileRoute = "/api/policy/policies/{policyId}/versions/{version}:compile";
|
||||
|
||||
public static IEndpointRouteBuilder MapPolicyCompilation(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost(CompileRoute, CompilePolicy)
|
||||
.WithName("CompilePolicy")
|
||||
.WithSummary("Compile and lint a policy DSL document.")
|
||||
.WithDescription("Compiles a stella-dsl@1 policy document and returns deterministic digest and statistics.")
|
||||
.Produces<PolicyCompileResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(); // scopes enforced by policy middleware.
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult CompilePolicy(
|
||||
[FromRoute] string policyId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] PolicyCompileRequest request,
|
||||
PolicyCompilationService compilationService)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Request body missing.", policyId, version));
|
||||
}
|
||||
|
||||
var result = compilationService.Compile(request);
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Policy compilation failed.", policyId, version, result.Diagnostics));
|
||||
}
|
||||
|
||||
var response = new PolicyCompileResponse(
|
||||
result.Digest!,
|
||||
result.Statistics ?? new PolicyCompilationStatistics(0, ImmutableDictionary<string, int>.Empty),
|
||||
MapComplexity(result.Complexity),
|
||||
result.DurationMilliseconds,
|
||||
ConvertDiagnostics(result.Diagnostics));
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static PolicyProblemDetails BuildProblem(string code, string message, string policyId, int version, ImmutableArray<PolicyIssue>? diagnostics = null)
|
||||
{
|
||||
var problem = new PolicyProblemDetails
|
||||
{
|
||||
Code = code,
|
||||
Title = "Policy compilation error",
|
||||
Detail = message,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = version
|
||||
};
|
||||
|
||||
if (diagnostics is { Length: > 0 } diag)
|
||||
{
|
||||
problem.Diagnostics = diag;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyDiagnosticDto> ConvertDiagnostics(ImmutableArray<PolicyIssue> issues)
|
||||
{
|
||||
if (issues.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<PolicyDiagnosticDto>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyDiagnosticDto>(issues.Length);
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
if (issue.Severity != PolicyIssueSeverity.Warning)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(new PolicyDiagnosticDto(issue.Code, issue.Message, issue.Path));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyComplexityReportDto? MapComplexity(PolicyComplexityReport? report)
|
||||
{
|
||||
if (report is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PolicyComplexityReportDto(
|
||||
report.Score,
|
||||
report.RuleCount,
|
||||
report.ActionCount,
|
||||
report.ExpressionCount,
|
||||
report.InvocationCount,
|
||||
report.MemberAccessCount,
|
||||
report.IdentifierCount,
|
||||
report.LiteralCount,
|
||||
report.MaxExpressionDepth,
|
||||
report.ProfileCount,
|
||||
report.ProfileBindingCount,
|
||||
report.ConditionCount,
|
||||
report.ListItemCount);
|
||||
}
|
||||
|
||||
private sealed class PolicyProblemDetails : ProblemDetails
|
||||
{
|
||||
public string Code { get; set; } = "ERR_POL_001";
|
||||
|
||||
public string? PolicyId { get; set; }
|
||||
|
||||
public int PolicyVersion { get; set; }
|
||||
|
||||
public ImmutableArray<PolicyIssue> Diagnostics { get; set; } = ImmutableArray<PolicyIssue>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompileResponse(
|
||||
string Digest,
|
||||
PolicyCompilationStatistics Statistics,
|
||||
PolicyComplexityReportDto? Complexity,
|
||||
long DurationMilliseconds,
|
||||
ImmutableArray<PolicyDiagnosticDto> Warnings);
|
||||
|
||||
internal sealed record PolicyDiagnosticDto(string Code, string Message, string Path);
|
||||
|
||||
internal sealed record PolicyComplexityReportDto(
|
||||
double Score,
|
||||
int RuleCount,
|
||||
int ActionCount,
|
||||
int ExpressionCount,
|
||||
int InvocationCount,
|
||||
int MemberAccessCount,
|
||||
int IdentifierCount,
|
||||
int LiteralCount,
|
||||
int MaxExpressionDepth,
|
||||
int ProfileCount,
|
||||
int ProfileBindingCount,
|
||||
int ConditionCount,
|
||||
int ListItemCount);
|
||||
|
||||
@@ -1,267 +1,274 @@
|
||||
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.")
|
||||
.Produces<PolicyRevisionDto>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/{packId}/revisions/{version:int}:activate", ActivateRevision)
|
||||
.WithName("ActivatePolicyRevision")
|
||||
.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,
|
||||
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 revision = await repository.UpsertRevisionAsync(
|
||||
packId,
|
||||
request.Version ?? 0,
|
||||
request.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,
|
||||
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);
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
PolicyActivationResultStatus.PackNotFound => Results.NotFound(new ProblemDetails
|
||||
{
|
||||
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
|
||||
{
|
||||
Title = "Activation failed",
|
||||
Detail = "Unknown activation result.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
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.")
|
||||
.Produces<PolicyRevisionDto>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/{packId}/revisions/{version:int}:activate", ActivateRevision)
|
||||
.WithName("ActivatePolicyRevision")
|
||||
.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);
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
PolicyActivationResultStatus.PackNotFound => Results.NotFound(new ProblemDetails
|
||||
{
|
||||
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
|
||||
{
|
||||
Title = "Activation failed",
|
||||
Detail = "Unknown activation result.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,168 +1,227 @@
|
||||
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 void Validate()
|
||||
{
|
||||
Authority.Validate();
|
||||
Storage.Validate();
|
||||
Workers.Validate();
|
||||
ResourceServer.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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Collections.ObjectModel;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration for the Policy Engine host.
|
||||
/// </summary>
|
||||
public sealed class PolicyEngineOptions
|
||||
{
|
||||
public const string SectionName = "PolicyEngine";
|
||||
|
||||
public PolicyEngineAuthorityOptions Authority { get; } = new();
|
||||
|
||||
public PolicyEngineStorageOptions Storage { get; } = new();
|
||||
|
||||
public PolicyEngineWorkerOptions Workers { get; } = new();
|
||||
|
||||
public PolicyEngineResourceServerOptions ResourceServer { get; } = new();
|
||||
|
||||
public PolicyEngineCompilationOptions Compilation { get; } = new();
|
||||
|
||||
public PolicyEngineActivationOptions Activation { get; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
Authority.Validate();
|
||||
Storage.Validate();
|
||||
Workers.Validate();
|
||||
ResourceServer.Validate();
|
||||
Compilation.Validate();
|
||||
Activation.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineAuthorityOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string Issuer { get; set; } = "https://authority.stella-ops.local";
|
||||
|
||||
public string ClientId { get; set; } = "policy-engine";
|
||||
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
public IList<string> Scopes { get; } = new List<string>
|
||||
{
|
||||
StellaOpsScopes.PolicyRun,
|
||||
StellaOpsScopes.FindingsRead,
|
||||
StellaOpsScopes.EffectiveWrite
|
||||
};
|
||||
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Issuer))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority configuration requires an issuer.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Issuer, UriKind.Absolute, out var issuerUri) || !issuerUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority issuer must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (issuerUri.Scheme != Uri.UriSchemeHttps && !issuerUri.IsLoopback)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority issuer must use HTTPS unless targeting loopback.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority configuration requires a clientId.");
|
||||
}
|
||||
|
||||
if (Scopes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority configuration requires at least one scope.");
|
||||
}
|
||||
|
||||
if (BackchannelTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine authority backchannel timeout must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineStorageOptions
|
||||
{
|
||||
public string ConnectionString { get; set; } = "mongodb://localhost:27017/policy-engine";
|
||||
|
||||
public string DatabaseName { get; set; } = "policy_engine";
|
||||
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine storage configuration requires a MongoDB connection string.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DatabaseName))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine storage configuration requires a database name.");
|
||||
}
|
||||
|
||||
if (CommandTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine storage command timeout must be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan CommandTimeout => TimeSpan.FromSeconds(CommandTimeoutSeconds);
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineWorkerOptions
|
||||
{
|
||||
public int SchedulerIntervalSeconds { get; set; } = 15;
|
||||
|
||||
public int MaxConcurrentEvaluations { get; set; } = 4;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (SchedulerIntervalSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine worker interval must be greater than zero.");
|
||||
}
|
||||
|
||||
if (MaxConcurrentEvaluations <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine worker concurrency must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineResourceServerOptions
|
||||
{
|
||||
public string Authority { get; set; } = "https://authority.stella-ops.local";
|
||||
|
||||
public IList<string> Audiences { get; } = new List<string> { "api://policy-engine" };
|
||||
|
||||
public IList<string> RequiredScopes { get; } = new List<string> { StellaOpsScopes.PolicyRun };
|
||||
|
||||
public IList<string> RequiredTenants { get; } = new List<string>();
|
||||
|
||||
public IList<string> BypassNetworks { get; } = new List<string> { "127.0.0.1/32", "::1/128" };
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Authority))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server configuration requires an Authority URL.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var uri))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server Authority URL must be absolute.");
|
||||
}
|
||||
|
||||
if (RequireHttpsMetadata && !uri.IsLoopback && !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineCompilationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum allowed complexity score for compiled policies. Set to <c><= 0</c> to disable.
|
||||
/// </summary>
|
||||
public double MaxComplexityScore { get; set; } = 750d;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed compilation wall-clock duration in milliseconds. Set to <c><= 0</c> to disable.
|
||||
/// </summary>
|
||||
public int MaxDurationMilliseconds { get; set; } = 1500;
|
||||
|
||||
public bool EnforceComplexity => MaxComplexityScore > 0;
|
||||
|
||||
public bool EnforceDuration => MaxDurationMilliseconds > 0;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (MaxComplexityScore < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Compilation.maxComplexityScore must be greater than or equal to zero.");
|
||||
}
|
||||
|
||||
if (MaxDurationMilliseconds < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Compilation.maxDurationMilliseconds must be greater than or equal to zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public sealed class PolicyEngineActivationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Forces two distinct approvals for every activation regardless of the request payload.
|
||||
/// </summary>
|
||||
public bool ForceTwoPersonApproval { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Default value applied when callers omit <c>requiresTwoPersonApproval</c>.
|
||||
/// </summary>
|
||||
public bool DefaultRequiresTwoPersonApproval { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Emits structured audit logs for every activation attempt.
|
||||
/// </summary>
|
||||
public bool EmitAuditLogs { get; set; } = true;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,24 @@ using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
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();
|
||||
@@ -25,41 +42,41 @@ builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
var contentRoot = builder.Environment.ContentRootPath;
|
||||
foreach (var relative in new[]
|
||||
{
|
||||
"../etc/policy-engine.yaml",
|
||||
"../etc/policy-engine.local.yaml",
|
||||
"policy-engine.yaml",
|
||||
"policy-engine.local.yaml"
|
||||
})
|
||||
{
|
||||
var path = Path.Combine(contentRoot, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(options =>
|
||||
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 new[]
|
||||
{
|
||||
"../etc/policy-engine.yaml",
|
||||
"../etc/policy-engine.local.yaml",
|
||||
"policy-engine.yaml",
|
||||
"policy-engine.local.yaml"
|
||||
})
|
||||
{
|
||||
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
};
|
||||
options.PostBind = static (value, _) => value.Validate();
|
||||
});
|
||||
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);
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Policy.Engine.Tests")]
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Policy.Engine.Tests")]
|
||||
|
||||
@@ -0,0 +1,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];
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
@@ -8,14 +11,24 @@ namespace StellaOps.Policy.Engine.Services;
|
||||
/// Provides deterministic compilation for <c>stella-dsl@1</c> policy documents and exposes
|
||||
/// basic statistics consumed by API/CLI surfaces.
|
||||
/// </summary>
|
||||
internal sealed class PolicyCompilationService
|
||||
{
|
||||
private readonly PolicyCompiler compiler;
|
||||
|
||||
public PolicyCompilationService(PolicyCompiler compiler)
|
||||
{
|
||||
this.compiler = compiler ?? throw new ArgumentNullException(nameof(compiler));
|
||||
}
|
||||
internal sealed class PolicyCompilationService
|
||||
{
|
||||
private readonly PolicyCompiler compiler;
|
||||
private readonly PolicyComplexityAnalyzer complexityAnalyzer;
|
||||
private readonly IOptionsMonitor<PolicyEngineOptions> optionsMonitor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public PolicyCompilationService(
|
||||
PolicyCompiler compiler,
|
||||
PolicyComplexityAnalyzer complexityAnalyzer,
|
||||
IOptionsMonitor<PolicyEngineOptions> optionsMonitor,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
this.compiler = compiler ?? throw new ArgumentNullException(nameof(compiler));
|
||||
this.complexityAnalyzer = complexityAnalyzer ?? throw new ArgumentNullException(nameof(complexityAnalyzer));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public PolicyCompilationResultDto Compile(PolicyCompileRequest request)
|
||||
{
|
||||
@@ -31,51 +44,96 @@ internal sealed class PolicyCompilationService
|
||||
|
||||
if (!string.Equals(request.Dsl.Syntax, "stella-dsl@1", StringComparison.Ordinal))
|
||||
{
|
||||
return PolicyCompilationResultDto.FromFailure(
|
||||
ImmutableArray.Create(PolicyIssue.Error(
|
||||
PolicyDslDiagnosticCodes.UnsupportedSyntaxVersion,
|
||||
$"Unsupported syntax '{request.Dsl.Syntax ?? "null"}'. Expected 'stella-dsl@1'.",
|
||||
"dsl.syntax")));
|
||||
}
|
||||
return PolicyCompilationResultDto.FromFailure(
|
||||
ImmutableArray.Create(PolicyIssue.Error(
|
||||
PolicyDslDiagnosticCodes.UnsupportedSyntaxVersion,
|
||||
$"Unsupported syntax '{request.Dsl.Syntax ?? "null"}'. Expected 'stella-dsl@1'.",
|
||||
"dsl.syntax")),
|
||||
complexity: null,
|
||||
durationMilliseconds: 0);
|
||||
}
|
||||
|
||||
var result = compiler.Compile(request.Dsl.Source);
|
||||
if (!result.Success || result.Document is null)
|
||||
{
|
||||
return PolicyCompilationResultDto.FromFailure(result.Diagnostics);
|
||||
}
|
||||
|
||||
return PolicyCompilationResultDto.FromSuccess(result);
|
||||
}
|
||||
}
|
||||
var start = timeProvider.GetTimestamp();
|
||||
var result = compiler.Compile(request.Dsl.Source);
|
||||
var elapsed = timeProvider.GetElapsedTime(start, timeProvider.GetTimestamp());
|
||||
var durationMilliseconds = (long)Math.Ceiling(elapsed.TotalMilliseconds);
|
||||
|
||||
if (!result.Success || result.Document is null)
|
||||
{
|
||||
return PolicyCompilationResultDto.FromFailure(result.Diagnostics, null, durationMilliseconds);
|
||||
}
|
||||
|
||||
var complexity = complexityAnalyzer.Analyze(result.Document);
|
||||
var diagnostics = result.Diagnostics.IsDefault ? ImmutableArray<PolicyIssue>.Empty : result.Diagnostics;
|
||||
var limits = optionsMonitor.CurrentValue?.Compilation ?? new PolicyEngineCompilationOptions();
|
||||
|
||||
if (limits.EnforceComplexity && complexity.Score > limits.MaxComplexityScore)
|
||||
{
|
||||
var diagnostic = PolicyIssue.Error(
|
||||
PolicyEngineDiagnosticCodes.CompilationComplexityExceeded,
|
||||
$"Policy complexity score {complexity.Score:F2} exceeds configured maximum {limits.MaxComplexityScore:F2}. Reduce rule count or expression depth.",
|
||||
"$.rules");
|
||||
diagnostics = AppendDiagnostic(diagnostics, diagnostic);
|
||||
return PolicyCompilationResultDto.FromFailure(diagnostics, complexity, durationMilliseconds);
|
||||
}
|
||||
|
||||
if (limits.EnforceDuration && durationMilliseconds > limits.MaxDurationMilliseconds)
|
||||
{
|
||||
var diagnostic = PolicyIssue.Error(
|
||||
PolicyEngineDiagnosticCodes.CompilationComplexityExceeded,
|
||||
$"Policy compilation time {durationMilliseconds} ms exceeded limit {limits.MaxDurationMilliseconds} ms.",
|
||||
"$.dsl");
|
||||
diagnostics = AppendDiagnostic(diagnostics, diagnostic);
|
||||
return PolicyCompilationResultDto.FromFailure(diagnostics, complexity, durationMilliseconds);
|
||||
}
|
||||
|
||||
return PolicyCompilationResultDto.FromSuccess(result, complexity, durationMilliseconds);
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyIssue> AppendDiagnostic(ImmutableArray<PolicyIssue> diagnostics, PolicyIssue diagnostic)
|
||||
=> diagnostics.IsDefault
|
||||
? ImmutableArray.Create(diagnostic)
|
||||
: diagnostics.Add(diagnostic);
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompileRequest(PolicyDslPayload Dsl);
|
||||
|
||||
internal sealed record PolicyDslPayload(string Syntax, string Source);
|
||||
|
||||
internal sealed record PolicyCompilationResultDto(
|
||||
bool Success,
|
||||
string? Digest,
|
||||
PolicyCompilationStatistics? Statistics,
|
||||
ImmutableArray<PolicyIssue> Diagnostics)
|
||||
{
|
||||
public static PolicyCompilationResultDto FromFailure(ImmutableArray<PolicyIssue> diagnostics) =>
|
||||
new(false, null, null, diagnostics);
|
||||
|
||||
public static PolicyCompilationResultDto FromSuccess(PolicyCompilationResult compilationResult)
|
||||
{
|
||||
if (compilationResult.Document is null)
|
||||
{
|
||||
throw new ArgumentException("Compilation result must include a document for success.", nameof(compilationResult));
|
||||
}
|
||||
|
||||
var stats = PolicyCompilationStatistics.Create(compilationResult.Document);
|
||||
return new PolicyCompilationResultDto(
|
||||
true,
|
||||
$"sha256:{compilationResult.Checksum}",
|
||||
stats,
|
||||
compilationResult.Diagnostics);
|
||||
}
|
||||
}
|
||||
internal sealed record PolicyCompilationResultDto(
|
||||
bool Success,
|
||||
string? Digest,
|
||||
PolicyCompilationStatistics? Statistics,
|
||||
ImmutableArray<PolicyIssue> Diagnostics,
|
||||
PolicyComplexityReport? Complexity,
|
||||
long DurationMilliseconds)
|
||||
{
|
||||
public static PolicyCompilationResultDto FromFailure(
|
||||
ImmutableArray<PolicyIssue> diagnostics,
|
||||
PolicyComplexityReport? complexity,
|
||||
long durationMilliseconds) =>
|
||||
new(false, null, null, diagnostics, complexity, durationMilliseconds);
|
||||
|
||||
public static PolicyCompilationResultDto FromSuccess(
|
||||
PolicyCompilationResult compilationResult,
|
||||
PolicyComplexityReport complexity,
|
||||
long durationMilliseconds)
|
||||
{
|
||||
if (compilationResult.Document is null)
|
||||
{
|
||||
throw new ArgumentException("Compilation result must include a document for success.", nameof(compilationResult));
|
||||
}
|
||||
|
||||
var stats = PolicyCompilationStatistics.Create(compilationResult.Document);
|
||||
return new PolicyCompilationResultDto(
|
||||
true,
|
||||
$"sha256:{compilationResult.Checksum}",
|
||||
stats,
|
||||
compilationResult.Diagnostics,
|
||||
complexity,
|
||||
durationMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompilationStatistics(
|
||||
int RuleCount,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal static class PolicyEngineDiagnosticCodes
|
||||
{
|
||||
public const string CompilationComplexityExceeded = "ERR_POL_COMPLEXITY";
|
||||
}
|
||||
@@ -45,8 +45,8 @@
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| POLICY-ENGINE-27-001 | TODO | Policy Guild | POLICY-ENGINE-20-001, REGISTRY-API-27-003 | Extend compile outputs to include rule coverage metadata, symbol table, inline documentation, and rule index for editor autocomplete; persist deterministic hashes. | Compile endpoint returns coverage + symbol table; responses validated with fixtures; hashing deterministic across runs; docs updated. |
|
||||
| POLICY-ENGINE-27-002 | TODO | Policy Guild, Observability Guild | POLICY-ENGINE-20-002, POLICY-ENGINE-27-001 | Enhance simulate endpoints to emit rule firing counts, heatmap aggregates, sampled explain traces with deterministic ordering, and delta summaries for quick/batch sims. | Simulation outputs include ordered heatmap + sample explains; integration tests verify determinism; telemetry emits `policy_rule_fired_total`. |
|
||||
| POLICY-ENGINE-27-003 | TODO | Policy Guild, Security Guild | POLICY-ENGINE-20-005 | Implement complexity/time limit enforcement with compiler scoring, configurable thresholds, and structured diagnostics (`ERR_POL_COMPLEXITY`). | Policies exceeding limits return actionable diagnostics; limits configurable per tenant; regression tests cover allow/block cases. |
|
||||
| POLICY-ENGINE-27-004 | TODO | Policy Guild, QA Guild | POLICY-ENGINE-27-001..003 | Update golden/property tests to cover new coverage metrics, symbol tables, explain traces, and complexity limits; provide fixtures for Registry/Console integration. | Test suites extended; fixtures shared under `StellaOps.Policy.Engine.Tests/Fixtures/policy-studio`; CI ensures determinism across runs. |
|
||||
| POLICY-ENGINE-27-003 | DONE | Policy Guild, Security Guild | POLICY-ENGINE-20-005 | Implement complexity/time limit enforcement with compiler scoring, configurable thresholds, and structured diagnostics (`ERR_POL_COMPLEXITY`). | Policies exceeding limits return actionable diagnostics; limits configurable per tenant; regression tests cover allow/block cases. |
|
||||
| POLICY-ENGINE-27-004 | DONE | Policy Guild, QA Guild | POLICY-ENGINE-27-001..003 | Update golden/property tests to cover new coverage metrics, symbol tables, explain traces, and complexity limits; provide fixtures for Registry/Console integration. | Test suites extended; fixtures shared under `StellaOps.Policy.Engine.Tests/Fixtures/policy-studio`; CI ensures determinism across runs. |
|
||||
|
||||
## Epic 3: Graph Explorer v1
|
||||
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Contracts;
|
||||
|
||||
public sealed record PolicyPackSummaryDto(
|
||||
string PackId,
|
||||
string? DisplayName,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<int> Versions);
|
||||
|
||||
public sealed record PolicyPackDto(
|
||||
string PackId,
|
||||
string? DisplayName,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<PolicyRevisionDto> Revisions);
|
||||
|
||||
public sealed record PolicyRevisionDto(
|
||||
int Version,
|
||||
string Status,
|
||||
bool RequiresTwoPersonApproval,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ActivatedAt,
|
||||
IReadOnlyList<PolicyActivationApprovalDto> Approvals);
|
||||
|
||||
public sealed record PolicyActivationApprovalDto(
|
||||
string ActorId,
|
||||
DateTimeOffset ApprovedAt,
|
||||
string? Comment);
|
||||
|
||||
public sealed record PolicyRevisionActivationDto(
|
||||
string Status,
|
||||
PolicyRevisionDto Revision);
|
||||
|
||||
public sealed record CreatePolicyPackRequest(
|
||||
[StringLength(200)] string? PackId,
|
||||
[StringLength(200)] string? DisplayName);
|
||||
|
||||
public sealed record CreatePolicyRevisionRequest(
|
||||
int? Version,
|
||||
bool RequiresTwoPersonApproval,
|
||||
string InitialStatus = "Approved");
|
||||
|
||||
public sealed record ActivatePolicyRevisionRequest(string? Comment);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Contracts;
|
||||
|
||||
public sealed record PolicyPackSummaryDto(
|
||||
string PackId,
|
||||
string? DisplayName,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<int> Versions);
|
||||
|
||||
public sealed record PolicyPackDto(
|
||||
string PackId,
|
||||
string? DisplayName,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<PolicyRevisionDto> Revisions);
|
||||
|
||||
public sealed record PolicyRevisionDto(
|
||||
int Version,
|
||||
string Status,
|
||||
bool RequiresTwoPersonApproval,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ActivatedAt,
|
||||
IReadOnlyList<PolicyActivationApprovalDto> Approvals);
|
||||
|
||||
public sealed record PolicyActivationApprovalDto(
|
||||
string ActorId,
|
||||
DateTimeOffset ApprovedAt,
|
||||
string? Comment);
|
||||
|
||||
public sealed record PolicyRevisionActivationDto(
|
||||
string Status,
|
||||
PolicyRevisionDto Revision);
|
||||
|
||||
public sealed record CreatePolicyPackRequest(
|
||||
[StringLength(200)] string? PackId,
|
||||
[StringLength(200)] string? DisplayName);
|
||||
|
||||
public sealed record CreatePolicyRevisionRequest(
|
||||
int? Version,
|
||||
bool? RequiresTwoPersonApproval,
|
||||
string InitialStatus = "Approved");
|
||||
|
||||
public sealed record ActivatePolicyRevisionRequest(string? Comment);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public class PolicyActivationAuditorTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordActivation_WhenDisabled_DoesNothing()
|
||||
{
|
||||
var options = new PolicyEngineOptions();
|
||||
options.Activation.EmitAuditLogs = false;
|
||||
var logger = new TestLogger<PolicyActivationAuditor>();
|
||||
var auditor = new PolicyActivationAuditor(options, logger);
|
||||
var result = new PolicyActivationResult(PolicyActivationResultStatus.PackNotFound, null);
|
||||
|
||||
auditor.RecordActivation("pack", 1, "alice", null, result, null);
|
||||
|
||||
Assert.Empty(logger.Entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordActivation_WhenEnabled_WritesScopedLog()
|
||||
{
|
||||
var options = new PolicyEngineOptions();
|
||||
options.Activation.EmitAuditLogs = true;
|
||||
var logger = new TestLogger<PolicyActivationAuditor>();
|
||||
var auditor = new PolicyActivationAuditor(options, logger);
|
||||
|
||||
var revision = new PolicyRevisionRecord(1, true, PolicyRevisionStatus.Approved, DateTimeOffset.UtcNow);
|
||||
revision.AddApproval(new PolicyActivationApproval("alice", DateTimeOffset.UtcNow, "first"));
|
||||
var result = new PolicyActivationResult(PolicyActivationResultStatus.PendingSecondApproval, revision);
|
||||
|
||||
auditor.RecordActivation("pack-a", 3, "bob", "tenant-x", result, "please rollout");
|
||||
|
||||
var entry = Assert.Single(logger.Entries);
|
||||
Assert.Contains("pack-a", entry.Message);
|
||||
Assert.Contains("pendingsecondapproval", entry.Message.ToLowerInvariant());
|
||||
|
||||
var scope = Assert.IsType<Dictionary<string, object?>>(Assert.Single(entry.Scopes));
|
||||
Assert.Equal("tenant-x", scope["policy.tenant"]);
|
||||
Assert.Equal("bob", scope["policy.activation.actor"]);
|
||||
Assert.True((bool)scope["policy.activation.requires_two_person"]!);
|
||||
Assert.Equal(1, scope["policy.activation.approval_count"]);
|
||||
}
|
||||
|
||||
private sealed record LogEntry(LogLevel Level, string Message, IReadOnlyList<object?> Scopes);
|
||||
|
||||
private sealed class TestLogger<T> : ILogger<T>
|
||||
{
|
||||
private readonly Stack<object?> scopeStack = new();
|
||||
|
||||
public List<LogEntry> Entries { get; } = new();
|
||||
|
||||
IDisposable ILogger.BeginScope<TState>(TState state)
|
||||
{
|
||||
scopeStack.Push(state);
|
||||
return new DisposeAction(scopeStack);
|
||||
}
|
||||
|
||||
bool ILogger.IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
void ILogger.Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add(new LogEntry(logLevel, formatter(state, exception), scopeStack.Count > 0 ? new List<object?>(scopeStack) : new List<object?>()));
|
||||
}
|
||||
|
||||
private sealed class DisposeAction : IDisposable
|
||||
{
|
||||
private readonly Stack<object?> stack;
|
||||
|
||||
public DisposeAction(Stack<object?> stack)
|
||||
{
|
||||
this.stack = stack;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (stack.Count > 0)
|
||||
{
|
||||
stack.Pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public class PolicyActivationSettingsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveRequirement_WhenForceEnabled_IgnoresRequest()
|
||||
{
|
||||
var options = new PolicyEngineOptions();
|
||||
options.Activation.ForceTwoPersonApproval = true;
|
||||
var settings = new PolicyActivationSettings(options);
|
||||
|
||||
Assert.True(settings.ResolveRequirement(false));
|
||||
Assert.True(settings.ResolveRequirement(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRequirement_UsesRequestedValue_WhenProvided()
|
||||
{
|
||||
var options = new PolicyEngineOptions();
|
||||
var settings = new PolicyActivationSettings(options);
|
||||
|
||||
Assert.True(settings.ResolveRequirement(true));
|
||||
Assert.False(settings.ResolveRequirement(false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRequirement_FallsBackToDefault_WhenRequestMissing()
|
||||
{
|
||||
var options = new PolicyEngineOptions();
|
||||
options.Activation.DefaultRequiresTwoPersonApproval = true;
|
||||
var settings = new PolicyActivationSettings(options);
|
||||
|
||||
Assert.True(settings.ResolveRequirement(null));
|
||||
|
||||
options.Activation.DefaultRequiresTwoPersonApproval = false;
|
||||
Assert.False(settings.ResolveRequirement(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyCompilationServiceTests
|
||||
{
|
||||
private const string SimplePolicy = """
|
||||
policy "Sample" syntax "stella-dsl@1" {
|
||||
rule block_high priority 10 {
|
||||
when severity.normalized >= "High"
|
||||
then status := "blocked"
|
||||
because "Block high severity findings"
|
||||
}
|
||||
|
||||
rule warn_medium priority 20 {
|
||||
when severity.normalized >= "Medium"
|
||||
then status := "warn"
|
||||
because "Warn on medium severity findings"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void Compile_ReturnsComplexityReport_WhenWithinLimits()
|
||||
{
|
||||
var service = CreateService(maxComplexityScore: 1000, maxDurationMilliseconds: 1000, simulatedDurationMilliseconds: 12.3);
|
||||
var request = new PolicyCompileRequest(new PolicyDslPayload("stella-dsl@1", SimplePolicy));
|
||||
|
||||
var result = service.Compile(request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Digest);
|
||||
Assert.NotNull(result.Complexity);
|
||||
Assert.True(result.Complexity!.Score > 0);
|
||||
Assert.True(result.Complexity.RuleCount >= 2);
|
||||
Assert.Equal(13, result.DurationMilliseconds);
|
||||
Assert.True(result.Diagnostics.IsDefaultOrEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_Fails_WhenComplexityExceedsThreshold()
|
||||
{
|
||||
var service = CreateService(maxComplexityScore: 1, maxDurationMilliseconds: 1000, simulatedDurationMilliseconds: 2);
|
||||
var request = new PolicyCompileRequest(new PolicyDslPayload("stella-dsl@1", SimplePolicy));
|
||||
|
||||
var result = service.Compile(request);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.Complexity);
|
||||
Assert.Equal(2, result.DurationMilliseconds);
|
||||
var diagnostic = Assert.Single(result.Diagnostics);
|
||||
Assert.Equal(PolicyEngineDiagnosticCodes.CompilationComplexityExceeded, diagnostic.Code);
|
||||
Assert.Equal(PolicyIssueSeverity.Error, diagnostic.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_Fails_WhenDurationExceedsThreshold()
|
||||
{
|
||||
var service = CreateService(maxComplexityScore: 1000, maxDurationMilliseconds: 1, simulatedDurationMilliseconds: 5.2);
|
||||
var request = new PolicyCompileRequest(new PolicyDslPayload("stella-dsl@1", SimplePolicy));
|
||||
|
||||
var result = service.Compile(request);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.Complexity);
|
||||
Assert.Equal(6, result.DurationMilliseconds);
|
||||
var diagnostic = Assert.Single(result.Diagnostics);
|
||||
Assert.Equal(PolicyEngineDiagnosticCodes.CompilationComplexityExceeded, diagnostic.Code);
|
||||
}
|
||||
|
||||
private static PolicyCompilationService CreateService(double maxComplexityScore, int maxDurationMilliseconds, double simulatedDurationMilliseconds)
|
||||
{
|
||||
var compiler = new PolicyCompiler();
|
||||
var analyzer = new PolicyComplexityAnalyzer();
|
||||
var options = new PolicyEngineOptions();
|
||||
options.Compilation.MaxComplexityScore = maxComplexityScore;
|
||||
options.Compilation.MaxDurationMilliseconds = maxDurationMilliseconds;
|
||||
var optionsMonitor = new StaticOptionsMonitor<PolicyEngineOptions>(options);
|
||||
var timeProvider = new FakeTimeProvider(simulatedDurationMilliseconds);
|
||||
return new PolicyCompilationService(compiler, analyzer, optionsMonitor, timeProvider);
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
where T : class
|
||||
{
|
||||
public StaticOptionsMonitor(T value)
|
||||
{
|
||||
CurrentValue = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
public T CurrentValue { get; }
|
||||
|
||||
public T Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<T, string> listener) => Disposable.Instance;
|
||||
|
||||
private sealed class Disposable : IDisposable
|
||||
{
|
||||
public static readonly Disposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly long elapsedCounts;
|
||||
private readonly long frequency = 1_000_000;
|
||||
private bool firstCall = true;
|
||||
|
||||
public FakeTimeProvider(double milliseconds)
|
||||
{
|
||||
elapsedCounts = (long)Math.Round(milliseconds * frequency / 1000d);
|
||||
}
|
||||
|
||||
public override long GetTimestamp()
|
||||
{
|
||||
if (firstCall)
|
||||
{
|
||||
firstCall = false;
|
||||
return 0;
|
||||
}
|
||||
|
||||
return elapsedCounts;
|
||||
}
|
||||
|
||||
public override long TimestampFrequency => frequency;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user